@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.
@@ -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 a JS module that exports the widget map.
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: { varName: string; key: string; importPath: string }[] = [];
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
- const key = extractWidgetKey(virtualId);
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 imports = entries.map((e) => `import ${e.varName} from '${e.importPath}';`).join('\n');
131
- const mapEntries = entries.map((e) => ` '${e.key}': ${e.varName},`).join('\n');
133
+ const loaderEntries = entries
134
+ .map((e) => ` ${JSON.stringify(e.key)}: () => import(${JSON.stringify(e.importPath)}),`)
135
+ .join('\n');
132
136
 
133
- return `${imports}
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 schemaEntries: string[] = [];
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
- 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
- }`);
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
- 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
- }`);
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
- ${schemaEntries.join(',\n')}
291
- };
338
+ export const widgetSchemas = {};
339
+ export const widgetMeta = {};
292
340
 
293
- export const widgetMeta = {
294
- ${metaEntries.join(',\n')}
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] === BASE_CSS_PATH) {
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.seriesNav && SeriesNav && (
190
- <div class="max-w-4xl mx-auto px-4 mb-8">
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
  ) : (
@@ -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: var(--color-neutral-900) /* oklch(0.500 0.220 260) */;
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);
@@ -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
- allowAICrawlers: z.boolean().default(true),
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
- header: z.object({
81
- logo: z.string().optional(),
82
- links: z.array(linkSchema).default([]),
83
- actions: z.array(actionSchema).default([]),
84
- }).default({}),
85
-
86
- footer: z.object({
87
- columns: z.array(footerColumnSchema).default([]),
88
- copyright: z.string().default(''),
89
- socialLinks: z.array(linkSchema).default([]),
90
- }).default({}),
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
- for (const entry of slugMap) {
37
- let expectedSlug: string | undefined;
38
- if (entry.pageKey === 'home') {
39
- expectedSlug = entry.locale === defaultLocale ? undefined : entry.locale;
40
- } else if (entry.locale === defaultLocale) {
41
- expectedSlug = entry.slug;
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
- return undefined;
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(
@@ -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
- let allLayouts;
23
- try {
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
- find(`${locale}/${name}`) ??
37
- find(`${defaultLocale}/${name}`) ??
38
- find(`${locale}/default`) ??
39
- find(`${defaultLocale}/default`);
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
  }