@parche/core 0.3.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ import fs from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { Plugin } from 'vite';
4
+ import type { ResolvedRegistry } from './types.js';
5
+
6
+ /**
7
+ * Resolve a bare specifier to an absolute path from @parche/core's own location.
8
+ *
9
+ * Generated virtual modules have no place on disk, so a bare `import ... from 'zod'`
10
+ * inside one is resolved by Vite relative to the consuming project root — which under
11
+ * pnpm's isolated node_modules will not see core's dependencies. Emitting the absolute
12
+ * path instead pins the import to the copy core itself declares.
13
+ *
14
+ * Uses `import.meta.resolve` rather than `require.resolve` so the package's `import`
15
+ * export condition wins: `require.resolve` picks the CJS entry, which Vite then inlines
16
+ * as ESM and blows up with "exports is not defined".
17
+ */
18
+ function resolveFromCore(specifier: string): string {
19
+ try {
20
+ return fileURLToPath(import.meta.resolve(specifier));
21
+ } catch {
22
+ // Fall back to the bare specifier; the consumer may hoist or declare it itself.
23
+ return specifier;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * A single variant of default props for a widget.
29
+ * Widgets can provide multiple variants (e.g., "minimal", "with image")
30
+ * so the builder can randomly pick one when adding a new section.
31
+ */
32
+ interface DefaultVariant {
33
+ label?: string;
34
+ props: Record<string, unknown>;
35
+ }
36
+
37
+ /**
38
+ * Load widget default variants from a sibling `.defaults.json` file.
39
+ * Supports two formats:
40
+ * - Array of variants: [{ label?: string, props: {...} }, ...]
41
+ * - Single props object: { title: "...", ... } (wrapped as one variant)
42
+ * Returns null if the file doesn't exist or can't be parsed.
43
+ */
44
+ function loadWidgetDefaults(astroFilePath: string): DefaultVariant[] | null {
45
+ const defaultsPath = astroFilePath.replace(/\.astro$/, '.defaults.json');
46
+ try {
47
+ const raw = JSON.parse(fs.readFileSync(defaultsPath, 'utf-8'));
48
+ if (Array.isArray(raw)) {
49
+ // Array format: each element must have a `props` field
50
+ return raw.map((entry: unknown) => {
51
+ if (entry && typeof entry === 'object' && 'props' in entry) {
52
+ return entry as DefaultVariant;
53
+ }
54
+ // Bare props object inside array
55
+ return { props: entry as Record<string, unknown> };
56
+ });
57
+ }
58
+ // Single object format: wrap as one variant
59
+ return [{ props: raw as Record<string, unknown> }];
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ const PARCHE_PREFIX = 'parche:';
66
+ const VIRTUAL_PREFIX = '\0parche:';
67
+ const WIDGET_MAP_ID = 'parche:registry/widgets';
68
+ const WIDGET_MAP_VIRTUAL = '\0parche:registry/widgets';
69
+ const TEMPLATE_MAP_ID = 'parche:registry/templates';
70
+ const TEMPLATE_MAP_VIRTUAL = '\0parche:registry/templates';
71
+ const I18N_CONFIG_ID = 'parche:config/i18n';
72
+ const I18N_CONFIG_VIRTUAL = '\0parche:config/i18n';
73
+ const THEMES_CONFIG_ID = 'parche:config/themes';
74
+ const THEMES_CONFIG_VIRTUAL = '\0parche:config/themes';
75
+ const STYLES_CONFIG_ID = 'parche:config/styles';
76
+ const STYLES_CONFIG_VIRTUAL = '\0parche:config/styles';
77
+
78
+ // Core's base.css is the Tailwind root (it has `@import "tailwindcss"`). We
79
+ // append each parche's absolute @source globs into it at transform time —
80
+ // Tailwind v4 only honors @source in the root's own cascade, and only absolute
81
+ // paths reach sibling packages once installed from npm.
82
+ const BASE_CSS_PATH = fileURLToPath(new URL('../styles/base.css', import.meta.url));
83
+ const WIDGET_SCHEMAS_ID = 'parche:registry/widgetSchemas';
84
+ const WIDGET_SCHEMAS_VIRTUAL = '\0parche:registry/widgetSchemas';
85
+ const RESOLVERS_ID = 'parche:registry/resolvers';
86
+ const RESOLVERS_VIRTUAL = '\0parche:registry/resolvers';
87
+ const APP_CONFIG_PREFIX = 'parche:app/';
88
+ const APP_CONFIG_VIRTUAL_PREFIX = '\0parche:app/';
89
+
90
+ /**
91
+ * Extract a widget key from a virtual module ID.
92
+ * Widgets use the full path after the prefix to avoid collisions.
93
+ * Atoms use the short name (last segment).
94
+ *
95
+ * 'parche:widgets/hero/Hero' → 'hero/Hero'
96
+ * 'parche:widgets/legacy/Hero' → 'legacy/Hero'
97
+ * 'parche:primitives/Button' → 'Button'
98
+ */
99
+ function extractWidgetKey(virtualId: string): string {
100
+ if (virtualId.startsWith('parche:widgets/')) {
101
+ return virtualId.replace('parche:widgets/', '');
102
+ }
103
+ const path = virtualId.replace('parche:', '');
104
+ return path.split('/').pop()!;
105
+ }
106
+
107
+ /**
108
+ * Extract a template key from a virtual module ID.
109
+ * 'parche:templates/contact' → 'contact'
110
+ */
111
+ function extractTemplateKey(virtualId: string): string {
112
+ return virtualId.replace('parche:templates/', '');
113
+ }
114
+
115
+ /**
116
+ * Generate a JS module that exports the widget map.
117
+ */
118
+ function generateWidgetMapModule(registry: ResolvedRegistry): string {
119
+ const entries: { varName: string; key: string; importPath: string }[] = [];
120
+ let index = 0;
121
+
122
+ for (const virtualId of Object.keys(registry.modules)) {
123
+ if (virtualId.startsWith('parche:widgets/') || virtualId.startsWith('parche:primitives/')) {
124
+ const key = extractWidgetKey(virtualId);
125
+ entries.push({ varName: `W${index}`, key, importPath: virtualId });
126
+ index++;
127
+ }
128
+ }
129
+
130
+ const imports = entries.map((e) => `import ${e.varName} from '${e.importPath}';`).join('\n');
131
+ const mapEntries = entries.map((e) => ` '${e.key}': ${e.varName},`).join('\n');
132
+
133
+ return `${imports}
134
+
135
+ export const widgetMap = {
136
+ ${mapEntries}
137
+ };
138
+ `;
139
+ }
140
+
141
+ /**
142
+ * Generate a JS module that exports the template map.
143
+ * The injected catch-all route imports this to render templates by name.
144
+ */
145
+ function generateTemplateMapModule(registry: ResolvedRegistry): string {
146
+ const entries: { varName: string; key: string; virtualId: string }[] = [];
147
+ let index = 0;
148
+
149
+ for (const virtualId of Object.keys(registry.modules)) {
150
+ if (virtualId.startsWith('parche:templates/')) {
151
+ const key = extractTemplateKey(virtualId);
152
+ entries.push({ varName: `T${index}`, key, virtualId });
153
+ index++;
154
+ }
155
+ }
156
+
157
+ const imports = entries.map((e) => `import ${e.varName} from '${e.virtualId}';`).join('\n');
158
+ const mapEntries = entries.map((e) => ` '${e.key}': ${e.varName},`).join('\n');
159
+
160
+ return `${imports}
161
+
162
+ export const templateMap = {
163
+ ${mapEntries}
164
+ };
165
+ `;
166
+ }
167
+
168
+ /**
169
+ * Generate a JS module that exports i18n config.
170
+ */
171
+ function generateI18nConfigModule(registry: ResolvedRegistry): string {
172
+ return `export const locales = ${JSON.stringify(registry.i18n.locales)};
173
+ export const defaultLocale = ${JSON.stringify(registry.i18n.defaultLocale)};
174
+ `;
175
+ }
176
+
177
+ /**
178
+ * Generate a JS module that exports available themes.
179
+ */
180
+ function generateThemesConfigModule(registry: ResolvedRegistry): string {
181
+ return `export const themes = ${JSON.stringify(registry.themes)};
182
+ export const showPanel = ${JSON.stringify(registry.showPanel)};
183
+ `;
184
+ }
185
+
186
+ /**
187
+ * Generate the styles entry: side-effect CSS imports for every file the
188
+ * imported parches contribute (plus any user entry). Empty when none — the
189
+ * base look ships via BaseLayout's own base.css regardless.
190
+ */
191
+ function generateStylesModule(registry: ResolvedRegistry): string {
192
+ return registry.styleEntries.map((p) => `import ${JSON.stringify(p)};`).join('\n') + '\n';
193
+ }
194
+
195
+ /**
196
+ * Tailwind `@source` directives (absolute globs) for every parche's component
197
+ * files, appended into base.css so the classes those components use are
198
+ * generated — including when the parches are installed from npm, where relative
199
+ * @source paths can't reach sibling packages. Absolute paths come from each
200
+ * parche's factory.
201
+ */
202
+ function generateSourceDirectives(registry: ResolvedRegistry): string {
203
+ return registry.contentGlobs.map((g) => `@source ${JSON.stringify(g)};`).join('\n');
204
+ }
205
+
206
+ /**
207
+ * Derive the palette category from a widget virtual ID.
208
+ * 'parche:widgets/hero/Hero' → 'hero'
209
+ * 'parche:widgets/call-to-action/CTA' → 'call-to-action'
210
+ */
211
+ function extractWidgetCategory(virtualId: string): string {
212
+ const after = virtualId.replace('parche:widgets/', '');
213
+ const slashIdx = after.lastIndexOf('/');
214
+ return slashIdx >= 0 ? after.slice(0, slashIdx) : after;
215
+ }
216
+
217
+ /**
218
+ * Convert a PascalCase component name to a human-readable label.
219
+ * Splits at lowercase→uppercase transitions to preserve acronyms.
220
+ * 'FeaturesList' → 'Features List'
221
+ * 'CallToAction' → 'Call To Action'
222
+ * 'FAQs' → 'FAQs' (no lowercase→uppercase transition)
223
+ */
224
+ function humanLabel(name: string): string {
225
+ return name.replace(/([a-z])([A-Z])/g, '$1 $2').trim();
226
+ }
227
+
228
+ /**
229
+ * Generate a JS module that exports:
230
+ * - `widgetSchemas` — JSON Schema per widget (from Zod v4 toJSONSchema)
231
+ * - `widgetMeta` — label / category / description / defaultProps / ui per widget
232
+ *
233
+ * Widgets with a sibling `.props.ts` get a full schema + metadata.
234
+ * Widgets without one get only basic metadata (no schema / no form in builder).
235
+ */
236
+ function generateWidgetSchemasModule(registry: ResolvedRegistry): string {
237
+ const imports: string[] = [`import { z } from ${JSON.stringify(resolveFromCore('zod'))};`];
238
+ const schemaEntries: string[] = [];
239
+ const metaEntries: string[] = [];
240
+ let index = 0;
241
+
242
+ for (const [virtualId, filePath] of Object.entries(registry.modules)) {
243
+ if (!virtualId.startsWith('parche:widgets/')) continue;
244
+ if (!filePath.endsWith('.astro')) continue;
245
+
246
+ const key = extractWidgetKey(virtualId);
247
+
248
+ // Layout widgets (layout/Header, layout/Footer) are structural — skip builder palette
249
+ if (key.startsWith('layout/')) continue;
250
+
251
+ const propsPath = filePath.replace(/\.astro$/, '.props.ts');
252
+ const hasProps = fs.existsSync(propsPath);
253
+ const variants = loadWidgetDefaults(filePath);
254
+ const variantsJson = JSON.stringify(variants ?? [{ props: {} }]);
255
+ const defaultPropsJson = JSON.stringify(variants?.[0]?.props ?? {});
256
+
257
+ if (hasProps) {
258
+ const varName = `p${index}`;
259
+ imports.push(`import { schema as ${varName}_s, meta as ${varName}_m } from ${JSON.stringify(propsPath)};`);
260
+
261
+ schemaEntries.push(` ${JSON.stringify(key)}: z.toJSONSchema(${varName}_s)`);
262
+
263
+ metaEntries.push(` ${JSON.stringify(key)}: {
264
+ label: ${varName}_m?.widget?.label ?? ${JSON.stringify(humanLabel(key))},
265
+ category: ${varName}_m?.widget?.category ?? ${JSON.stringify(extractWidgetCategory(virtualId))},
266
+ description: ${varName}_m?.widget?.description ?? '',
267
+ icon: ${varName}_m?.widget?.icon ?? '',
268
+ defaultProps: ${defaultPropsJson},
269
+ defaultVariants: ${variantsJson},
270
+ ui: ${varName}_m?.ui ?? {},
271
+ }`);
272
+ index++;
273
+ } else {
274
+ // No .props.ts — basic meta only, no schema
275
+ metaEntries.push(` ${JSON.stringify(key)}: {
276
+ label: ${JSON.stringify(humanLabel(key))},
277
+ category: ${JSON.stringify(extractWidgetCategory(virtualId))},
278
+ description: '',
279
+ icon: '',
280
+ defaultProps: ${defaultPropsJson},
281
+ defaultVariants: ${variantsJson},
282
+ ui: {},
283
+ }`);
284
+ }
285
+ }
286
+
287
+ return `${imports.join('\n')}
288
+
289
+ export const widgetSchemas = {
290
+ ${schemaEntries.join(',\n')}
291
+ };
292
+
293
+ export const widgetMeta = {
294
+ ${metaEntries.join(',\n')}
295
+ };
296
+ `;
297
+ }
298
+
299
+ /**
300
+ * Generate a JS module that aggregates all app resolvers.
301
+ * Exports resolveContent(slug, locale, opts) and getResolverPaths(locales, defaultLocale, opts).
302
+ */
303
+ function generateResolversModule(registry: ResolvedRegistry): string {
304
+ if (registry.resolvers.length === 0) {
305
+ return `
306
+ export async function resolveContent() { return null; }
307
+ export async function getResolverPaths() { return []; }
308
+ `;
309
+ }
310
+
311
+ const imports: string[] = [];
312
+ const resolverNames: string[] = [];
313
+
314
+ registry.resolvers.forEach((r, i) => {
315
+ const varResolve = `resolve_${i}`;
316
+ const varPaths = `getPaths_${i}`;
317
+ imports.push(
318
+ `import { resolve as ${varResolve}, getPaths as ${varPaths} } from ${JSON.stringify(r.entrypoint)};`,
319
+ );
320
+ resolverNames.push(`{ resolve: ${varResolve}, getPaths: ${varPaths} }`);
321
+ });
322
+
323
+ return `${imports.join('\n')}
324
+
325
+ const resolvers = [${resolverNames.join(', ')}];
326
+
327
+ export async function resolveContent(slug, locale, opts) {
328
+ for (const r of resolvers) {
329
+ const result = await r.resolve(slug, locale, opts);
330
+ if (result) return result;
331
+ }
332
+ return null;
333
+ }
334
+
335
+ export async function getResolverPaths(locales, defaultLocale, opts) {
336
+ const all = [];
337
+ for (const r of resolvers) {
338
+ const paths = await r.getPaths(locales, defaultLocale, opts);
339
+ all.push(...paths);
340
+ }
341
+ return all;
342
+ }
343
+ `;
344
+ }
345
+
346
+ export function vitePluginParche(registry: ResolvedRegistry): Plugin {
347
+ return {
348
+ name: 'vite-plugin-parche',
349
+ enforce: 'pre',
350
+
351
+ resolveId(id) {
352
+ if (id === WIDGET_MAP_ID) return WIDGET_MAP_VIRTUAL;
353
+ if (id === TEMPLATE_MAP_ID) return TEMPLATE_MAP_VIRTUAL;
354
+ if (id === I18N_CONFIG_ID) return I18N_CONFIG_VIRTUAL;
355
+ if (id === THEMES_CONFIG_ID) return THEMES_CONFIG_VIRTUAL;
356
+ if (id === STYLES_CONFIG_ID) return STYLES_CONFIG_VIRTUAL;
357
+
358
+ if (id === WIDGET_SCHEMAS_ID) return WIDGET_SCHEMAS_VIRTUAL;
359
+ if (id === RESOLVERS_ID) return RESOLVERS_VIRTUAL;
360
+ if (id.startsWith(APP_CONFIG_PREFIX)) return '\0' + id;
361
+ if (id.startsWith(PARCHE_PREFIX)) {
362
+ return '\0' + id;
363
+ }
364
+ },
365
+
366
+ load(id) {
367
+ // Feed Tailwind's root (core's base.css) with each parche's absolute
368
+ // @source globs, appended to the file's own content. Done in `load` (not
369
+ // `transform`) so @tailwindcss/vite compiles the augmented CSS regardless
370
+ // of plugin ordering. Absolute paths are the only ones that reach sibling
371
+ // packages once installed from npm.
372
+ if (registry.contentGlobs.length > 0 && id.split('?')[0] === BASE_CSS_PATH) {
373
+ const css = fs.readFileSync(BASE_CSS_PATH, 'utf-8');
374
+ this.addWatchFile(BASE_CSS_PATH);
375
+ return `${css}\n${generateSourceDirectives(registry)}\n`;
376
+ }
377
+
378
+ if (id === WIDGET_MAP_VIRTUAL) return generateWidgetMapModule(registry);
379
+ if (id === TEMPLATE_MAP_VIRTUAL) return generateTemplateMapModule(registry);
380
+ if (id === I18N_CONFIG_VIRTUAL) return generateI18nConfigModule(registry);
381
+ if (id === THEMES_CONFIG_VIRTUAL) return generateThemesConfigModule(registry);
382
+ if (id === STYLES_CONFIG_VIRTUAL) return generateStylesModule(registry);
383
+ if (id === RESOLVERS_VIRTUAL) return generateResolversModule(registry);
384
+ if (id === WIDGET_SCHEMAS_VIRTUAL) {
385
+ // Watch .props.ts and .defaults.json files for HMR
386
+ for (const [virtualId, filePath] of Object.entries(registry.modules)) {
387
+ if (!virtualId.startsWith('parche:widgets/') || !filePath.endsWith('.astro')) continue;
388
+ for (const ext of ['.props.ts', '.defaults.json']) {
389
+ const sibling = filePath.replace(/\.astro$/, ext);
390
+ if (fs.existsSync(sibling)) {
391
+ this.addWatchFile(sibling);
392
+ }
393
+ }
394
+ }
395
+ return generateWidgetSchemasModule(registry);
396
+ }
397
+
398
+ // App config virtual modules: parche:app/{name}
399
+ if (id.startsWith(APP_CONFIG_VIRTUAL_PREFIX)) {
400
+ const appName = id.slice(APP_CONFIG_VIRTUAL_PREFIX.length);
401
+ const app = registry.apps.find((a) => a.name === appName);
402
+ return `export default ${JSON.stringify(app?.config ?? {})};\n`;
403
+ }
404
+
405
+ if (!id.startsWith(VIRTUAL_PREFIX)) return;
406
+
407
+ const virtualId = id.slice(1); // strip \0
408
+ const resolved = registry.modules[virtualId];
409
+
410
+ if (!resolved) {
411
+ this.error(`[parche] Unknown virtual module: ${virtualId}`);
412
+ return;
413
+ }
414
+
415
+ // Watch the resolved file for HMR
416
+ this.addWatchFile(resolved);
417
+
418
+ const quotedPath = JSON.stringify(resolved);
419
+
420
+ // CSS files are side-effect imports (no exports)
421
+ if (resolved.endsWith('.css')) {
422
+ return `import ${quotedPath};`;
423
+ }
424
+
425
+ // Use named exports for utility modules, default export for components
426
+ if (registry.namedExportModules.has(virtualId)) {
427
+ return `export * from ${quotedPath};`;
428
+ }
429
+ return `export { default } from ${quotedPath};`;
430
+ },
431
+ };
432
+ }
@@ -0,0 +1,121 @@
1
+ ---
2
+ import '../styles/base.css';
3
+ import { ClientRouter } from 'astro:transitions';
4
+ import Font from 'astro/components/Font.astro';
5
+ import { parcheFontDefs } from '../config/font-variables.js';
6
+ import type { ResolvedMetadata } from '../utils/metadata';
7
+ import { buildRobotsContent, buildBreadcrumbs, buildJsonLdGraph } from '../utils/metadata';
8
+ import type { SiteConfig } from '../types/config';
9
+
10
+ interface Props {
11
+ metadata: ResolvedMetadata;
12
+ config: SiteConfig;
13
+ lang?: string;
14
+ }
15
+
16
+ const { metadata, config, lang } = Astro.props;
17
+
18
+ const locale = lang ?? metadata.locale ?? 'en';
19
+ const canonicalURL = metadata.canonical
20
+ || (Astro.site ? new URL(Astro.url.pathname, Astro.site).href : Astro.url.pathname);
21
+ const ogImageURL = metadata.ogImage && Astro.site
22
+ ? new URL(metadata.ogImage, Astro.site).href
23
+ : metadata.ogImage;
24
+
25
+ const robotsContent = buildRobotsContent(metadata);
26
+ const siteUrl = Astro.site?.href ?? config.site.url ?? '';
27
+ const breadcrumbs = siteUrl ? buildBreadcrumbs(Astro.url.pathname, siteUrl, metadata.title) : undefined;
28
+ const jsonLdGraph = buildJsonLdGraph(metadata, canonicalURL, siteUrl, config, breadcrumbs);
29
+
30
+ const verification = config.seo?.verification;
31
+ const preconnect = config.seo?.preconnect ?? [];
32
+ const twitterHandle = config.metadata?.twitterHandle;
33
+ const article = metadata.ogType === 'article' ? metadata.article : undefined;
34
+ ---
35
+
36
+ <html lang={locale}>
37
+ <head>
38
+ <meta charset="utf-8" />
39
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
40
+ <meta name="generator" content={Astro.generator} />
41
+
42
+ <title>{metadata.title}</title>
43
+ {metadata.description && <meta name="description" content={metadata.description} />}
44
+ {metadata.keywords && <meta name="keywords" content={metadata.keywords} />}
45
+ <link rel="canonical" href={canonicalURL} />
46
+ <meta name="robots" content={robotsContent} />
47
+
48
+ {/* Verification tokens */}
49
+ {verification?.google && <meta name="google-site-verification" content={verification.google} />}
50
+ {verification?.bing && <meta name="msvalidate.01" content={verification.bing} />}
51
+ {verification?.yandex && <meta name="yandex-verification" content={verification.yandex} />}
52
+ {verification?.pinterest && <meta name="p:domain_verify" content={verification.pinterest} />}
53
+
54
+ {/* Open Graph */}
55
+ <meta property="og:title" content={metadata.ogTitle} />
56
+ {metadata.ogDescription && <meta property="og:description" content={metadata.ogDescription} />}
57
+ <meta property="og:url" content={canonicalURL} />
58
+ <meta property="og:type" content={metadata.ogType} />
59
+ <meta property="og:site_name" content={metadata.siteName} />
60
+ {ogImageURL && <meta property="og:image" content={ogImageURL} />}
61
+ <meta property="og:locale" content={locale} />
62
+
63
+ {/* Article-specific OG tags */}
64
+ {article?.author && <meta name="author" content={article.author} />}
65
+ {article?.publishedDate && <meta property="article:published_time" content={article.publishedDate} />}
66
+ {article?.modifiedDate && <meta property="article:modified_time" content={article.modifiedDate} />}
67
+ {article?.section && <meta property="article:section" content={article.section} />}
68
+ {article?.tags?.map((tag) => <meta property="article:tag" content={tag} />)}
69
+
70
+ {/* Twitter Card */}
71
+ <meta name="twitter:card" content={metadata.twitterCard} />
72
+ <meta name="twitter:title" content={metadata.ogTitle} />
73
+ {metadata.ogDescription && <meta name="twitter:description" content={metadata.ogDescription} />}
74
+ {ogImageURL && <meta name="twitter:image" content={ogImageURL} />}
75
+ {twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
76
+
77
+ {/* Resource hints */}
78
+ {preconnect.map((url) => <link rel="preconnect" href={url} />)}
79
+
80
+ {/* JSON-LD Structured Data (@graph) */}
81
+ <script type="application/ld+json" set:html={jsonLdGraph} />
82
+
83
+ {/* Fonts: driven by parcheFonts (single source of truth), never hardcoded here. */}
84
+ {parcheFontDefs.map((f) => <Font cssVariable={f.cssVariable} preload={f.preload} />)}
85
+
86
+ {/* Theme init: runs before body renders to prevent flash */}
87
+ <script is:inline>
88
+ (function() {
89
+ var d = document.documentElement;
90
+ var stored = localStorage.getItem('theme');
91
+ if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
92
+ d.classList.add('dark');
93
+ }
94
+ var siteTheme = localStorage.getItem('site-theme');
95
+ if (siteTheme) {
96
+ d.setAttribute('data-theme', siteTheme);
97
+ }
98
+
99
+ // Preserve theme state across View Transitions
100
+ document.addEventListener('astro:before-swap', function(e) {
101
+ var html = document.documentElement;
102
+ var newHtml = e.newDocument.documentElement;
103
+ if (html.classList.contains('dark')) {
104
+ newHtml.classList.add('dark');
105
+ }
106
+ var theme = html.getAttribute('data-theme');
107
+ if (theme) {
108
+ newHtml.setAttribute('data-theme', theme);
109
+ }
110
+ });
111
+ })();
112
+ </script>
113
+
114
+ <ClientRouter />
115
+
116
+ <slot name="head" />
117
+ </head>
118
+ <body class="min-h-screen bg-background text-text flex flex-col">
119
+ <slot />
120
+ </body>
121
+ </html>
@@ -0,0 +1,46 @@
1
+ ---
2
+ import 'parche:config/styles';
3
+ import BaseLayout from 'parche:layouts/BaseLayout';
4
+ import ThemePanel from 'parche:components/ThemePanel';
5
+ import LayoutRenderer from 'parche:LayoutRenderer';
6
+ import config from 'parche:config';
7
+ import { showPanel } from 'parche:config/themes';
8
+ import { defaultLocale } from 'parche:config/i18n';
9
+ import { resolveMetadata } from 'parche:utils/metadata';
10
+ import { resolveLayout } from 'parche:utils/layout';
11
+
12
+ const locale = Astro.currentLocale || defaultLocale;
13
+ const layoutSections = await resolveLayout('default', locale, defaultLocale);
14
+
15
+ const metadata = resolveMetadata(
16
+ {
17
+ title: `Page Not Found — ${config.site.name}`,
18
+ description: "The page you're looking for doesn't exist.",
19
+ metadata: { noindex: true },
20
+ },
21
+ config,
22
+ { locale },
23
+ );
24
+ ---
25
+
26
+ <BaseLayout metadata={metadata} config={config} lang={locale}>
27
+ <LayoutRenderer layoutSections={layoutSections} pageTemplate="dynamic">
28
+ <section class="py-16 md:py-24">
29
+ <div class="mx-auto max-w-6xl px-4 text-center md:px-6">
30
+ <p class="mb-4 text-8xl font-bold text-primary">404</p>
31
+ <h1 class="type-h1 mb-4">Page not found</h1>
32
+ <p class="type-body mx-auto mb-8 max-w-md text-muted">
33
+ Sorry, the page you're looking for doesn't exist or has been moved.
34
+ </p>
35
+ <a
36
+ href="/"
37
+ class="inline-flex items-center justify-center gap-2 rounded-full bg-primary px-5 py-2.5 text-sm font-semibold text-on-primary no-underline shadow-sm transition-all hover:opacity-90"
38
+ >
39
+ Back to Home
40
+ </a>
41
+ </div>
42
+ </section>
43
+ </LayoutRenderer>
44
+
45
+ {showPanel && <ThemePanel />}
46
+ </BaseLayout>