Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2ca0db9ca | |||
| 615d9cdbd1 | |||
| d0dede92fe |
@@ -32,10 +32,14 @@ jobs:
|
||||
cache: npm
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
- name: Generate icons subset
|
||||
run: npm run generate:icons
|
||||
- name: SvelteKit sync
|
||||
run: npx svelte-kit sync
|
||||
- name: Typecheck
|
||||
- name: Typecheck (svelte-check)
|
||||
run: npx svelte-check --threshold error
|
||||
- name: Typecheck (tsc)
|
||||
run: npx tsc --noEmit
|
||||
|
||||
deploy:
|
||||
needs: verify
|
||||
|
||||
Generated
+2371
-6
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -4,10 +4,19 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"predev": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"preview": "vite preview",
|
||||
"start": "node build",
|
||||
"generate:api-types": "node scripts/generate-api-types.mjs",
|
||||
"generate:icons": "node scripts/generate-icons.mjs",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"typecheck": "svelte-kit sync && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "svelte-kit sync || true"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -17,11 +26,15 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"openapi-typescript": "^7.4.0",
|
||||
"svelte": "^5.28.2",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
@@ -31,8 +44,8 @@
|
||||
"blurhash": "^2.0.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"marked": "^17.0.2",
|
||||
"sanitize-html": "^2.13.1",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tree-shake @iconify-json/mdi: scan src/ for `icon="mdi:..."` references and
|
||||
* emit a minimal IconifyJSON containing only those icons. Saves ~3 MB in the
|
||||
* client bundle vs. shipping the full 7000+ icon collection.
|
||||
*
|
||||
* Dynamic icons (from CMS data, conditional names) cannot be detected
|
||||
* statically — list them under EXTRA_ICONS below. Anything missing falls
|
||||
* through to the iconify API at runtime, which adds a network roundtrip but
|
||||
* does not break.
|
||||
*
|
||||
* Run via `npm run generate:icons`. Hooked into `prebuild`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT, 'src');
|
||||
const ICONS_JSON = path.join(ROOT, 'node_modules', '@iconify-json', 'mdi', 'icons.json');
|
||||
const OUT_FILE = path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.json');
|
||||
|
||||
/**
|
||||
* Icons that appear via dynamic bindings (CMS data, conditional expressions)
|
||||
* and would otherwise miss the static scan. Add new ones here when editors
|
||||
* pick icons that appear blank — better than shipping the full collection.
|
||||
*/
|
||||
const EXTRA_ICONS = [
|
||||
// PostActions: dynamic share/copy state
|
||||
'share-variant-outline',
|
||||
'link-variant',
|
||||
// Search: file-type fallback
|
||||
'file-outline',
|
||||
// Common social icons editors pick (Header.svelte: social.icon)
|
||||
'facebook',
|
||||
'instagram',
|
||||
'twitter',
|
||||
'youtube',
|
||||
'linkedin',
|
||||
'mastodon',
|
||||
'github',
|
||||
'email',
|
||||
'email-outline',
|
||||
'phone',
|
||||
'whatsapp',
|
||||
'telegram',
|
||||
// Common file-type icons (FilesBlock: f.iconName)
|
||||
'file-pdf-box',
|
||||
'file-word-box',
|
||||
'file-excel-box',
|
||||
'file-image',
|
||||
'file-video',
|
||||
'file-music',
|
||||
// Tag/link/banner fallbacks (LinkListBlock, Tags, DeadlineBannerBlock)
|
||||
'link',
|
||||
'tag-outline',
|
||||
'flag-outline',
|
||||
'information-outline',
|
||||
];
|
||||
|
||||
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...walk(full));
|
||||
} else if (/\.(svelte|ts|tsx|js|mjs)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scanStatic() {
|
||||
const found = new Set();
|
||||
for (const file of walk(SRC_DIR)) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
let m;
|
||||
while ((m = STATIC_PATTERN.exec(content)) !== null) {
|
||||
found.add(m[1]);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(ICONS_JSON)) {
|
||||
console.error(
|
||||
`[generate-icons] missing ${ICONS_JSON} — run \`npm install\` first.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const collection = JSON.parse(fs.readFileSync(ICONS_JSON, 'utf8'));
|
||||
const wanted = new Set([...scanStatic(), ...EXTRA_ICONS]);
|
||||
|
||||
const icons = {};
|
||||
const aliases = {};
|
||||
const missing = [];
|
||||
|
||||
for (const name of wanted) {
|
||||
if (collection.icons[name]) {
|
||||
icons[name] = collection.icons[name];
|
||||
} else if (collection.aliases?.[name]) {
|
||||
aliases[name] = collection.aliases[name];
|
||||
const parent = collection.aliases[name].parent;
|
||||
if (parent && collection.icons[parent] && !icons[parent]) {
|
||||
icons[parent] = collection.icons[parent];
|
||||
}
|
||||
} else {
|
||||
missing.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
const subset = {
|
||||
prefix: collection.prefix,
|
||||
width: collection.width,
|
||||
height: collection.height,
|
||||
icons,
|
||||
...(Object.keys(aliases).length ? { aliases } : {}),
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(subset, null, 2) + '\n');
|
||||
|
||||
const sizeBefore = fs.statSync(ICONS_JSON).size;
|
||||
const sizeAfter = fs.statSync(OUT_FILE).size;
|
||||
console.log(
|
||||
`[generate-icons] ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, OUT_FILE)}`,
|
||||
);
|
||||
console.log(
|
||||
`[generate-icons] ${(sizeBefore / 1024 / 1024).toFixed(2)} MB → ${(sizeAfter / 1024).toFixed(1)} KB (${((1 - sizeAfter / sizeBefore) * 100).toFixed(1)}% smaller)`,
|
||||
);
|
||||
if (missing.length) {
|
||||
console.warn(
|
||||
`[generate-icons] ${missing.length} icon(s) not found in MDI: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+29
-2
@@ -2,6 +2,8 @@ import { getTranslationBundleBySlug } from '$lib/cms';
|
||||
import { isCmsAvailable } from '$lib/cms-health.server';
|
||||
import { maintenanceResponse } from '$lib/maintenance.server';
|
||||
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
|
||||
import { env as envPublic } from '$env/dynamic/public';
|
||||
import { logWarn } from '$lib/log.server';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,22 @@ function isMaintenanceSimulation(pathname: string, search: URLSearchParams): boo
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy /blog/* + /p/* Pfade auf das neue /posts/* Schema mappen.
|
||||
* Liefert Ziel-Pfad oder null. Permanente 301-Redirects → werden vom
|
||||
* Browser/CDN gecacht, daher kein Render-Aufruf nötig.
|
||||
*/
|
||||
function legacyRedirectTarget(pathname: string): string | null {
|
||||
if (pathname === '/blog' || pathname === '/blog/') return '/posts/';
|
||||
const m1 = pathname.match(/^\/blog\/page\/([^/]+)\/?$/);
|
||||
if (m1) return `/posts/page/${m1[1]}/`;
|
||||
const m2 = pathname.match(/^\/blog\/tag\/([^/]+)\/?$/);
|
||||
if (m2) return `/posts/tag/${m2[1]}/`;
|
||||
const m3 = pathname.match(/^\/p\/([^/]+)\/?$/);
|
||||
if (m3) return `/post/${m3[1]}/`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Simulation: erlaubt manuelles Triggern der Maintenance-Seite ohne
|
||||
// CMS abzuschalten. /maintenance oder ?cms_down=1.
|
||||
@@ -44,6 +62,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return maintenanceResponse(200);
|
||||
}
|
||||
|
||||
// Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
|
||||
// SvelteKit-Routing/CMS-Calls dafür unnötig.
|
||||
const redirectTo = legacyRedirectTarget(event.url.pathname);
|
||||
if (redirectTo) {
|
||||
const dest = `${redirectTo}${event.url.search}`;
|
||||
return new Response(null, { status: 301, headers: { location: dest } });
|
||||
}
|
||||
|
||||
// Echter Ausfall: vor dem ersten CMS-Call prüfen ob die API antwortet.
|
||||
// Cached, daher kein Roundtrip pro Request.
|
||||
if (event.request.method === 'GET' && shouldHealthCheck(event.url.pathname)) {
|
||||
@@ -69,8 +95,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
} else {
|
||||
event.locals.translations = {};
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
event.locals.translations = {};
|
||||
logWarn('hooks.translations', err);
|
||||
}
|
||||
|
||||
const response = await resolve(event, {
|
||||
@@ -116,7 +143,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// headers alone so production hardening (X-Frame-Options / CSP from Caddy)
|
||||
// stays intact.
|
||||
if (isPreviewDraft) {
|
||||
const parent = (process.env.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim();
|
||||
const parent = (envPublic.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim();
|
||||
response.headers.set(
|
||||
'content-security-policy',
|
||||
`frame-ancestors 'self' ${parent || '*'}`,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Stub für `$env/dynamic/{public,private}`. Wird ausschließlich von Vitest
|
||||
* via Alias aufgelöst (siehe `vitest.config.ts`). Im Produktivbuild verwendet
|
||||
* SvelteKit das echte Modul.
|
||||
*
|
||||
* Tests können `process.env` direkt setzen — Proxy reicht alle Reads
|
||||
* unverändert durch.
|
||||
*/
|
||||
export const env = new Proxy({} as Record<string, string | undefined>, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop !== 'string') return undefined;
|
||||
return process.env[prop];
|
||||
},
|
||||
has(_target, prop) {
|
||||
if (typeof prop !== 'string') return false;
|
||||
return prop in process.env;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { constantTimeEqual } from './auth-token.server';
|
||||
|
||||
describe('constantTimeEqual', () => {
|
||||
it('matches identical strings', () => {
|
||||
expect(constantTimeEqual('abc123', 'abc123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects different strings of same length', () => {
|
||||
expect(constantTimeEqual('abc123', 'abc124')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects different lengths', () => {
|
||||
expect(constantTimeEqual('abc', 'abcd')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null/undefined input', () => {
|
||||
expect(constantTimeEqual(null, 'x')).toBe(false);
|
||||
expect(constantTimeEqual('x', undefined)).toBe(false);
|
||||
expect(constantTimeEqual(null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(constantTimeEqual('', '')).toBe(true);
|
||||
expect(constantTimeEqual('', 'x')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles unicode', () => {
|
||||
expect(constantTimeEqual('für', 'für')).toBe(true);
|
||||
expect(constantTimeEqual('für', 'fur')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Konstantzeitvergleich für Auth-Tokens (Webhook-Secrets, API-Keys).
|
||||
*
|
||||
* `a !== b` leakt timing-Information; `crypto.timingSafeEqual` vergleicht in
|
||||
* O(n) immer alle Bytes. Längen-Mismatch wird vorab geprüft, weil
|
||||
* `timingSafeEqual` bei ungleicher Länge wirft.
|
||||
*/
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export function constantTimeEqual(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
const ab = Buffer.from(a, 'utf8');
|
||||
const bb = Buffer.from(b, 'utf8');
|
||||
if (ab.length !== bb.length) return false;
|
||||
return timingSafeEqual(ab, bb);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const ORIG_ENV = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env.PUBLIC_CMS_URL = 'https://cms.example.com';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIG_ENV };
|
||||
});
|
||||
|
||||
async function loadResolver() {
|
||||
const mod = await import('./cms-source.server');
|
||||
return mod.resolveCmsSource;
|
||||
}
|
||||
|
||||
describe('resolveCmsSource', () => {
|
||||
it('accepts asset filename', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('photo.jpg')).toBe('https://cms.example.com/api/assets/photo.jpg');
|
||||
});
|
||||
|
||||
it('accepts absolute path on CMS host', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('/api/assets/foo.png')).toBe('https://cms.example.com/api/assets/foo.png');
|
||||
});
|
||||
|
||||
it('accepts full URL on CMS host', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('https://cms.example.com/some/path')).toBe('https://cms.example.com/some/path');
|
||||
});
|
||||
|
||||
it('rejects foreign host', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('https://attacker.com/exploit.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects http when CMS uses https', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('http://cms.example.com/foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects protocol-relative URLs', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('//attacker.com/x.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects path traversal in filename', async () => {
|
||||
const resolve = await loadResolver();
|
||||
expect(resolve('../etc/passwd')).toBeNull();
|
||||
expect(resolve('./hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('encodes "javascript:" payloads as harmless asset filename', async () => {
|
||||
const resolve = await loadResolver();
|
||||
// Kein Schema-Marker (kein "://") → wird als Filename behandelt und
|
||||
// url-encoded an /api/assets angehängt. Server liefert 404 zurück,
|
||||
// keine Code-Ausführung möglich.
|
||||
expect(resolve('javascript:alert(1)')).toContain('/api/assets/javascript%3Aalert');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Allowlist-Resolver für `?src=...`-Query-Parameter in den Image- und File-Proxy-
|
||||
* Routen. Verhindert SSRF: Angreifer könnten sonst beliebige URLs an die
|
||||
* CMS-API durchreichen lassen (interne Hosts, Cloud-Metadata-Endpunkte,
|
||||
* Cache-Pollution).
|
||||
*
|
||||
* Erlaubt:
|
||||
* - Asset-Dateiname ("foo.jpg") → {CMS}/api/assets/foo.jpg
|
||||
* - Absoluter Pfad ("/api/assets/...") → {CMS}/api/assets/...
|
||||
* - Vollständige URL exakt unter CMS-Host → unverändert
|
||||
*
|
||||
* Abgelehnt:
|
||||
* - Fremde Hosts, andere Schemata
|
||||
* - Protokoll-relative URLs ("//host/...")
|
||||
* - Pfad-Traversal ("..", führender Punkt)
|
||||
*/
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
export function getCmsBaseUrl(): string {
|
||||
return (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
'http://localhost:3000'
|
||||
).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function resolveCmsSource(src: string): string | null {
|
||||
if (src.startsWith('//')) return null;
|
||||
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
let candidate: URL;
|
||||
let cmsHost: URL;
|
||||
try {
|
||||
candidate = new URL(src);
|
||||
cmsHost = new URL(getCmsBaseUrl());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (candidate.host !== cmsHost.host) return null;
|
||||
if (candidate.protocol !== cmsHost.protocol) return null;
|
||||
return src;
|
||||
}
|
||||
if (src.includes('://') || src.includes('..') || src.startsWith('.')) return null;
|
||||
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
|
||||
}
|
||||
+40
-22
@@ -15,6 +15,24 @@ const getBaseUrl = (): string => {
|
||||
return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Default-Timeout für CMS-Calls (ms). Verhindert hängende SSR-Worker bei
|
||||
* langsamem oder unerreichbarem CMS. Override per Call via `init.signal`
|
||||
* oder global per `PUBLIC_CMS_FETCH_TIMEOUT_MS`.
|
||||
*/
|
||||
const FETCH_TIMEOUT_MS =
|
||||
Number(env.PUBLIC_CMS_FETCH_TIMEOUT_MS) || 5_000;
|
||||
|
||||
/**
|
||||
* Wrapper um `fetch` mit Default-Timeout. Wenn ein eigener `signal`
|
||||
* mitgegeben wird, bleibt der unverändert; sonst wird `AbortSignal.timeout`
|
||||
* gesetzt. Fehler-Mapping wie nativer fetch — Timeout wirft `AbortError`.
|
||||
*/
|
||||
async function cmsFetch(input: string | URL, init?: RequestInit): Promise<Response> {
|
||||
const signal = init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
return fetch(input, { ...init, signal });
|
||||
}
|
||||
|
||||
/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
|
||||
export type PageEntry = components["schemas"]["page"];
|
||||
|
||||
@@ -125,7 +143,7 @@ export function cacheStats(): { size: number; keys: string[] } {
|
||||
export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
if (openApiCache && !import.meta.env.DEV) return openApiCache;
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api-docs/openapi.json`);
|
||||
const res = await cmsFetch(`${base}/api-docs/openapi.json`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
|
||||
@@ -142,7 +160,7 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
export async function getPages(): Promise<PageEntry[]> {
|
||||
return cached("page", "page:list:", async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page`);
|
||||
const res = await cmsFetch(`${base}/api/content/page`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -168,7 +186,7 @@ export async function getPageConfigs(options?: {
|
||||
const url = new URL(`${base}/api/content/page_config`);
|
||||
if (locale) url.searchParams.set("_locale", locale);
|
||||
url.searchParams.set("_per_page", String(per));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -195,7 +213,7 @@ export async function getPageConfigBySlug(
|
||||
);
|
||||
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -279,7 +297,7 @@ export type BatchResponse = { results: Record<string, BatchResult> };
|
||||
export async function batchFetch(requests: BatchRequest[]): Promise<BatchResponse> {
|
||||
if (requests.length === 0) return { results: {} };
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/_batch`, {
|
||||
const res = await cmsFetch(`${base}/api/content/_batch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requests }),
|
||||
@@ -328,7 +346,7 @@ export async function getPageBySlug(
|
||||
u.searchParams.set("_fields", options.fields.join(","));
|
||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||
return fetch(u.toString());
|
||||
return cmsFetch(u.toString());
|
||||
};
|
||||
const normSlug = normalizePageSlug(slug);
|
||||
let res = await trySlug(normSlug);
|
||||
@@ -383,7 +401,7 @@ export async function getPageStubs(options?: { locale?: string }): Promise<PageS
|
||||
url.searchParams.set("_fields", "_slug,slug,linkName,name,headline");
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
if (locale) url.searchParams.set("_locale", locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`);
|
||||
const data = (await res.json()) as { items?: PageStub[] };
|
||||
return data.items ?? [];
|
||||
@@ -406,7 +424,7 @@ export async function getPostStubs(options?: { locale?: string }): Promise<PostS
|
||||
url.searchParams.set("_fields", "_slug,slug,linkName,headline");
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
if (locale) url.searchParams.set("_locale", locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`);
|
||||
const data = (await res.json()) as { items?: PostStub[] };
|
||||
return data.items ?? [];
|
||||
@@ -449,7 +467,7 @@ export async function getNavigations(options?: {
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
url.searchParams.set("_page", String(opts.page ?? 1));
|
||||
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -509,7 +527,7 @@ export async function getNavigationBySlug(
|
||||
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as NavigationEntry;
|
||||
@@ -534,7 +552,7 @@ export async function getFooterBySlug(
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
url.searchParams.set("_resolve", options.resolve.join(","));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -577,7 +595,7 @@ export async function getPostBySlug(
|
||||
u.searchParams.set("_fields", options.fields.join(","));
|
||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||
return fetch(u.toString());
|
||||
return cmsFetch(u.toString());
|
||||
};
|
||||
const normSlug = normalizePostSlug(slug);
|
||||
let res = await trySlug(normSlug);
|
||||
@@ -645,7 +663,7 @@ export async function getPosts(
|
||||
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
|
||||
if (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth));
|
||||
if (fieldsVal) url.searchParams.set("_fields", fieldsVal);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error("RustyCMS list post failed");
|
||||
const data = (await res.json()) as { items?: PostEntry[] };
|
||||
return data.items ?? [];
|
||||
@@ -659,7 +677,7 @@ export type TagEntry = components["schemas"]["tag"];
|
||||
export async function getTags(): Promise<TagEntry[]> {
|
||||
return cached("tag", "tag:list:", async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/tag`);
|
||||
const res = await cmsFetch(`${base}/api/content/tag`);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: TagEntry[] };
|
||||
return data.items ?? [];
|
||||
@@ -681,7 +699,7 @@ export async function getTextFragmentBySlug(
|
||||
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -707,7 +725,7 @@ export async function getFullwidthBannerBySlug(
|
||||
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -733,7 +751,7 @@ export async function getTranslationBySlug(
|
||||
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as TranslationEntry;
|
||||
@@ -761,7 +779,7 @@ export async function getTranslationBundleBySlug(
|
||||
);
|
||||
url.searchParams.set("_resolve", "all");
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as TranslationBundleEntry;
|
||||
@@ -787,7 +805,7 @@ export async function getCalendarBySlug(
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CalendarEntry;
|
||||
@@ -806,7 +824,7 @@ export async function getCalendarItemBySlug(
|
||||
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CalendarItemEntry;
|
||||
@@ -824,7 +842,7 @@ export async function getCalendarItems(
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
url.searchParams.set("_resolve", "all");
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: CalendarItemEntry[] };
|
||||
return data.items ?? [];
|
||||
@@ -853,7 +871,7 @@ export async function getCommentCounts(
|
||||
url.searchParams.set("_environment", options.environment);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) return {};
|
||||
const data = (await res.json()) as { counts?: Record<string, number> };
|
||||
return data.counts ?? {};
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
* und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `<picture>` mit WebP-Sources
|
||||
* und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `<img>` (legacy).
|
||||
*/
|
||||
import { marked } from "marked";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import type { FullwidthBannerEntry } from "$lib/cms";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
interface ResponsiveBannerImage {
|
||||
sources: { type: string; srcset: string }[];
|
||||
fallback: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import { fade } from "svelte/transition";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types";
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const descriptionHtml = $derived(
|
||||
block.description && typeof block.description === "string"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { MarkdownBlockData } from "$lib/block-types";
|
||||
|
||||
let { block, class: className = "" }: { block: MarkdownBlockData; class?: string } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const html = $derived(
|
||||
block.resolvedContent ??
|
||||
(block.content && typeof block.content === "string"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
@@ -10,8 +10,6 @@
|
||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
function organisationLogoField(o: OrganisationEntry) {
|
||||
return extractCmsImageField(o.logo);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import PostCard from "../PostCard.svelte";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { PostOverviewBlockData } from "$lib/block-types";
|
||||
@@ -11,7 +11,6 @@
|
||||
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
|
||||
const t = useTranslate();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const textHtml = $derived(
|
||||
block.text && typeof block.text === "string"
|
||||
? (marked.parse(block.text) as string)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type {
|
||||
SearchableTextBlockData,
|
||||
@@ -28,8 +28,6 @@
|
||||
}
|
||||
const helpTooltipText = $derived(t(T.searchable_text_help));
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
|
||||
const fragments = $derived.by(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import { fade } from "svelte/transition";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type {
|
||||
@@ -71,8 +71,6 @@
|
||||
return id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg` : null;
|
||||
}
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const descriptionHtml = $derived(
|
||||
block.description && typeof block.description === "string"
|
||||
|
||||
@@ -8,9 +8,10 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const CACHE_DIR = process.env.FILE_CACHE_DIR?.trim() || '.cache/files';
|
||||
const MAX_MEMORY_ENTRIES = Number(process.env.FILE_CACHE_MAX_MEMORY) || 50;
|
||||
const CACHE_DIR = env.FILE_CACHE_DIR?.trim() || '.cache/files';
|
||||
const MAX_MEMORY_ENTRIES = Number(env.FILE_CACHE_MAX_MEMORY) || 50;
|
||||
|
||||
export interface CachedFile {
|
||||
buffer: Buffer;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"prefix": "mdi",
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"icons": {
|
||||
"chevron-right": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6z\"/>"
|
||||
},
|
||||
"magnify": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5l-1.5 1.5l-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16A6.5 6.5 0 0 1 3 9.5A6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14S14 12 14 9.5S12 5 9.5 5\"/>"
|
||||
},
|
||||
"close": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z\"/>"
|
||||
},
|
||||
"calendar": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M19 19H5V8h14m-3-7v2H8V1H6v2H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-1V1m-1 11h-5v5h5z\"/>"
|
||||
},
|
||||
"chevron-down": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z\"/>"
|
||||
},
|
||||
"calendar-clock": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M15 13h1.5v2.82l2.44 1.41l-.75 1.3L15 16.69zm4-5H5v11h4.67c-.43-.91-.67-1.93-.67-3a7 7 0 0 1 7-7c1.07 0 2.09.24 3 .67zM5 21a2 2 0 0 1-2-2V5c0-1.11.89-2 2-2h1V1h2v2h8V1h2v2h1a2 2 0 0 1 2 2v6.1c1.24 1.26 2 2.99 2 4.9a7 7 0 0 1-7 7c-1.91 0-3.64-.76-4.9-2zm11-9.85A4.85 4.85 0 0 0 11.15 16c0 2.68 2.17 4.85 4.85 4.85A4.85 4.85 0 0 0 20.85 16c0-2.68-2.17-4.85-4.85-4.85\"/>"
|
||||
},
|
||||
"rss": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27zm0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93z\"/>"
|
||||
},
|
||||
"pencil-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"m14.06 9l.94.94L5.92 19H5v-.92zm3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83l3.75 3.75l1.83-1.83c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29m-3.6 3.19L3 17.25V21h3.75L17.81 9.94z\"/>"
|
||||
},
|
||||
"trash-can-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3zM7 6h10v13H7zm2 2v9h2V8zm4 0v9h2V8z\"/>"
|
||||
},
|
||||
"clock-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M12 20a8 8 0 0 0 8-8a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10a10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67l-.75 1.23L11 13V7z\"/>"
|
||||
},
|
||||
"alert-circle-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M11 15h2v2h-2zm0-8h2v6h-2zm1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8\"/>"
|
||||
},
|
||||
"comment-text-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12zM6 7h12v2H6zm0 4h9v2H6z\"/>"
|
||||
},
|
||||
"reply": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11\"/>"
|
||||
},
|
||||
"shield-check-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M21 11c0 5.55-3.84 10.74-9 12c-5.16-1.26-9-6.45-9-12V5l9-4l9 4zm-9 10c3.75-1 7-5.46 7-9.78V6.3l-7-3.12L5 6.3v4.92C5 15.54 8.25 20 12 21m-2-4l-4-4l1.41-1.41L10 14.17l6.59-6.59L18 9\"/>"
|
||||
},
|
||||
"map-marker": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M12 11.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5M12 2a7 7 0 0 0-7 7c0 5.25 7 13 7 13s7-7.75 7-13a7 7 0 0 0-7-7\"/>"
|
||||
},
|
||||
"cursor-default-click": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M10.76 8.69a.76.76 0 0 0-.76.76V20.9c0 .42.34.76.76.76c.19 0 .35-.06.48-.16l1.91-1.55l1.66 3.62c.13.27.4.43.69.43c.11 0 .22 0 .33-.08l2.76-1.28c.38-.18.56-.64.36-1.01L17.28 18l2.41-.45a.9.9 0 0 0 .43-.26c.27-.32.23-.79-.12-1.08l-8.74-7.35l-.01.01a.76.76 0 0 0-.49-.18M15 10V8h5v2zm-1.17-5.24l2.83-2.83l1.41 1.41l-2.83 2.83zM10 0h2v5h-2zM3.93 14.66l2.83-2.83l1.41 1.41l-2.83 2.83zm0-11.32l1.41-1.41l2.83 2.83l-1.41 1.41zM7 10H2V8h5z\"/>"
|
||||
},
|
||||
"open-in-new": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M14 3v2h3.59l-9.83 9.83l1.41 1.41L19 6.41V10h2V3m-2 16H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2z\"/>"
|
||||
},
|
||||
"menu": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z\"/>"
|
||||
},
|
||||
"chevron-left": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z\"/>"
|
||||
},
|
||||
"comment-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z\"/>"
|
||||
},
|
||||
"printer-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M19 8c1.66 0 3 1.34 3 3v6h-4v4H6v-4H2v-6c0-1.66 1.34-3 3-3h1V3h12v5zM8 5v3h8V5zm8 14v-4H8v4zm2-4h2v-4c0-.55-.45-1-1-1H5c-.55 0-1 .45-1 1v4h2v-2h12zm1-3.5c0 .55-.45 1-1 1s-1-.45-1-1s.45-1 1-1s1 .45 1 1\"/>"
|
||||
},
|
||||
"download-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M13 5v6h1.17L12 13.17L9.83 11H11V5zm2-2H9v6H5l7 7l7-7h-4zm4 15H5v2h14z\"/>"
|
||||
},
|
||||
"help-circle-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M11 18h2v-2h-2zm1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-14a4 4 0 0 0-4 4h2a2 2 0 0 1 2-2a2 2 0 0 1 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5a4 4 0 0 0-4-4\"/>"
|
||||
},
|
||||
"check": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>"
|
||||
},
|
||||
"content-copy": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12z\"/>"
|
||||
},
|
||||
"play": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>"
|
||||
},
|
||||
"home": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8z\"/>"
|
||||
},
|
||||
"home-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"m12 5.69l5 4.5V18h-2v-6H9v6H7v-7.81zM12 3L2 12h3v8h6v-6h2v6h6v-8h3\"/>"
|
||||
},
|
||||
"file-document-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>"
|
||||
},
|
||||
"share-variant-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81c1.66 0 3-1.34 3-3s-1.34-3-3-3s-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91s-1.31-2.92-2.92-2.92M18 4c.55 0 1 .45 1 1s-.45 1-1 1s-1-.45-1-1s.45-1 1-1M6 13c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m12 7c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1\"/>"
|
||||
},
|
||||
"link-variant": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M10.59 13.41c.41.39.41 1.03 0 1.42c-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0a5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.98 2.98 0 0 0 0-4.24a2.98 2.98 0 0 0-4.24 0l-3.53 3.53a2.98 2.98 0 0 0 0 4.24m2.82-4.24c.39-.39 1.03-.39 1.42 0a5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0a5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.98 2.98 0 0 0 0 4.24a2.98 2.98 0 0 0 4.24 0l3.53-3.53a2.98 2.98 0 0 0 0-4.24a.973.973 0 0 1 0-1.42\"/>"
|
||||
},
|
||||
"file-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zm4 18H6V4h7v5h5z\"/>"
|
||||
},
|
||||
"facebook": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M12 2.04c-5.5 0-10 4.49-10 10.02c0 5 3.66 9.15 8.44 9.9v-7H7.9v-2.9h2.54V9.85c0-2.51 1.49-3.89 3.78-3.89c1.09 0 2.23.19 2.23.19v2.47h-1.26c-1.24 0-1.63.77-1.63 1.56v1.88h2.78l-.45 2.9h-2.33v7a10 10 0 0 0 8.44-9.9c0-5.53-4.5-10.02-10-10.02\"/>"
|
||||
},
|
||||
"instagram": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4zm9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8A1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5a5 5 0 0 1-5 5a5 5 0 0 1-5-5a5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3\"/>"
|
||||
},
|
||||
"twitter": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M22.46 6c-.77.35-1.6.58-2.46.69c.88-.53 1.56-1.37 1.88-2.38c-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29c0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15c0 1.49.75 2.81 1.91 3.56c-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.2 4.2 0 0 1-1.93.07a4.28 4.28 0 0 0 4 2.98a8.52 8.52 0 0 1-5.33 1.84q-.51 0-1.02-.06C3.44 20.29 5.7 21 8.12 21C16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56c.84-.6 1.56-1.36 2.14-2.23\"/>"
|
||||
},
|
||||
"youtube": {
|
||||
"body": "<path fill=\"currentColor\" d=\"m10 15l5.19-3L10 9zm11.56-7.83c.13.47.22 1.1.28 1.9c.07.8.1 1.49.1 2.09L22 12c0 2.19-.16 3.8-.44 4.83c-.25.9-.83 1.48-1.73 1.73c-.47.13-1.33.22-2.65.28c-1.3.07-2.49.1-3.59.1L12 19c-4.19 0-6.8-.16-7.83-.44c-.9-.25-1.48-.83-1.73-1.73c-.13-.47-.22-1.1-.28-1.9c-.07-.8-.1-1.49-.1-2.09L2 12c0-2.19.16-3.8.44-4.83c.25-.9.83-1.48 1.73-1.73c.47-.13 1.33-.22 2.65-.28c1.3-.07 2.49-.1 3.59-.1L12 5c4.19 0 6.8.16 7.83.44c.9.25 1.48.83 1.73 1.73\"/>"
|
||||
},
|
||||
"linkedin": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93zM6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37z\"/>"
|
||||
},
|
||||
"mastodon": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M20.94 14c-.28 1.41-2.44 2.96-4.97 3.26c-1.31.15-2.6.3-3.97.24c-2.25-.11-4-.54-4-.54v.62c.32 2.22 2.22 2.35 4.03 2.42c1.82.05 3.44-.46 3.44-.46l.08 1.65s-1.28.68-3.55.81c-1.25.07-2.81-.03-4.62-.5c-3.92-1.05-4.6-5.24-4.7-9.5l-.01-3.43c0-4.34 2.83-5.61 2.83-5.61C6.95 2.3 9.41 2 11.97 2h.06c2.56 0 5.02.3 6.47.96c0 0 2.83 1.27 2.83 5.61c0 0 .04 3.21-.39 5.43M18 8.91c0-1.08-.3-1.91-.85-2.56c-.56-.63-1.3-.96-2.23-.96c-1.06 0-1.87.41-2.42 1.23l-.5.88l-.5-.88c-.56-.82-1.36-1.23-2.43-1.23c-.92 0-1.66.33-2.23.96C6.29 7 6 7.83 6 8.91v5.26h2.1V9.06c0-1.06.45-1.62 1.36-1.62c1 0 1.5.65 1.5 1.93v2.79h2.07V9.37c0-1.28.5-1.93 1.51-1.93c.9 0 1.35.56 1.35 1.62v5.11H18z\"/>"
|
||||
},
|
||||
"github": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2\"/>"
|
||||
},
|
||||
"email": {
|
||||
"body": "<path fill=\"currentColor\" d=\"m20 8l-8 5l-8-5V6l8 5l8-5m0-2H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2\"/>"
|
||||
},
|
||||
"email-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z\"/>"
|
||||
},
|
||||
"phone": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M6.62 10.79c1.44 2.83 3.76 5.15 6.59 6.59l2.2-2.2c.28-.28.67-.36 1.02-.25c1.12.37 2.32.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57c.11.35.03.74-.25 1.02z\"/>"
|
||||
},
|
||||
"whatsapp": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M12.04 2c-5.46 0-9.91 4.45-9.91 9.91c0 1.75.46 3.45 1.32 4.95L2.05 22l5.25-1.38c1.45.79 3.08 1.21 4.74 1.21c5.46 0 9.91-4.45 9.91-9.91c0-2.65-1.03-5.14-2.9-7.01A9.82 9.82 0 0 0 12.04 2m.01 1.67c2.2 0 4.26.86 5.82 2.42a8.23 8.23 0 0 1 2.41 5.83c0 4.54-3.7 8.23-8.24 8.23c-1.48 0-2.93-.39-4.19-1.15l-.3-.17l-3.12.82l.83-3.04l-.2-.32a8.2 8.2 0 0 1-1.26-4.38c.01-4.54 3.7-8.24 8.25-8.24M8.53 7.33c-.16 0-.43.06-.66.31c-.22.25-.87.86-.87 2.07c0 1.22.89 2.39 1 2.56c.14.17 1.76 2.67 4.25 3.73c.59.27 1.05.42 1.41.53c.59.19 1.13.16 1.56.1c.48-.07 1.46-.6 1.67-1.18s.21-1.07.15-1.18c-.07-.1-.23-.16-.48-.27c-.25-.14-1.47-.74-1.69-.82c-.23-.08-.37-.12-.56.12c-.16.25-.64.81-.78.97c-.15.17-.29.19-.53.07c-.26-.13-1.06-.39-2-1.23c-.74-.66-1.23-1.47-1.38-1.72c-.12-.24-.01-.39.11-.5c.11-.11.27-.29.37-.44c.13-.14.17-.25.25-.41c.08-.17.04-.31-.02-.43c-.06-.11-.56-1.35-.77-1.84c-.2-.48-.4-.42-.56-.43c-.14 0-.3-.01-.47-.01\"/>"
|
||||
},
|
||||
"telegram": {
|
||||
"body": "<path d=\"M9.78 18.65l.28-4.23l7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3L3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z\" fill=\"currentColor\"/>",
|
||||
"hidden": true
|
||||
},
|
||||
"file-pdf-box": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m-9.5 8.5c0 .8-.7 1.5-1.5 1.5H7v2H5.5V9H8c.8 0 1.5.7 1.5 1.5zm5 2c0 .8-.7 1.5-1.5 1.5h-2.5V9H13c.8 0 1.5.7 1.5 1.5zm4-3H17v1h1.5V13H17v2h-1.5V9h3zm-6.5 0h1v3h-1zm-5 0h1v1H7z\"/>"
|
||||
},
|
||||
"file-word-box": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M15.5 17H14l-2-7.5l-2 7.5H8.5L6.1 7h1.7l1.54 7.5L11.3 7h1.4l1.97 7.5L16.2 7h1.7M19 3H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2\"/>"
|
||||
},
|
||||
"file-excel-box": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M16.2 17h-2L12 13.2L9.8 17h-2l3.2-5l-3.2-5h2l2.2 3.8L14.2 7h2L13 12m6-9H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2\"/>"
|
||||
},
|
||||
"file-image": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m0 18h12v-8l-4 4l-2-2zM8 9a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>"
|
||||
},
|
||||
"file-video": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m11 17v-6l-3 2.2V13H7v6h7v-2.2z\"/>"
|
||||
},
|
||||
"file-music": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zm-1 11h-2v5a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2c.4 0 .7.1 1 .3V11h3zm0-4V3.5L18.5 9z\"/>"
|
||||
},
|
||||
"link": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7a5 5 0 0 0-5 5a5 5 0 0 0 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1M8 13h8v-2H8zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4a5 5 0 0 0 5-5a5 5 0 0 0-5-5\"/>"
|
||||
},
|
||||
"tag-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"m21.41 11.58l-9-9A2 2 0 0 0 11 2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 .59 1.42l9 9A2 2 0 0 0 13 22a2 2 0 0 0 1.41-.59l7-7A2 2 0 0 0 22 13a2 2 0 0 0-.59-1.42M13 20l-9-9V4h7l9 9M6.5 5A1.5 1.5 0 1 1 5 6.5A1.5 1.5 0 0 1 6.5 5\"/>"
|
||||
},
|
||||
"flag-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>"
|
||||
},
|
||||
"information-outline": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M11 9h2V7h-2m1 13c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m-1 15h2v-6h-2z\"/>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Iconify Offline: MDI-Collection für Server und Client registrieren.
|
||||
* Muss an zwei Stellen importiert werden (Side-Effect-Import), damit
|
||||
* <Icon icon="mdi:…" /> überall gleich rendert (kein Hydration-Mismatch):
|
||||
* 1. Layout.astro (--- Block) → läuft beim SSR
|
||||
* 2. Header.svelte (oder andere frühe client:load-Komponente) → läuft im Browser
|
||||
* Danach können alle Svelte-Komponenten Icon ohne API nutzen.
|
||||
* Iconify-Subset für MDI-Icons. Statt der kompletten Collection (~3 MB) wird
|
||||
* nur eine generierte Untermenge eingebunden — siehe `scripts/generate-icons.mjs`
|
||||
* und `iconify-mdi-subset.generated.json`. Statisch verwendete Icons werden
|
||||
* automatisch erkannt; Icons aus CMS-Daten / dynamischen Expressions stehen
|
||||
* unter `EXTRA_ICONS` im Generator.
|
||||
*
|
||||
* Side-Effect-Import: an zwei Stellen einbinden, damit SSR und Client-Hydration
|
||||
* dieselbe Collection sehen (kein Hydration-Mismatch):
|
||||
* 1. `+layout.svelte` / `+error.svelte` (top-level import) → Server + Client
|
||||
* 2. überall sonst implizit über das Layout
|
||||
*
|
||||
* Fehlt ein Icon, fällt @iconify/svelte zur Laufzeit auf api.iconify.design
|
||||
* zurück. Lieber `EXTRA_ICONS` ergänzen + neu generieren.
|
||||
*/
|
||||
import { addCollection } from "@iconify/svelte";
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi";
|
||||
import { addCollection } from '@iconify/svelte';
|
||||
import type { IconifyJSON } from '@iconify/types';
|
||||
import mdiSubset from './iconify-mdi-subset.generated.json';
|
||||
|
||||
addCollection(mdiIcons);
|
||||
addCollection(mdiSubset as IconifyJSON);
|
||||
|
||||
@@ -11,9 +11,10 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const CACHE_DIR = process.env.IMAGE_CACHE_DIR?.trim() || '.cache/images';
|
||||
const MAX_MEMORY_ENTRIES = Number(process.env.IMAGE_CACHE_MAX_MEMORY) || 200;
|
||||
const CACHE_DIR = env.IMAGE_CACHE_DIR?.trim() || '.cache/images';
|
||||
const MAX_MEMORY_ENTRIES = Number(env.IMAGE_CACHE_MAX_MEMORY) || 200;
|
||||
|
||||
export interface CachedImage {
|
||||
buffer: Buffer;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Minimaler strukturierter Logger für Server-Code. Stdout/stderr in einem
|
||||
* konsistenten Format, sodass Caddy/Docker-Aggregation davor zurechtkommt.
|
||||
*
|
||||
* Bewusst keine externe Lib (pino, winston) — Adapter-Node läuft lean,
|
||||
* und das kleine Volumen rechtfertigt keinen extra Dep + Bundle-Overhead.
|
||||
*
|
||||
* Nutzung in `catch`-Blöcken statt swallowen:
|
||||
*
|
||||
* } catch (err) {
|
||||
* logWarn('cms.batchFetch', err, { collection });
|
||||
* }
|
||||
*
|
||||
* Der Scope (`'cms.batchFetch'`) ist freier Text — Konvention: `area.action`.
|
||||
*/
|
||||
|
||||
type LogContext = Record<string, unknown>;
|
||||
|
||||
function format(level: string, scope: string, err: unknown, ctx?: LogContext): string {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error && err.stack ? err.stack : undefined;
|
||||
const parts: string[] = [`[${level}] ${scope}: ${message}`];
|
||||
if (ctx && Object.keys(ctx).length) {
|
||||
parts.push(JSON.stringify(ctx));
|
||||
}
|
||||
if (stack && level === 'error') {
|
||||
parts.push(stack);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function logWarn(scope: string, err: unknown, ctx?: LogContext): void {
|
||||
console.warn(format('warn', scope, err, ctx));
|
||||
}
|
||||
|
||||
export function logError(scope: string, err: unknown, ctx?: LogContext): void {
|
||||
console.error(format('error', scope, err, ctx));
|
||||
}
|
||||
|
||||
export function logInfo(scope: string, message: string, ctx?: LogContext): void {
|
||||
const parts: string[] = [`[info] ${scope}: ${message}`];
|
||||
if (ctx && Object.keys(ctx).length) parts.push(JSON.stringify(ctx));
|
||||
console.log(parts.join(' '));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Zentral konfigurierter `marked`-Parser. Wichtig: deaktiviert das
|
||||
* Rendern von **rohem HTML** in Markdown-Quellen — sonst kann ein
|
||||
* Editor (oder ein kompromittiertes Editor-Konto) `<script>` oder
|
||||
* `<iframe>`-Tags direkt einschleusen. Marked rendert sie standardmäßig
|
||||
* wörtlich durch.
|
||||
*
|
||||
* `marked` ist Singleton: einmaliger Side-Effect-Import setzt die
|
||||
* Renderer-Hooks global. Komponenten und Loader importieren `marked`
|
||||
* von hier statt direkt von "marked".
|
||||
*
|
||||
* Komponenten, die `marked.parse(...)` aufrufen, sollten das Resultat
|
||||
* weiterhin nur über `{@html}` einsetzen — der Sanitizer hier deckt das
|
||||
* Markdown-→-HTML-Mapping ab. Roh-HTML-Felder (HtmlBlock) werden zusätzlich
|
||||
* über `$lib/sanitize-blocks.server` gefiltert.
|
||||
*/
|
||||
import { marked } from 'marked';
|
||||
|
||||
let configured = false;
|
||||
|
||||
function configure() {
|
||||
if (configured) return;
|
||||
marked.setOptions({ gfm: true });
|
||||
marked.use({
|
||||
renderer: {
|
||||
// Inline raw HTML: `<...>` → bleibt wörtlich als Text stehen.
|
||||
html() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
});
|
||||
configured = true;
|
||||
}
|
||||
|
||||
configure();
|
||||
|
||||
export { marked };
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Server-only Bild-Helfer: Asset-Meta vom CMS, Blurhash-LQIP, Responsive-Srcset.
|
||||
* Nur in `+page.server.ts` / `+layout.server.ts` importieren — nutzt sharp + blurhash + process.env.
|
||||
* Nur in `+page.server.ts` / `+layout.server.ts` importieren — nutzt sharp + blurhash.
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
} from './rusty-image';
|
||||
|
||||
function getCmsBaseUrl(): string {
|
||||
return (env.PUBLIC_CMS_URL || process.env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
return (env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export interface AssetMeta {
|
||||
|
||||
@@ -199,8 +199,7 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo
|
||||
...transformParams,
|
||||
};
|
||||
|
||||
const { marked } = await import('marked');
|
||||
marked.setOptions({ gfm: true });
|
||||
const { marked } = await import('./markdown-safe');
|
||||
|
||||
for (const content of rows) {
|
||||
for (const item of content) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeBlocks, sanitizeUntrustedHtml } from './sanitize-blocks.server';
|
||||
|
||||
describe('sanitizeUntrustedHtml', () => {
|
||||
it('strips <script>', () => {
|
||||
const out = sanitizeUntrustedHtml('<p>ok</p><script>alert(1)</script>');
|
||||
expect(out).not.toContain('script');
|
||||
expect(out).toContain('ok');
|
||||
});
|
||||
|
||||
it('strips on* event handlers', () => {
|
||||
const out = sanitizeUntrustedHtml('<a href="#" onclick="alert(1)">x</a>');
|
||||
expect(out).not.toContain('onclick');
|
||||
});
|
||||
|
||||
it('strips javascript: URLs', () => {
|
||||
const out = sanitizeUntrustedHtml('<a href="javascript:alert(1)">x</a>');
|
||||
expect(out).not.toContain('javascript');
|
||||
});
|
||||
|
||||
it('keeps allowed tags + attributes', () => {
|
||||
const out = sanitizeUntrustedHtml('<p class="foo"><strong>bold</strong></p>');
|
||||
expect(out).toContain('<p class="foo">');
|
||||
expect(out).toContain('<strong>bold</strong>');
|
||||
});
|
||||
|
||||
it('allows iframes only from approved hosts', () => {
|
||||
const ok = sanitizeUntrustedHtml('<iframe src="https://www.youtube.com/embed/x"></iframe>');
|
||||
expect(ok).toContain('youtube.com');
|
||||
const bad = sanitizeUntrustedHtml('<iframe src="https://evil.com/x"></iframe>');
|
||||
expect(bad).not.toContain('evil');
|
||||
});
|
||||
|
||||
it('adds rel=noopener on target=_blank', () => {
|
||||
const out = sanitizeUntrustedHtml('<a href="https://x.com" target="_blank">x</a>');
|
||||
expect(out).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeBlocks (walker)', () => {
|
||||
it('mutates html-block fields in place', () => {
|
||||
const tree = {
|
||||
row1Content: [
|
||||
{ _type: 'html', html: '<p>safe</p><script>x</script>' },
|
||||
{ _type: 'markdown', content: 'untouched' },
|
||||
],
|
||||
};
|
||||
sanitizeBlocks(tree);
|
||||
expect(tree.row1Content[0].html).not.toContain('script');
|
||||
expect(tree.row1Content[0].html).toContain('safe');
|
||||
expect((tree.row1Content[1] as { content: string }).content).toBe('untouched');
|
||||
});
|
||||
|
||||
it('walks nested arrays', () => {
|
||||
const tree = {
|
||||
sections: [
|
||||
{ items: [{ _type: 'html', html: '<img src=x onerror=alert(1)>' }] },
|
||||
],
|
||||
};
|
||||
sanitizeBlocks(tree);
|
||||
const html = (tree.sections[0].items[0] as { html: string }).html;
|
||||
expect(html).not.toContain('onerror');
|
||||
});
|
||||
|
||||
it('handles non-object inputs gracefully', () => {
|
||||
expect(() => sanitizeBlocks(null)).not.toThrow();
|
||||
expect(() => sanitizeBlocks('string')).not.toThrow();
|
||||
expect(() => sanitizeBlocks([1, 2, 3])).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Walkt einen vom CMS gelieferten Page-/Post-Content-Tree und sanitisiert
|
||||
* Felder, die später roh über `{@html}` gerendert werden — primär das
|
||||
* `html`-Feld von HtmlBlocks.
|
||||
*
|
||||
* Wird in den Server-Loads aufgerufen (`+layout.server.ts`, `+page.server.ts`),
|
||||
* **nachdem** `resolveContentImages` etc. den Tree fertig aufgelöst haben.
|
||||
* Die Daten landen anschließend als sanitiertes HTML in der Page-Data, sodass
|
||||
* Svelte-Komponenten beim Render (Server + Client) keine zusätzliche Filter-
|
||||
* Logik brauchen.
|
||||
*
|
||||
* Server-only (`sanitize-html` braucht Node-APIs). Komponenten dürfen das
|
||||
* Modul daher nicht importieren — der `.server.ts`-Suffix erzwingt das.
|
||||
*/
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
const HTML_BLOCK_OPTIONS: sanitizeHtml.IOptions = {
|
||||
// Allowlist: bewusst restriktiv. Alles, was nicht in `allowedTags` steht,
|
||||
// wird entfernt. `allowedSchemes` blockiert javascript:, data: usw.
|
||||
allowedTags: [
|
||||
'p', 'br', 'hr',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup', 'mark',
|
||||
'a', 'blockquote', 'cite', 'q',
|
||||
'ul', 'ol', 'li',
|
||||
'pre', 'code', 'kbd', 'samp',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
||||
'div', 'span', 'section', 'article', 'aside', 'figure', 'figcaption',
|
||||
'img', 'picture', 'source',
|
||||
'iframe',
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'name', 'target', 'rel', 'title'],
|
||||
img: ['src', 'srcset', 'sizes', 'alt', 'width', 'height', 'loading', 'decoding'],
|
||||
source: ['src', 'srcset', 'sizes', 'type', 'media'],
|
||||
iframe: [
|
||||
'src', 'width', 'height', 'allow', 'allowfullscreen',
|
||||
'referrerpolicy', 'loading', 'title', 'frameborder',
|
||||
],
|
||||
'*': ['class', 'id', 'style', 'data-*', 'aria-*'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
allowedSchemesByTag: {
|
||||
img: ['http', 'https', 'data'],
|
||||
},
|
||||
allowedIframeHostnames: [
|
||||
'www.youtube.com',
|
||||
'youtube.com',
|
||||
'www.youtube-nocookie.com',
|
||||
'player.vimeo.com',
|
||||
'open.spotify.com',
|
||||
],
|
||||
allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
rel: attribs.target === '_blank' ? 'noopener noreferrer' : (attribs.rel ?? ''),
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export function sanitizeUntrustedHtml(html: string): string {
|
||||
return sanitizeHtml(html, HTML_BLOCK_OPTIONS);
|
||||
}
|
||||
|
||||
type Block = { _type?: string; html?: unknown } & Record<string, unknown>;
|
||||
|
||||
function isBlock(value: unknown): value is Block {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function visit(node: unknown): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isBlock(node)) return;
|
||||
|
||||
if (node._type === 'html' && typeof node.html === 'string') {
|
||||
node.html = sanitizeUntrustedHtml(node.html);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === '_type' || key === 'html') continue;
|
||||
visit(node[key]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutiert den Tree in-place: alle HtmlBlocks bekommen sanitisiertes `html`. */
|
||||
export function sanitizeBlocks(root: unknown): void {
|
||||
visit(root);
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
* - Optional Datei-Cache: TRANSFORM_CACHE_DIR setzen (z. B. .cache/transform).
|
||||
*/
|
||||
|
||||
const MAX_ENTRIES = Number(process.env.TRANSFORM_CACHE_MAX_ENTRIES) || 100;
|
||||
const CACHE_DIR = process.env.TRANSFORM_CACHE_DIR?.trim() || '';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const MAX_ENTRIES = Number(env.TRANSFORM_CACHE_MAX_ENTRIES) || 100;
|
||||
const CACHE_DIR = env.TRANSFORM_CACHE_DIR?.trim() || '';
|
||||
|
||||
interface CachedImage {
|
||||
buffer: ArrayBuffer;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SOCIAL_IMAGE_TRANSFORM,
|
||||
} from '$lib/constants';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { logWarn } from '$lib/log.server';
|
||||
|
||||
interface NavLink {
|
||||
href: string;
|
||||
@@ -94,8 +95,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
|
||||
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
|
||||
bootstrapConfig = batchData<PageConfigEntry>(batch.results.config);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
/* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */
|
||||
logWarn('layout.bootstrap', err);
|
||||
}
|
||||
|
||||
// Fallback für config: Slug "default" probieren (legacy), wenn die kanonische
|
||||
@@ -112,8 +114,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
},
|
||||
]);
|
||||
bootstrapConfig = batchData<PageConfigEntry>(fallback.results.config);
|
||||
} catch {
|
||||
/* optional */
|
||||
} catch (err) {
|
||||
logWarn('layout.config-fallback', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +193,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const href = postHref ?? pageHref;
|
||||
headerLinks.push({ href, label });
|
||||
}
|
||||
} catch {
|
||||
// CMS nicht erreichbar
|
||||
} catch (err) {
|
||||
logWarn('layout.headerLinks', err);
|
||||
}
|
||||
|
||||
// Social-Media-Links (aus Batch übernommen — Fallback falls Batch nichts
|
||||
@@ -220,8 +222,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const label = asObj.linkName ?? asObj.name ?? '';
|
||||
socialLinks.push({ href, icon, label });
|
||||
}
|
||||
} catch {
|
||||
/* Social-Links optional */
|
||||
} catch (err) {
|
||||
logWarn('layout.socialLinks', err);
|
||||
}
|
||||
|
||||
// Footer + page_config kommen direkt aus dem Batch (siehe oben).
|
||||
@@ -257,7 +259,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const text = (await res.text()).trim();
|
||||
if (text.includes('<svg')) logoSvgHtml = text;
|
||||
}
|
||||
} catch { /* Fallback: img mit logoUrl */ }
|
||||
} catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); }
|
||||
} else {
|
||||
logoUrl = await ensureTransformedImage(u, {
|
||||
height: 56,
|
||||
@@ -266,8 +268,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Logo optional */
|
||||
} catch (err) {
|
||||
logWarn('layout.logo', err);
|
||||
}
|
||||
|
||||
const siteName =
|
||||
@@ -282,8 +284,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
logoSocialImage = transformed.startsWith('/')
|
||||
? `${siteBaseUrl}${transformed}`
|
||||
: transformed;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logoSocialImage = null;
|
||||
logWarn('layout.socialImage', err);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
resolveDeadlineBannerBlocks,
|
||||
} from '$lib/blog-utils';
|
||||
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
|
||||
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
||||
import { logWarn } from '$lib/log.server';
|
||||
|
||||
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920];
|
||||
const BANNER_AR = '16/9';
|
||||
@@ -27,8 +29,8 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
|
||||
try {
|
||||
homeSlug = (await getHomePageSlugFromConfig({ locale: 'de' })) ?? DEFAULT_HOME_PAGE_SLUG;
|
||||
} catch {
|
||||
// fallback
|
||||
} catch (err) {
|
||||
logWarn('home.slug', err);
|
||||
}
|
||||
|
||||
let page = null;
|
||||
@@ -47,9 +49,10 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
await resolveSearchableTextBlocks(page, tagsMap);
|
||||
await resolveCalendarBlocks(page);
|
||||
await resolveDeadlineBannerBlocks(page);
|
||||
sanitizeBlocks(page);
|
||||
}
|
||||
} catch {
|
||||
// CMS nicht erreichbar
|
||||
} catch (err) {
|
||||
logWarn('home.page', err, { homeSlug });
|
||||
}
|
||||
|
||||
const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '$lib/blog-utils';
|
||||
import { PAGE_RESOLVE } from '$lib/constants';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
||||
|
||||
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920];
|
||||
const BANNER_AR = '16/9';
|
||||
@@ -55,6 +56,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
|
||||
await resolveSearchableTextBlocks(page, tagsMap);
|
||||
await resolveCalendarBlocks(page);
|
||||
await resolveDeadlineBannerBlocks(page);
|
||||
sanitizeBlocks(page);
|
||||
|
||||
// Resolve top banner if present
|
||||
let topBannerResolvedImages: string[] = [];
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import { env } from '$env/dynamic/private';
|
||||
import { env as envPublic } from '$env/dynamic/public';
|
||||
import { invalidateAll } from '$lib/cms';
|
||||
import { clearImageCache } from '$lib/image-cache.server';
|
||||
import { constantTimeEqual } from '$lib/auth-token.server';
|
||||
|
||||
/**
|
||||
* Manual full-cache purge:
|
||||
@@ -21,7 +22,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
||||
request.headers.get('x-revalidate-token') ??
|
||||
request.headers.get('x-webhook-secret') ??
|
||||
url.searchParams.get('token');
|
||||
if (provided !== token) throw error(401, 'invalid token');
|
||||
if (!constantTimeEqual(provided, token)) throw error(401, 'invalid token');
|
||||
|
||||
invalidateAll();
|
||||
const img = await clearImageCache();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { invalidateAll, invalidateCollection } from '$lib/cms';
|
||||
import { clearImageCache } from '$lib/image-cache.server';
|
||||
import { constantTimeEqual } from '$lib/auth-token.server';
|
||||
|
||||
/**
|
||||
* RustyCMS Webhook Receiver.
|
||||
@@ -29,7 +30,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
||||
request.headers.get('x-revalidate-token') ??
|
||||
request.headers.get('x-webhook-secret') ??
|
||||
url.searchParams.get('token');
|
||||
if (provided !== token) {
|
||||
if (!constantTimeEqual(provided, token)) {
|
||||
throw error(401, 'invalid token');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(301, '/posts');
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
redirect(301, `/posts/page/${encodeURIComponent(params.page)}/`);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
redirect(301, `/posts/tag/${encodeURIComponent(params.tag)}/`);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import {
|
||||
buildFileCacheKey,
|
||||
cacheFile,
|
||||
@@ -7,26 +6,12 @@ import {
|
||||
extFromSrc,
|
||||
getCachedFile,
|
||||
} from '$lib/file-cache.server';
|
||||
import { resolveCmsSource } from '$lib/cms-source.server';
|
||||
|
||||
const IMMUTABLE_HEADERS = {
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
} as const;
|
||||
|
||||
function getCmsBaseUrl(): string {
|
||||
return (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
'http://localhost:3000'
|
||||
).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function resolveSourceUrl(src: string): string {
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) return src;
|
||||
if (src.startsWith('//')) return `https:${src}`;
|
||||
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
|
||||
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
function buildResponseHeaders(
|
||||
contentType: string,
|
||||
download: string | null,
|
||||
@@ -73,7 +58,10 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const sourceUrl = resolveSourceUrl(src);
|
||||
const sourceUrl = resolveCmsSource(src);
|
||||
if (!sourceUrl) {
|
||||
return new Response('Source not allowed', { status: 400 });
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(sourceUrl);
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import {
|
||||
buildCacheKey,
|
||||
getCachedImage,
|
||||
cacheImage,
|
||||
type ImageTransformParams,
|
||||
} from '$lib/image-cache.server';
|
||||
import { getCmsBaseUrl, resolveCmsSource } from '$lib/cms-source.server';
|
||||
|
||||
const IMMUTABLE_HEADERS = {
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
} as const;
|
||||
|
||||
function getCmsBaseUrl(): string {
|
||||
return (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
'http://localhost:3000'
|
||||
).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wenn src kein vollständiger URL ist, wird er als CMS-Asset-Dateiname behandelt
|
||||
* und zu {CMS_URL}/api/assets/{src} aufgelöst.
|
||||
* Absolute Pfade (z.B. /api/assets/logo/logo3.svg) werden direkt an die CMS-URL angehängt.
|
||||
*/
|
||||
function resolveSourceUrl(src: string): string {
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) return src;
|
||||
if (src.startsWith('//')) return `https:${src}`;
|
||||
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
|
||||
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick output format based on client Accept header.
|
||||
* Priority: avif > webp > jpeg. Used when `format=auto` is requested so the
|
||||
@@ -102,7 +82,10 @@ export const GET: RequestHandler = async ({ url, request }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const sourceUrl = resolveSourceUrl(src);
|
||||
const sourceUrl = resolveCmsSource(src);
|
||||
if (!sourceUrl) {
|
||||
return new Response('Source not allowed', { status: 400 });
|
||||
}
|
||||
const cmsBase = getCmsBaseUrl();
|
||||
const transformParams = new URLSearchParams();
|
||||
transformParams.set('url', sourceUrl);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const { slug } = params;
|
||||
redirect(301, `/post/${encodeURIComponent(slug)}`);
|
||||
};
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
import { POST_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM, SITE_NAME } from '$lib/constants';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { marked } from 'marked';
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
import { marked } from '$lib/markdown-safe';
|
||||
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, setHeaders }) => {
|
||||
const { slug } = params;
|
||||
@@ -51,6 +50,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
||||
await resolveSearchableTextBlocks(post, tagsMap);
|
||||
await resolveDeadlineBannerBlocks(post);
|
||||
resolvePostTagsInPost(post, tagsMap);
|
||||
sanitizeBlocks(post);
|
||||
|
||||
const postImageField = getPostImageField(post.postImage);
|
||||
const postImageUrl = postImageField
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getPages, getPosts } from '$lib/cms';
|
||||
import { filterHiddenPosts, postHref } from '$lib/blog-utils';
|
||||
import { logWarn } from '$lib/log.server';
|
||||
|
||||
function normalizeSlug(s: string | undefined): string {
|
||||
return (s ?? '').replace(/^\//, '').trim();
|
||||
@@ -64,8 +65,8 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
priority: '0.6',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* CMS down */
|
||||
} catch (err) {
|
||||
logWarn('sitemap.pages', err);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -85,8 +86,8 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
priority: '0.5',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* CMS down */
|
||||
} catch (err) {
|
||||
logWarn('sitemap.posts', err);
|
||||
}
|
||||
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Vitest läuft ohne SvelteKit-Plugin (zu schwer für Unit-Tests, zu viele
|
||||
* Initialisierungsschritte). `$env/dynamic/{public,private}`-Imports werden
|
||||
* stattdessen auf Stub-Module gemappt, die `process.env` direkt durchreichen.
|
||||
* Damit lassen sich Server-Module isoliert testen, ohne das volle
|
||||
* SvelteKit-Boot durchlaufen zu müssen.
|
||||
*/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: path.resolve(__dirname, 'src/lib'),
|
||||
'$env/dynamic/public': path.resolve(__dirname, 'src/lib/_test/env-stub.ts'),
|
||||
'$env/dynamic/private': path.resolve(__dirname, 'src/lib/_test/env-stub.ts'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{ts,js}'],
|
||||
environment: 'node',
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user