@parche/core 0.3.0-alpha.0 → 0.4.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.
- package/package.json +1 -1
- package/src/components/DynamicRenderer.astro +97 -23
- package/src/components/LayoutRenderer.astro +23 -3
- package/src/components/SectionWrapper.astro +9 -8
- package/src/integration/index.ts +225 -10
- package/src/integration/registry.ts +131 -15
- package/src/integration/types.ts +60 -4
- package/src/integration/virtual.d.ts +30 -199
- package/src/integration/vite-plugin-parche.ts +117 -44
- package/src/routes/[...slug].astro +15 -15
- package/src/styles/semantic.css +1 -1
- package/src/types/config.ts +13 -18
- package/src/utils/i18n.ts +39 -14
- package/src/utils/layout.ts +31 -21
|
@@ -74,6 +74,8 @@ const THEMES_CONFIG_ID = 'parche:config/themes';
|
|
|
74
74
|
const THEMES_CONFIG_VIRTUAL = '\0parche:config/themes';
|
|
75
75
|
const STYLES_CONFIG_ID = 'parche:config/styles';
|
|
76
76
|
const STYLES_CONFIG_VIRTUAL = '\0parche:config/styles';
|
|
77
|
+
const LAYOUT_CONFIG_ID = 'parche:config/layout';
|
|
78
|
+
const LAYOUT_CONFIG_VIRTUAL = '\0parche:config/layout';
|
|
77
79
|
|
|
78
80
|
// Core's base.css is the Tailwind root (it has `@import "tailwindcss"`). We
|
|
79
81
|
// append each parche's absolute @source globs into it at transform time —
|
|
@@ -113,28 +115,41 @@ function extractTemplateKey(virtualId: string): string {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
/**
|
|
116
|
-
* Generate
|
|
118
|
+
* Generate the widget catalog as LAZY loaders. Each widget is a `() => import()`
|
|
119
|
+
* so Vite code-splits it into its own chunk, loaded only when a rendered section
|
|
120
|
+
* references it — the SSR server never holds the whole catalog resident. Renderers
|
|
121
|
+
* call `loadWidgets(keys)` in their (async) frontmatter to resolve just the
|
|
122
|
+
* components a page uses before rendering synchronously.
|
|
117
123
|
*/
|
|
118
124
|
function generateWidgetMapModule(registry: ResolvedRegistry): string {
|
|
119
|
-
const entries: {
|
|
120
|
-
let index = 0;
|
|
125
|
+
const entries: { key: string; importPath: string }[] = [];
|
|
121
126
|
|
|
122
127
|
for (const virtualId of Object.keys(registry.modules)) {
|
|
123
128
|
if (virtualId.startsWith('parche:widgets/') || virtualId.startsWith('parche:primitives/')) {
|
|
124
|
-
|
|
125
|
-
entries.push({ varName: `W${index}`, key, importPath: virtualId });
|
|
126
|
-
index++;
|
|
129
|
+
entries.push({ key: extractWidgetKey(virtualId), importPath: virtualId });
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
const
|
|
131
|
-
|
|
133
|
+
const loaderEntries = entries
|
|
134
|
+
.map((e) => ` ${JSON.stringify(e.key)}: () => import(${JSON.stringify(e.importPath)}),`)
|
|
135
|
+
.join('\n');
|
|
132
136
|
|
|
133
|
-
return
|
|
134
|
-
|
|
135
|
-
export const widgetMap = {
|
|
136
|
-
${mapEntries}
|
|
137
|
+
return `export const widgetLoaders = {
|
|
138
|
+
${loaderEntries}
|
|
137
139
|
};
|
|
140
|
+
|
|
141
|
+
/** Resolve the given widget keys (deduped) to their components. Keys with no
|
|
142
|
+
* loader (e.g. the synthetic 'layout/Main') are skipped. */
|
|
143
|
+
export async function loadWidgets(keys) {
|
|
144
|
+
const out = {};
|
|
145
|
+
await Promise.all(
|
|
146
|
+
[...new Set(keys)].map(async (key) => {
|
|
147
|
+
const loader = widgetLoaders[key];
|
|
148
|
+
if (loader) out[key] = (await loader()).default;
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
138
153
|
`;
|
|
139
154
|
}
|
|
140
155
|
|
|
@@ -183,6 +198,17 @@ export const showPanel = ${JSON.stringify(registry.showPanel)};
|
|
|
183
198
|
`;
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Generate a JS module that exports layout hints the render path needs — the
|
|
203
|
+
* set of widget keys that render full-bleed (no default SectionWrapper). Kept as
|
|
204
|
+
* a plain static array so DynamicRenderer imports zero widget schemas/components
|
|
205
|
+
* for this decision. Which widgets are full-bleed is declared by the parches, not
|
|
206
|
+
* hardcoded in core.
|
|
207
|
+
*/
|
|
208
|
+
function generateLayoutConfigModule(registry: ResolvedRegistry): string {
|
|
209
|
+
return `export const fullBleedWidgets = ${JSON.stringify(registry.fullBleedWidgets)};\n`;
|
|
210
|
+
}
|
|
211
|
+
|
|
186
212
|
/**
|
|
187
213
|
* Generate the styles entry: side-effect CSS imports for every file the
|
|
188
214
|
* imported parches contribute (plus any user entry). Empty when none — the
|
|
@@ -235,8 +261,7 @@ function humanLabel(name: string): string {
|
|
|
235
261
|
*/
|
|
236
262
|
function generateWidgetSchemasModule(registry: ResolvedRegistry): string {
|
|
237
263
|
const imports: string[] = [`import { z } from ${JSON.stringify(resolveFromCore('zod'))};`];
|
|
238
|
-
const
|
|
239
|
-
const metaEntries: string[] = [];
|
|
264
|
+
const statements: string[] = [];
|
|
240
265
|
let index = 0;
|
|
241
266
|
|
|
242
267
|
for (const [virtualId, filePath] of Object.entries(registry.modules)) {
|
|
@@ -253,46 +278,69 @@ function generateWidgetSchemasModule(registry: ResolvedRegistry): string {
|
|
|
253
278
|
const variants = loadWidgetDefaults(filePath);
|
|
254
279
|
const variantsJson = JSON.stringify(variants ?? [{ props: {} }]);
|
|
255
280
|
const defaultPropsJson = JSON.stringify(variants?.[0]?.props ?? {});
|
|
281
|
+
const keyJson = JSON.stringify(key);
|
|
256
282
|
|
|
257
283
|
if (hasProps) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
284
|
+
// Namespace import: a `.props.ts` missing `schema` or `meta` no longer
|
|
285
|
+
// fails the whole module (it would with a named import). Each widget's
|
|
286
|
+
// schema serialization is isolated in a try/catch so one bad/incompatible
|
|
287
|
+
// schema (e.g. Zod v3, or a construct toJSONSchema can't serialize) is
|
|
288
|
+
// skipped with a warning instead of killing the entire builder palette.
|
|
289
|
+
const m = `p${index}`;
|
|
290
|
+
imports.push(`import * as ${m} from ${JSON.stringify(propsPath)};`);
|
|
291
|
+
statements.push(
|
|
292
|
+
`if (${m}.schema) { try { widgetSchemas[${keyJson}] = z.toJSONSchema(${m}.schema); } ` +
|
|
293
|
+
`catch (e) { console.warn(${JSON.stringify(`[parche] Skipped JSON Schema for widget "${key}": `)} + ((e && e.message) || e)); } }`,
|
|
294
|
+
);
|
|
295
|
+
statements.push(`widgetMeta[${keyJson}] = {
|
|
296
|
+
label: ${m}.meta?.widget?.label ?? ${JSON.stringify(humanLabel(key))},
|
|
297
|
+
category: ${m}.meta?.widget?.category ?? ${JSON.stringify(extractWidgetCategory(virtualId))},
|
|
298
|
+
description: ${m}.meta?.widget?.description ?? '',
|
|
299
|
+
icon: ${m}.meta?.widget?.icon ?? '',
|
|
300
|
+
defaultProps: ${defaultPropsJson},
|
|
301
|
+
defaultVariants: ${variantsJson},
|
|
302
|
+
ui: ${m}.meta?.ui ?? {},
|
|
303
|
+
};`);
|
|
272
304
|
index++;
|
|
273
305
|
} else {
|
|
274
306
|
// No .props.ts — basic meta only, no schema
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
307
|
+
statements.push(`widgetMeta[${keyJson}] = {
|
|
308
|
+
label: ${JSON.stringify(humanLabel(key))},
|
|
309
|
+
category: ${JSON.stringify(extractWidgetCategory(virtualId))},
|
|
310
|
+
description: '',
|
|
311
|
+
icon: '',
|
|
312
|
+
defaultProps: ${defaultPropsJson},
|
|
313
|
+
defaultVariants: ${variantsJson},
|
|
314
|
+
ui: {},
|
|
315
|
+
};`);
|
|
284
316
|
}
|
|
285
317
|
}
|
|
286
318
|
|
|
319
|
+
// Structural requirements (V2): warn when a requiring parche expects a prop
|
|
320
|
+
// the provider's schema doesn't expose. Runs where the schemas exist (this
|
|
321
|
+
// module), so it fires for builder/dev; presence + versions are gated earlier
|
|
322
|
+
// in createRegistry for every build.
|
|
323
|
+
const requirementChecks = registry.widgetPropRequirements.map((r) => {
|
|
324
|
+
const nameJson = JSON.stringify(r.name);
|
|
325
|
+
const fromJson = JSON.stringify(r.from);
|
|
326
|
+
const propsJson = JSON.stringify(r.props);
|
|
327
|
+
return `{
|
|
328
|
+
const __s = widgetSchemas[${nameJson}];
|
|
329
|
+
if (__s && __s.properties) {
|
|
330
|
+
const __missing = ${propsJson}.filter((p) => !(p in __s.properties));
|
|
331
|
+
if (__missing.length) console.warn(${JSON.stringify(`[parche] ${r.from} requires widget "${r.name}" to expose prop(s): `)} + __missing.join(', ') + ${JSON.stringify(` — provider "${r.name}" schema does not.`)});
|
|
332
|
+
}
|
|
333
|
+
}`;
|
|
334
|
+
});
|
|
335
|
+
|
|
287
336
|
return `${imports.join('\n')}
|
|
288
337
|
|
|
289
|
-
export const widgetSchemas = {
|
|
290
|
-
|
|
291
|
-
};
|
|
338
|
+
export const widgetSchemas = {};
|
|
339
|
+
export const widgetMeta = {};
|
|
292
340
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
341
|
+
${statements.join('\n')}
|
|
342
|
+
|
|
343
|
+
${requirementChecks.join('\n')}
|
|
296
344
|
`;
|
|
297
345
|
}
|
|
298
346
|
|
|
@@ -343,6 +391,22 @@ export async function getResolverPaths(locales, defaultLocale, opts) {
|
|
|
343
391
|
`;
|
|
344
392
|
}
|
|
345
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Is this the core base.css (the Tailwind root we inject @source into)?
|
|
396
|
+
* Compares by realpath so a symlinked path (pnpm's isolated layout) still
|
|
397
|
+
* matches — otherwise a mismatch would silently drop every parche's classes.
|
|
398
|
+
* A consuming app's own base.css has a different realpath and won't match.
|
|
399
|
+
*/
|
|
400
|
+
function isCoreBaseCss(file: string): boolean {
|
|
401
|
+
if (file === BASE_CSS_PATH) return true;
|
|
402
|
+
if (!file.endsWith('base.css')) return false;
|
|
403
|
+
try {
|
|
404
|
+
return fs.realpathSync(file) === fs.realpathSync(BASE_CSS_PATH);
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
346
410
|
export function vitePluginParche(registry: ResolvedRegistry): Plugin {
|
|
347
411
|
return {
|
|
348
412
|
name: 'vite-plugin-parche',
|
|
@@ -354,6 +418,7 @@ export function vitePluginParche(registry: ResolvedRegistry): Plugin {
|
|
|
354
418
|
if (id === I18N_CONFIG_ID) return I18N_CONFIG_VIRTUAL;
|
|
355
419
|
if (id === THEMES_CONFIG_ID) return THEMES_CONFIG_VIRTUAL;
|
|
356
420
|
if (id === STYLES_CONFIG_ID) return STYLES_CONFIG_VIRTUAL;
|
|
421
|
+
if (id === LAYOUT_CONFIG_ID) return LAYOUT_CONFIG_VIRTUAL;
|
|
357
422
|
|
|
358
423
|
if (id === WIDGET_SCHEMAS_ID) return WIDGET_SCHEMAS_VIRTUAL;
|
|
359
424
|
if (id === RESOLVERS_ID) return RESOLVERS_VIRTUAL;
|
|
@@ -369,17 +434,25 @@ export function vitePluginParche(registry: ResolvedRegistry): Plugin {
|
|
|
369
434
|
// `transform`) so @tailwindcss/vite compiles the augmented CSS regardless
|
|
370
435
|
// of plugin ordering. Absolute paths are the only ones that reach sibling
|
|
371
436
|
// packages once installed from npm.
|
|
372
|
-
if (registry.contentGlobs.length > 0 && id.split('?')[0]
|
|
437
|
+
if (registry.contentGlobs.length > 0 && isCoreBaseCss(id.split('?')[0])) {
|
|
373
438
|
const css = fs.readFileSync(BASE_CSS_PATH, 'utf-8');
|
|
374
439
|
this.addWatchFile(BASE_CSS_PATH);
|
|
375
440
|
return `${css}\n${generateSourceDirectives(registry)}\n`;
|
|
376
441
|
}
|
|
377
442
|
|
|
443
|
+
// Inline site config (parche({ site })) — serve the validated object directly
|
|
444
|
+
// instead of re-exporting a user file. Must come before the generic
|
|
445
|
+
// module resolution, which has no file path for parche:config here.
|
|
446
|
+
if (id === '\0parche:config' && registry.inlineSiteConfig) {
|
|
447
|
+
return `export default ${JSON.stringify(registry.inlineSiteConfig)};\n`;
|
|
448
|
+
}
|
|
449
|
+
|
|
378
450
|
if (id === WIDGET_MAP_VIRTUAL) return generateWidgetMapModule(registry);
|
|
379
451
|
if (id === TEMPLATE_MAP_VIRTUAL) return generateTemplateMapModule(registry);
|
|
380
452
|
if (id === I18N_CONFIG_VIRTUAL) return generateI18nConfigModule(registry);
|
|
381
453
|
if (id === THEMES_CONFIG_VIRTUAL) return generateThemesConfigModule(registry);
|
|
382
454
|
if (id === STYLES_CONFIG_VIRTUAL) return generateStylesModule(registry);
|
|
455
|
+
if (id === LAYOUT_CONFIG_VIRTUAL) return generateLayoutConfigModule(registry);
|
|
383
456
|
if (id === RESOLVERS_VIRTUAL) return generateResolversModule(registry);
|
|
384
457
|
if (id === WIDGET_SCHEMAS_VIRTUAL) {
|
|
385
458
|
// Watch .props.ts and .defaults.json files for HMR
|
|
@@ -3,9 +3,9 @@ import 'parche:config/styles';
|
|
|
3
3
|
import BaseLayout from 'parche:layouts/BaseLayout';
|
|
4
4
|
import ThemePanel from 'parche:components/ThemePanel';
|
|
5
5
|
import LayoutRenderer from 'parche:LayoutRenderer';
|
|
6
|
+
import DynamicRenderer from 'parche:DynamicRenderer';
|
|
6
7
|
import config from 'parche:config';
|
|
7
8
|
import { templateMap } from 'parche:registry/templates';
|
|
8
|
-
import { widgetMap } from 'parche:registry/widgets';
|
|
9
9
|
import { defaultLocale } from 'parche:config/i18n';
|
|
10
10
|
import { showPanel } from 'parche:config/themes';
|
|
11
11
|
import { resolveContent, getResolverPaths } from 'parche:registry/resolvers';
|
|
@@ -101,11 +101,15 @@ let AppTemplate: any = null;
|
|
|
101
101
|
let AppContent: any = null;
|
|
102
102
|
let appMetadata: any = null;
|
|
103
103
|
let appExtras: any = {};
|
|
104
|
-
const RelatedPosts = widgetMap['blog/RelatedPosts'];
|
|
105
|
-
const SeriesNav = widgetMap['blog/SeriesNav'];
|
|
106
104
|
|
|
107
105
|
if (mode === 'resolver' && resolvedApp) {
|
|
108
106
|
AppTemplate = templateMap[resolvedApp.template];
|
|
107
|
+
if (import.meta.env.DEV && resolvedApp.template && !AppTemplate) {
|
|
108
|
+
console.warn(
|
|
109
|
+
`[parche] Unknown template "${resolvedApp.template}" requested by a resolver — ` +
|
|
110
|
+
`not registered by any parche. Falling back to a bare <article>.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
109
113
|
appMetadata = resolvedApp.metadata;
|
|
110
114
|
appExtras = resolvedApp.extras;
|
|
111
115
|
|
|
@@ -137,6 +141,12 @@ if (mode === 'page' && pageData) {
|
|
|
137
141
|
pageMetadata = resolved;
|
|
138
142
|
|
|
139
143
|
PageTemplateComponent = pageTemplate !== 'dynamic' ? templateMap[pageTemplate] : null;
|
|
144
|
+
if (import.meta.env.DEV && pageTemplate !== 'dynamic' && !PageTemplateComponent) {
|
|
145
|
+
console.warn(
|
|
146
|
+
`[parche] Unknown template "${pageTemplate}" set on page "${pageKey}" — ` +
|
|
147
|
+
`not registered by any parche. The page will render without it.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
140
150
|
|
|
141
151
|
const pageEntry = await getEntry('pages', entryId);
|
|
142
152
|
if (pageEntry) {
|
|
@@ -186,18 +196,8 @@ const layoutSections = await resolveLayout(layoutName, locale, defaultLocale);
|
|
|
186
196
|
</article>
|
|
187
197
|
)}
|
|
188
198
|
|
|
189
|
-
{appExtras.
|
|
190
|
-
<
|
|
191
|
-
<SeriesNav
|
|
192
|
-
seriesName={appExtras.seriesNav.seriesName}
|
|
193
|
-
posts={appExtras.seriesNav.posts}
|
|
194
|
-
currentOrder={appExtras.seriesNav.currentOrder}
|
|
195
|
-
/>
|
|
196
|
-
</div>
|
|
197
|
-
)}
|
|
198
|
-
|
|
199
|
-
{appExtras.relatedPosts && RelatedPosts && (
|
|
200
|
-
<RelatedPosts posts={appExtras.relatedPosts} />
|
|
199
|
+
{appExtras.sections?.length > 0 && (
|
|
200
|
+
<DynamicRenderer sections={appExtras.sections} />
|
|
201
201
|
)}
|
|
202
202
|
</LayoutRenderer>
|
|
203
203
|
) : (
|
package/src/styles/semantic.css
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
--ds-color-background: oklch(1 0 0);
|
|
30
30
|
--ds-color-foreground: oklch(0.07 0.01 260);
|
|
31
31
|
--ds-color-surface: oklch(1 0 0);
|
|
32
|
-
--ds-color-primary:
|
|
32
|
+
--ds-color-primary: oklch(0.500 0.220 260); /* brand blue — same as dark, so primary is consistent across modes */
|
|
33
33
|
--ds-color-on-primary: oklch(1 0 0);
|
|
34
34
|
--ds-color-muted: oklch(0.55 0.02 264.42);
|
|
35
35
|
--ds-color-border: oklch(0.90 0.006 260);
|
package/src/types/config.ts
CHANGED
|
@@ -60,7 +60,8 @@ export const siteConfigSchema = z.object({
|
|
|
60
60
|
defaultRobots: defaultRobotsSchema,
|
|
61
61
|
defaultOgType: z.enum(['website', 'article', 'product', 'profile']).default('website'),
|
|
62
62
|
defaultTwitterCard: z.enum(['summary', 'summary_large_image', 'player', 'app']).default('summary_large_image'),
|
|
63
|
-
|
|
63
|
+
// Note: AI-crawler policy for robots.txt is the `parche({ seo: { allowAICrawlers } })`
|
|
64
|
+
// integration option, not a SiteConfig field — kept there so it's a single home.
|
|
64
65
|
preconnect: z.array(z.string()).default([]),
|
|
65
66
|
}).default({}),
|
|
66
67
|
|
|
@@ -77,23 +78,17 @@ export const siteConfigSchema = z.object({
|
|
|
77
78
|
contactPoint: contactPointSchema.optional(),
|
|
78
79
|
}).default({}),
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
theme: z.object({
|
|
93
|
-
name: z.string().default(''),
|
|
94
|
-
darkMode: z.boolean().default(true),
|
|
95
|
-
}).default({}),
|
|
96
|
-
});
|
|
81
|
+
// Note: header/footer nav and theme are NOT configured here.
|
|
82
|
+
// - Chrome (header/footer) is authored in the `layouts` content collection
|
|
83
|
+
// (see content/schemas.ts navigationSchema) and passed to the layout widgets.
|
|
84
|
+
// - Theming is driven by imported theme parches + the `[data-theme]` switcher,
|
|
85
|
+
// not by a config flag.
|
|
86
|
+
// These fields used to live here but were consumed by nothing (a silent trap),
|
|
87
|
+
// so they were removed. Setting them is now a type error, on purpose.
|
|
88
|
+
}).strict();
|
|
89
|
+
// `.strict()` so a stray/typo'd top-level key (e.g. the old `theme`, `header`,
|
|
90
|
+
// `footer`) fails at build with a clear message instead of being silently
|
|
91
|
+
// dropped by Zod — matching `userConfigSchema` in the integration.
|
|
97
92
|
|
|
98
93
|
export type SiteConfig = z.infer<typeof siteConfigSchema>;
|
|
99
94
|
|
package/src/utils/i18n.ts
CHANGED
|
@@ -12,7 +12,19 @@ export interface SlugMapEntry {
|
|
|
12
12
|
entryId: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// The `pages` collection is immutable at runtime, so in production (SSG build and
|
|
16
|
+
// SSR serving) we memoize the slug map + a slug→entry lookup index. This collapses
|
|
17
|
+
// the two `buildSlugMap` calls per page request into one and turns the linear
|
|
18
|
+
// resolve scan into an O(1) lookup (also fixes the static build's O(N²) — every
|
|
19
|
+
// generated page was rebuilding the whole map). Disabled in dev so edited content
|
|
20
|
+
// is always fresh.
|
|
21
|
+
const CACHE = import.meta.env.PROD;
|
|
22
|
+
let _slugMapCache: SlugMapEntry[] | null = null;
|
|
23
|
+
let _slugIndexCache: { defaultLocale: string; index: Map<string, SlugMapEntry> } | null = null;
|
|
24
|
+
|
|
15
25
|
export async function buildSlugMap(): Promise<SlugMapEntry[]> {
|
|
26
|
+
if (CACHE && _slugMapCache) return _slugMapCache;
|
|
27
|
+
|
|
16
28
|
const allPages = await getCollection('pages');
|
|
17
29
|
const map: SlugMapEntry[] = [];
|
|
18
30
|
|
|
@@ -24,31 +36,44 @@ export async function buildSlugMap(): Promise<SlugMapEntry[]> {
|
|
|
24
36
|
map.push({ locale, pageKey, slug, data: entry.data, entryId: entry.id });
|
|
25
37
|
}
|
|
26
38
|
|
|
39
|
+
if (CACHE) _slugMapCache = map;
|
|
27
40
|
return map;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
/** The URL slug an entry is served at (empty string = the root / home). */
|
|
44
|
+
function expectedSlugFor(entry: SlugMapEntry, defaultLocale: string): string {
|
|
45
|
+
if (entry.pageKey === 'home') return entry.locale === defaultLocale ? '' : entry.locale;
|
|
46
|
+
if (entry.locale === defaultLocale) return entry.slug;
|
|
47
|
+
return `${entry.locale}/${entry.slug}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildSlugIndex(slugMap: SlugMapEntry[], defaultLocale: string): Map<string, SlugMapEntry> {
|
|
51
|
+
const index = new Map<string, SlugMapEntry>();
|
|
52
|
+
for (const entry of slugMap) {
|
|
53
|
+
const key = expectedSlugFor(entry, defaultLocale);
|
|
54
|
+
if (!index.has(key)) index.set(key, entry); // first wins, matching the old scan
|
|
55
|
+
}
|
|
56
|
+
return index;
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
export async function resolvePageFromSlug(
|
|
31
60
|
urlSlug: string | undefined,
|
|
32
61
|
defaultLocale: string,
|
|
33
62
|
): Promise<{ pageData: any; locale: string; pageKey: string; entryId: string } | undefined> {
|
|
34
63
|
const slugMap = await buildSlugMap();
|
|
35
64
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} else {
|
|
43
|
-
expectedSlug = `${entry.locale}/${entry.slug}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if ((expectedSlug ?? '') === (urlSlug ?? '')) {
|
|
47
|
-
return { pageData: entry.data, locale: entry.locale, pageKey: entry.pageKey, entryId: entry.entryId };
|
|
48
|
-
}
|
|
65
|
+
let index: Map<string, SlugMapEntry>;
|
|
66
|
+
if (CACHE && _slugIndexCache?.defaultLocale === defaultLocale) {
|
|
67
|
+
index = _slugIndexCache.index;
|
|
68
|
+
} else {
|
|
69
|
+
index = buildSlugIndex(slugMap, defaultLocale);
|
|
70
|
+
if (CACHE) _slugIndexCache = { defaultLocale, index };
|
|
49
71
|
}
|
|
50
72
|
|
|
51
|
-
|
|
73
|
+
const entry = index.get(urlSlug ?? '');
|
|
74
|
+
return entry
|
|
75
|
+
? { pageData: entry.data, locale: entry.locale, pageKey: entry.pageKey, entryId: entry.entryId }
|
|
76
|
+
: undefined;
|
|
52
77
|
}
|
|
53
78
|
|
|
54
79
|
export function getAlternateUrls(
|
package/src/utils/layout.ts
CHANGED
|
@@ -4,6 +4,30 @@ import type { SectionEntry } from '../content/schemas.js';
|
|
|
4
4
|
/** Hardcoded base layout: just a Main area (no header/footer) */
|
|
5
5
|
const BASE_LAYOUT_SECTIONS: SectionEntry[] = [{ widget: 'layout/Main' }];
|
|
6
6
|
|
|
7
|
+
type LayoutEntry = { id: string; data: { sections: SectionEntry[] } };
|
|
8
|
+
|
|
9
|
+
// Content collections are immutable at runtime, so build the id→entry index
|
|
10
|
+
// once and reuse it across requests. Cache only in prod — in dev, content edits
|
|
11
|
+
// must re-read (mirrors the i18n slug-index caching).
|
|
12
|
+
const CACHE = import.meta.env.PROD;
|
|
13
|
+
let _layoutIndex: Map<string, LayoutEntry> | null = null;
|
|
14
|
+
|
|
15
|
+
async function getLayoutIndex(): Promise<Map<string, LayoutEntry> | null> {
|
|
16
|
+
if (CACHE && _layoutIndex) return _layoutIndex;
|
|
17
|
+
let allLayouts;
|
|
18
|
+
try {
|
|
19
|
+
allLayouts = await getCollection('layouts');
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (!allLayouts || allLayouts.length === 0) return null;
|
|
24
|
+
const index = new Map<string, LayoutEntry>(
|
|
25
|
+
(allLayouts as LayoutEntry[]).map((entry) => [entry.id, entry]),
|
|
26
|
+
);
|
|
27
|
+
if (CACHE) _layoutIndex = index;
|
|
28
|
+
return index;
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
/**
|
|
8
32
|
* Resolve a layout by name and locale from the layouts content collection.
|
|
9
33
|
*
|
|
@@ -19,28 +43,14 @@ export async function resolveLayout(
|
|
|
19
43
|
locale: string,
|
|
20
44
|
defaultLocale: string,
|
|
21
45
|
): Promise<SectionEntry[]> {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
allLayouts = await getCollection('layouts');
|
|
25
|
-
} catch {
|
|
26
|
-
return BASE_LAYOUT_SECTIONS;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (!allLayouts || allLayouts.length === 0) {
|
|
30
|
-
return BASE_LAYOUT_SECTIONS;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const find = (id: string) => allLayouts.find((entry) => entry.id === id);
|
|
46
|
+
const index = await getLayoutIndex();
|
|
47
|
+
if (!index) return BASE_LAYOUT_SECTIONS;
|
|
34
48
|
|
|
35
49
|
const entry =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!entry) {
|
|
42
|
-
return BASE_LAYOUT_SECTIONS;
|
|
43
|
-
}
|
|
50
|
+
index.get(`${locale}/${name}`) ??
|
|
51
|
+
index.get(`${defaultLocale}/${name}`) ??
|
|
52
|
+
index.get(`${locale}/default`) ??
|
|
53
|
+
index.get(`${defaultLocale}/default`);
|
|
44
54
|
|
|
45
|
-
return entry.data.sections;
|
|
55
|
+
return entry ? entry.data.sections : BASE_LAYOUT_SECTIONS;
|
|
46
56
|
}
|