fimo 0.17.0 → 0.18.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.
Files changed (159) hide show
  1. package/assets/astro/components/DateTime.astro +22 -0
  2. package/assets/astro/components/index.d.ts +1 -0
  3. package/assets/astro/components/index.js +1 -0
  4. package/assets/content-templates/shared/types.eta +2 -0
  5. package/assets/skills/fimo-cli/SKILL.md +3 -2
  6. package/assets/skills/fimo-cli/references/content.md +2 -2
  7. package/assets/skills/fimo-cli/references/deploy.md +2 -0
  8. package/assets/skills/fimo-cli/references/init.md +11 -4
  9. package/assets/skills/fimo-cli/references/locales.md +38 -14
  10. package/assets/skills/fimo-cli/references/pages.md +70 -0
  11. package/assets/skills/fimo-cli/references/webhooks.md +1 -1
  12. package/assets/skills/fimo-code/SKILL.md +11 -0
  13. package/assets/skills/fimo-code/assets/react-router-locales/LocaleLayout.tsx +12 -0
  14. package/assets/skills/fimo-code/assets/react-router-locales/routes.ts +7 -6
  15. package/assets/skills/fimo-code/references/best-practices/astro.md +47 -0
  16. package/assets/skills/fimo-code/references/best-practices/next.md +42 -0
  17. package/assets/skills/fimo-code/references/best-practices/sveltekit.md +17 -0
  18. package/assets/skills/fimo-code/references/content/richtext.md +5 -0
  19. package/assets/skills/fimo-code/references/content/schema.md +4 -1
  20. package/assets/skills/fimo-code/references/frameworks/astro.md +5 -3
  21. package/assets/skills/fimo-code/references/frameworks/hosting.md +5 -2
  22. package/assets/skills/fimo-code/references/frameworks/next/app-router.md +6 -0
  23. package/assets/skills/fimo-code/references/frameworks/next/locales.md +12 -2
  24. package/assets/skills/fimo-code/references/frameworks/next.md +7 -0
  25. package/assets/skills/fimo-code/references/frameworks/react-router.md +15 -7
  26. package/assets/skills/fimo-code/references/frameworks/sveltekit.md +15 -8
  27. package/assets/skills/fimo-code/references/labels.md +9 -1
  28. package/assets/skills/fimo-code/references/locales.md +8 -2
  29. package/assets/skills/fimo-code/references/migrations/react-router-route-ownership.md +93 -0
  30. package/assets/skills/fimo-code/references/ui.md +21 -2
  31. package/assets/skills/fimo-migration/SKILL.md +276 -51
  32. package/assets/skills/fimo-migration/references/content.md +119 -35
  33. package/assets/skills/fimo-migration/references/decision.md +72 -0
  34. package/assets/skills/fimo-migration/references/forms.md +38 -6
  35. package/assets/skills/fimo-migration/references/frameworks/nextjs.md +179 -23
  36. package/assets/skills/fimo-migration/references/import-contract.md +71 -19
  37. package/assets/skills/fimo-migration/references/manifest.md +11 -161
  38. package/assets/skills/fimo-migration/references/media.md +33 -1
  39. package/assets/skills/fimo-migration/references/platforms.md +26 -2
  40. package/assets/skills/fimo-migration/references/qa.md +167 -13
  41. package/assets/skills/fimo-migration/references/workers/content-execution.md +39 -0
  42. package/assets/skills/fimo-migration/references/workers/content-planner.md +64 -0
  43. package/assets/skills/fimo-migration/references/workers/framework.md +37 -0
  44. package/assets/skills/fimo-migration/references/workers/validator.md +25 -0
  45. package/assets/skills/fimo-migration/references/workers.md +82 -0
  46. package/assets/skills/fimo-migration/references/workflow.md +140 -192
  47. package/assets/skills/fimo-migration/scripts/apply-decision.mjs +534 -0
  48. package/assets/skills/fimo-migration/scripts/build-canonical-manifest.mjs +7 -4
  49. package/assets/skills/fimo-migration/scripts/write-migration-report.mjs +191 -4
  50. package/assets/skills/fimo-studio/references/cms.md +25 -7
  51. package/assets/skills/fimo-studio/references/webhooks.md +1 -1
  52. package/assets/sveltekit/components/DateTime.svelte +35 -0
  53. package/assets/sveltekit/components/FimoProvider.svelte +2 -2
  54. package/assets/sveltekit/components/index.d.ts +1 -0
  55. package/assets/sveltekit/components/index.js +1 -0
  56. package/dist/build/next/config.d.ts +4 -1
  57. package/dist/build/next/config.js +21 -5
  58. package/dist/build/sveltekit/vite.d.ts +3 -4
  59. package/dist/build/sveltekit/vite.js +3 -4
  60. package/dist/build/vite/plugins/runtime-env.d.ts +2 -1
  61. package/dist/build/vite/plugins/runtime-env.js +14 -10
  62. package/dist/cli/bundle.json +2 -2
  63. package/dist/cli/index.js +76 -78
  64. package/dist/runtime/app/FimoProviders.js +25 -14
  65. package/dist/runtime/app/prefetch.d.ts +3 -1
  66. package/dist/runtime/app/prefetch.js +11 -55
  67. package/dist/runtime/astro/preview.js +7 -9
  68. package/dist/runtime/content/client/internal.js +1 -0
  69. package/dist/runtime/i18n/locale.d.ts +4 -0
  70. package/dist/runtime/i18n/locale.js +14 -14
  71. package/dist/runtime/next/client-runtime.js +5 -1
  72. package/dist/runtime/next/client.d.ts +3 -2
  73. package/dist/runtime/next/client.js +2 -1
  74. package/dist/runtime/next/labels.d.ts +2 -2
  75. package/dist/runtime/next/labels.js +2 -2
  76. package/dist/runtime/next/preview.d.ts +13 -0
  77. package/dist/runtime/next/preview.js +34 -0
  78. package/dist/runtime/next/primitives/server.d.ts +2 -0
  79. package/dist/runtime/next/primitives/server.js +8 -0
  80. package/dist/runtime/next/provider.d.ts +5 -0
  81. package/dist/runtime/next/provider.js +17 -3
  82. package/dist/runtime/next/server-runtime.js +4 -1
  83. package/dist/runtime/next/server.d.ts +1 -1
  84. package/dist/runtime/next/server.js +1 -1
  85. package/dist/runtime/paths/get-fimo-paths.d.ts +6 -5
  86. package/dist/runtime/paths/get-fimo-paths.js +43 -42
  87. package/dist/runtime/preview/location.d.ts +19 -2
  88. package/dist/runtime/preview/location.js +16 -2
  89. package/dist/runtime/primitives/index.d.ts +1 -0
  90. package/dist/runtime/primitives/index.js +1 -0
  91. package/dist/runtime/react/primitives/editable/DateTime.d.ts +4 -0
  92. package/dist/runtime/react/primitives/editable/DateTime.js +13 -0
  93. package/dist/runtime/react/primitives/renderers/DateRenderer.d.ts +7 -1
  94. package/dist/runtime/react/primitives/renderers/DateRenderer.js +11 -8
  95. package/dist/runtime/react-router/{routes.d.ts → legacy/routes.d.ts} +6 -1
  96. package/dist/runtime/react-router/{routes.js → legacy/routes.js} +22 -7
  97. package/dist/runtime/routes/contract.d.ts +116 -0
  98. package/dist/runtime/routes/contract.js +377 -0
  99. package/dist/runtime/routes/index.d.ts +3 -0
  100. package/dist/runtime/routes/index.js +3 -0
  101. package/dist/runtime/routing/AppRoutesProvider.js +4 -3
  102. package/dist/runtime/seo/resolver.d.ts +5 -0
  103. package/dist/runtime/seo/resolver.js +10 -42
  104. package/dist/runtime/shared/fimo-config.types.d.ts +34 -8
  105. package/dist/runtime/shared/preview-location.d.ts +1 -22
  106. package/dist/runtime/shared/preview-location.js +5 -93
  107. package/dist/runtime/sveltekit/preview.d.ts +12 -3
  108. package/dist/runtime/sveltekit/preview.js +13 -8
  109. package/dist/scripts/sync-forms.js +1 -1
  110. package/dist/scripts/sync-schemas.js +1 -4
  111. package/dist/scripts/validate-fimo-config.js +5 -1
  112. package/dist/scripts/validate-page-registry.d.ts +2 -0
  113. package/dist/scripts/validate-page-registry.js +167 -0
  114. package/dist/scripts/validate-route-metadata.js +132 -41
  115. package/package.json +7 -3
  116. package/templates/astro/.fimo/config.json +13 -5
  117. package/templates/astro/AGENTS.md +5 -1
  118. package/templates/astro/astro.config.mjs +7 -0
  119. package/templates/astro/pnpm-workspace.yaml +0 -3
  120. package/templates/astro/src/components/ProjectOverview.astro +9 -6
  121. package/templates/astro/src/layouts/Base.astro +1 -1
  122. package/templates/astro/src/locales.ts +4 -0
  123. package/templates/astro/src/pages/[locale]/index.astro +19 -0
  124. package/templates/astro/src/pages/[locale]/projects/[slug].astro +35 -0
  125. package/templates/astro/src/pages/[locale]/projects/index.astro +19 -0
  126. package/templates/astro/src/pages/index.astro +5 -2
  127. package/templates/astro/src/pages/projects/[slug].astro +4 -2
  128. package/templates/astro/src/pages/projects/index.astro +5 -2
  129. package/templates/nextjs/.fimo/config.json +2 -1
  130. package/templates/nextjs/AGENTS.md +5 -0
  131. package/templates/nextjs/src/app/(default)/layout.tsx +10 -0
  132. package/templates/nextjs/src/app/[locale]/layout.tsx +22 -0
  133. package/templates/nextjs/src/app/[locale]/page.tsx +1 -0
  134. package/templates/nextjs/src/components/Document.tsx +26 -0
  135. package/templates/nextjs/src/components/FimoPreviewBridge.tsx +13 -0
  136. package/templates/nextjs/src/locales.ts +13 -0
  137. package/templates/react-router/.fimo/config.json +3 -6
  138. package/templates/react-router/AGENTS.md +4 -0
  139. package/templates/react-router/package.json +1 -1
  140. package/templates/react-router/pnpm-workspace.yaml +1 -3
  141. package/templates/react-router/react-router.config.ts +4 -10
  142. package/templates/react-router/src/pages/LocaleLayout.tsx +12 -0
  143. package/templates/react-router/src/root.tsx +2 -4
  144. package/templates/react-router/src/routes.ts +5 -3
  145. package/templates/sveltekit/.fimo/config.json +2 -1
  146. package/templates/sveltekit/AGENTS.md +5 -0
  147. package/templates/sveltekit/src/app.html +1 -1
  148. package/templates/sveltekit/src/hooks.server.ts +11 -0
  149. package/templates/sveltekit/src/locales.ts +8 -0
  150. package/templates/sveltekit/src/params/locale.ts +5 -0
  151. package/templates/sveltekit/src/routes/+layout.server.ts +6 -1
  152. package/templates/sveltekit/src/routes/+layout.svelte +5 -0
  153. package/assets/skills/fimo-migration/references/inventory/coordinator.md +0 -40
  154. package/assets/skills/fimo-migration/references/inventory/locales/next-intl.md +0 -39
  155. package/assets/skills/fimo-migration/references/inventory/locales.md +0 -41
  156. package/assets/skills/fimo-migration/scripts/inventory/locales/next-intl.mjs +0 -459
  157. package/templates/nextjs/src/app/layout.tsx +0 -17
  158. /package/templates/nextjs/src/app/{page.tsx → (default)/page.tsx} +0 -0
  159. /package/templates/sveltekit/src/routes/{+page.svelte → [[locale=locale]]/+page.svelte} +0 -0
@@ -1,93 +1,5 @@
1
- const PREVIEW_INTERNAL_QUERY_PARAMS = new Set(['bl_preview_token', 'preview_token', 't']);
2
- const PREVIEW_PARENT_ORIGIN_STORAGE_KEY = 'fimo-preview-parent-origin';
3
- let rememberedPreviewParentOrigin;
4
- const getOrigin = (value) => {
5
- if (!value) {
6
- return null;
7
- }
8
- try {
9
- const origin = new URL(value).origin;
10
- return origin === 'null' ? null : origin;
11
- }
12
- catch {
13
- return null;
14
- }
15
- };
16
- /** Prefer the immediate embedding document over a potentially stale referrer. */
17
- export function resolvePreviewParentOrigin(referrer, ancestorOrigins) {
18
- return getOrigin(ancestorOrigins?.[0]) ?? getOrigin(referrer);
19
- }
20
- /** Resolve and remember the trusted Studio origin across full-page navigation. */
21
- export function getPreviewParentOrigin(browser) {
22
- const browserWindow = browser?.window ?? (typeof window === 'undefined' ? undefined : window);
23
- const browserDocument = browser?.document ?? (typeof document === 'undefined' ? undefined : document);
24
- if (!browserWindow) {
25
- return null;
26
- }
27
- const storage = readSessionStorage(browserWindow);
28
- const ancestorOrigin = getOrigin(browserWindow.location.ancestorOrigins?.[0]);
29
- if (ancestorOrigin) {
30
- rememberPreviewParentOrigin(ancestorOrigin, storage);
31
- return ancestorOrigin;
32
- }
33
- const rememberedOrigin = readRememberedPreviewParentOrigin(storage);
34
- if (rememberedOrigin) {
35
- return rememberedOrigin;
36
- }
37
- const referrerOrigin = getOrigin(browserDocument?.referrer);
38
- if (referrerOrigin) {
39
- rememberPreviewParentOrigin(referrerOrigin, storage);
40
- }
41
- return referrerOrigin;
42
- }
43
- function readSessionStorage(browserWindow) {
44
- try {
45
- return browserWindow.sessionStorage;
46
- }
47
- catch {
48
- return undefined;
49
- }
50
- }
51
- function readRememberedPreviewParentOrigin(storage) {
52
- if (rememberedPreviewParentOrigin !== undefined) {
53
- return rememberedPreviewParentOrigin;
54
- }
55
- if (!storage) {
56
- rememberedPreviewParentOrigin = null;
57
- return null;
58
- }
59
- try {
60
- rememberedPreviewParentOrigin = getOrigin(storage.getItem(PREVIEW_PARENT_ORIGIN_STORAGE_KEY) ?? undefined);
61
- }
62
- catch {
63
- rememberedPreviewParentOrigin = null;
64
- }
65
- return rememberedPreviewParentOrigin;
66
- }
67
- function rememberPreviewParentOrigin(origin, storage) {
68
- rememberedPreviewParentOrigin = origin;
69
- if (!storage) {
70
- return;
71
- }
72
- try {
73
- storage.setItem(PREVIEW_PARENT_ORIGIN_STORAGE_KEY, origin);
74
- }
75
- catch {
76
- // Storage availability must not block the preview message channel.
77
- }
78
- }
79
- /** Keep sandbox credentials and the iframe cache-buster out of app navigation state. */
80
- export function getPreviewLocationPath(location) {
81
- const search = new URLSearchParams(location.search ?? '');
82
- for (const key of PREVIEW_INTERNAL_QUERY_PARAMS) {
83
- search.delete(key);
84
- }
85
- const query = search.toString();
86
- return `${location.pathname}${query ? `?${query}` : ''}${location.hash ?? ''}`;
87
- }
88
- export function getPreviewLocationChange(location) {
89
- return {
90
- path: location.pathname,
91
- navigationPath: getPreviewLocationPath(location),
92
- };
93
- }
1
+ // The SvelteKit bundle reaches the preview location contract through this
2
+ // path. Only one implementation may exist: the remembered parent origin is
3
+ // module state, and a second copy would let the two bridges disagree about
4
+ // which Studio origin is trusted.
5
+ export * from '../preview/location.js';
@@ -3,11 +3,20 @@ export interface PreviewNavigation {
3
3
  goto: (to: string) => unknown;
4
4
  /** SvelteKit `invalidateAll` — reruns every `load` against fresh content. */
5
5
  invalidateAll: () => Promise<unknown>;
6
+ /** Optional app-owned locale resolver, evaluated for every bridge message. */
7
+ locale?: () => string | undefined;
6
8
  }
7
9
  /** Load the platform preview script once. Empty configuration keeps preview disabled. */
8
10
  export declare function ensurePreviewScript(src: string | undefined): boolean;
9
- /** Report where the app is now. Studio keeps its URL and route selector in sync from this. */
10
- export declare function sendPreviewLocation(): void;
11
+ /**
12
+ * Report where the app is now, and in which locale. Studio keeps its URL,
13
+ * route selector and content locale in sync from this.
14
+ *
15
+ * `locale` defaults to the locale the SvelteKit shell rendered into
16
+ * `<html lang>`; pass one explicitly when the app resolves it somewhere the
17
+ * document does not show, for example a cookie or a user preference.
18
+ */
19
+ export declare function sendPreviewLocation(locale?: string): void;
11
20
  /**
12
21
  * Listen for the live Studio preview protocol and answer it with SvelteKit's
13
22
  * own navigation APIs. Returns the disconnect function.
@@ -15,5 +24,5 @@ export declare function sendPreviewLocation(): void;
15
24
  * Only messages from the established parent origin are honored. History stays
16
25
  * native, so Studio's back/forward controls need no protocol of their own.
17
26
  */
18
- export declare function connectPreview({ goto, invalidateAll }: PreviewNavigation): () => void;
27
+ export declare function connectPreview({ goto, invalidateAll, locale }: PreviewNavigation): () => void;
19
28
  //# sourceMappingURL=preview.d.ts.map
@@ -1,4 +1,4 @@
1
- import { getPreviewLocationChange, getPreviewParentOrigin } from '../shared/preview-location.js';
1
+ import { getDocumentLocale, getPreviewLocationChange, getPreviewParentOrigin } from '../shared/preview-location.js';
2
2
  /** Load the platform preview script once. Empty configuration keeps preview disabled. */
3
3
  export function ensurePreviewScript(src) {
4
4
  if (!src) {
@@ -13,14 +13,19 @@ export function ensurePreviewScript(src) {
13
13
  }
14
14
  return true;
15
15
  }
16
- /** Report where the app is now. Studio keeps its URL and route selector in sync from this. */
17
- export function sendPreviewLocation() {
16
+ /**
17
+ * Report where the app is now, and in which locale. Studio keeps its URL,
18
+ * route selector and content locale in sync from this.
19
+ *
20
+ * `locale` defaults to the locale the SvelteKit shell rendered into
21
+ * `<html lang>`; pass one explicitly when the app resolves it somewhere the
22
+ * document does not show, for example a cookie or a user preference.
23
+ */
24
+ export function sendPreviewLocation(locale) {
18
25
  const parentOrigin = getPreviewParentOrigin();
19
- const locationChange = getPreviewLocationChange(window.location);
20
26
  window.parent.postMessage({
21
27
  type: 'nav/locationChange',
22
- // Query and hash state only travels to an established parent origin.
23
- payload: parentOrigin ? locationChange : { path: locationChange.path },
28
+ payload: getPreviewLocationChange(window.location, locale ?? getDocumentLocale(document, 'en'), parentOrigin),
24
29
  }, parentOrigin ?? '*');
25
30
  }
26
31
  /** Reliable-delivery reply: the parent retries an unacknowledged message forever. */
@@ -48,7 +53,7 @@ async function refreshContent(message, invalidateAll) {
48
53
  * Only messages from the established parent origin are honored. History stays
49
54
  * native, so Studio's back/forward controls need no protocol of their own.
50
55
  */
51
- export function connectPreview({ goto, invalidateAll }) {
56
+ export function connectPreview({ goto, invalidateAll, locale }) {
52
57
  const onMessage = (event) => {
53
58
  const parentOrigin = getPreviewParentOrigin();
54
59
  if (event.source !== window.parent || !parentOrigin || event.origin !== parentOrigin) {
@@ -63,7 +68,7 @@ export function connectPreview({ goto, invalidateAll }) {
63
68
  return;
64
69
  case 'nav/read-routes':
65
70
  // The app owns its route table; answer with where it actually is.
66
- sendPreviewLocation();
71
+ sendPreviewLocation(locale?.());
67
72
  return;
68
73
  case 'content/refresh':
69
74
  void refreshContent(message, invalidateAll);
@@ -41,7 +41,7 @@ function frameworkFormsTarget(framework) {
41
41
  return 'next';
42
42
  if (framework === 'astro')
43
43
  return 'astro';
44
- if (framework === 'sveltekit' || framework === 'sveltekit-1')
44
+ if (framework === 'sveltekit')
45
45
  return 'sveltekit';
46
46
  return null;
47
47
  }
@@ -120,10 +120,7 @@ function contentRuntimeImportFor(framework) {
120
120
  if (framework === 'astro') {
121
121
  return 'fimo/astro';
122
122
  }
123
- // @vercel/frameworks reports SvelteKit as `sveltekit-1`, while Fimo's
124
- // public runtime profile is `sveltekit`. Both describe the same codegen
125
- // target; callers may supply either the detector evidence or the profile.
126
- if (framework === 'sveltekit' || framework === 'sveltekit-1') {
123
+ if (framework === 'sveltekit') {
127
124
  return 'fimo/sveltekit';
128
125
  }
129
126
  return 'fimo/ui';
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { FimoAppConfigSchema } from '@fimo/api-contracts';
4
4
  import { resolveFimoConfigPath } from '../runtime/shared/fimo-config.server.js';
5
+ import { validatePageRegistrySchemaReferences } from './validate-page-registry.js';
5
6
  export function validateFimoConfig(cwd) {
6
7
  const configPath = resolveFimoConfigPath(cwd);
7
8
  if (!fs.existsSync(configPath)) {
@@ -17,7 +18,10 @@ export function validateFimoConfig(cwd) {
17
18
  }
18
19
  const parsed = FimoAppConfigSchema.safeParse(value);
19
20
  if (parsed.success) {
20
- return { errors: [], configChecked: true };
21
+ return {
22
+ errors: validatePageRegistrySchemaReferences(cwd, value, displayPath),
23
+ configChecked: true,
24
+ };
21
25
  }
22
26
  return {
23
27
  errors: parsed.error.issues.map((issue) => {
@@ -0,0 +1,2 @@
1
+ /** Cross-check Page config references against the project's schema source files. */
2
+ export declare function validatePageRegistrySchemaReferences(cwd: string, config: Record<string, unknown>, displayPath: string): string[];
@@ -0,0 +1,167 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pageSourceParamExpressions, parsePageParamExpression, } from '@fimo/api-contracts';
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function fieldList(value) {
8
+ if (typeof value === 'string') {
9
+ return [value];
10
+ }
11
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
12
+ }
13
+ function queryFields(query) {
14
+ if (!isRecord(query)) {
15
+ return [];
16
+ }
17
+ const fields = new Set();
18
+ if (isRecord(query.where)) {
19
+ for (const field of Object.keys(query.where)) {
20
+ if (!field.startsWith('$')) {
21
+ fields.add(field.split('.')[0]);
22
+ }
23
+ }
24
+ }
25
+ for (const sort of fieldList(query.sort)) {
26
+ fields.add(sort.replace(/^-/, '').split('.')[0]);
27
+ }
28
+ for (const field of [...fieldList(query.fields), ...fieldList(query.populate)]) {
29
+ fields.add(field.split('.')[0]);
30
+ }
31
+ return [...fields];
32
+ }
33
+ function readSchemas(cwd) {
34
+ const directory = path.join(cwd, 'src/schemas');
35
+ if (!fs.existsSync(directory)) {
36
+ return null;
37
+ }
38
+ const schemas = new Map();
39
+ for (const file of fs.readdirSync(directory).filter((name) => name.endsWith('.json'))) {
40
+ const fallbackUid = path.basename(file, '.json');
41
+ let value;
42
+ try {
43
+ value = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
44
+ }
45
+ catch {
46
+ schemas.set(fallbackUid, { fields: {}, isSingleton: false, valid: false });
47
+ continue;
48
+ }
49
+ if (!isRecord(value)) {
50
+ schemas.set(fallbackUid, { fields: {}, isSingleton: false, valid: false });
51
+ continue;
52
+ }
53
+ const uid = typeof value.uid === 'string' && value.uid ? value.uid : fallbackUid;
54
+ schemas.set(uid, {
55
+ fields: isRecord(value.fields) ? value.fields : {},
56
+ isSingleton: value.isSingleton === true,
57
+ valid: true,
58
+ });
59
+ }
60
+ return schemas;
61
+ }
62
+ function schemaFields(schema) {
63
+ return new Set([...Object.keys(schema.fields), 'id', 'slug']);
64
+ }
65
+ function queryableFields(schema) {
66
+ return new Set([...schemaFields(schema), 'createdAt', 'updatedAt']);
67
+ }
68
+ function validateQuery(query, pointer, schema, params, schemas) {
69
+ const errors = [];
70
+ const unknownField = queryFields(query).find((field) => !queryableFields(schema).has(field));
71
+ if (unknownField) {
72
+ errors.push(`${pointer}: Query field "${unknownField}" does not exist on the referenced schema.`);
73
+ }
74
+ for (const expression of pageSourceParamExpressions(query)) {
75
+ const parsed = parsePageParamExpression(expression);
76
+ const binding = parsed ? params[parsed.param] : undefined;
77
+ if (!parsed || !binding || typeof binding.schema !== 'string') {
78
+ continue;
79
+ }
80
+ const boundSchema = schemas.get(binding.schema);
81
+ const field = parsed.field.split('.')[0];
82
+ if (boundSchema?.valid && !schemaFields(boundSchema).has(field)) {
83
+ errors.push(`${pointer}: ${expression} reads missing field "${field}" from schema "${binding.schema}".`);
84
+ }
85
+ }
86
+ return errors;
87
+ }
88
+ /** Cross-check Page config references against the project's schema source files. */
89
+ export function validatePageRegistrySchemaReferences(cwd, config, displayPath) {
90
+ const schemas = readSchemas(cwd);
91
+ if (!schemas || !isRecord(config.routes)) {
92
+ return [];
93
+ }
94
+ const errors = [];
95
+ for (const [routeId, rawRoute] of Object.entries(config.routes)) {
96
+ if (!isRecord(rawRoute)) {
97
+ continue;
98
+ }
99
+ const params = isRecord(rawRoute.params) ? rawRoute.params : {};
100
+ for (const [param, binding] of Object.entries(params)) {
101
+ const pointer = `${displayPath}#routes.${routeId}.params.${param}`;
102
+ if (typeof binding.schema !== 'string') {
103
+ continue;
104
+ }
105
+ const schema = schemas.get(binding.schema);
106
+ if (!schema) {
107
+ errors.push(`${pointer}.schema: Unknown schema "${binding.schema}".`);
108
+ continue;
109
+ }
110
+ if (!schema.valid) {
111
+ continue;
112
+ }
113
+ if (schema.isSingleton) {
114
+ errors.push(`${pointer}.schema: Page params require a Collection, but "${binding.schema}" is a Single.`);
115
+ }
116
+ const fields = schemaFields(schema);
117
+ for (const key of ['queryField', 'displayField']) {
118
+ const field = binding[key];
119
+ if (typeof field === 'string' && !fields.has(field)) {
120
+ errors.push(`${pointer}.${key}: Field "${field}" does not exist on schema "${binding.schema}".`);
121
+ }
122
+ }
123
+ if (typeof binding.queryField === 'string' && !['id', 'slug'].includes(binding.queryField)) {
124
+ const definition = schema.fields[binding.queryField];
125
+ if (isRecord(definition)) {
126
+ if (definition.required !== true) {
127
+ errors.push(`${pointer}.queryField: Custom URL field "${binding.queryField}" must set "required": true.`);
128
+ }
129
+ if (definition.unique !== true) {
130
+ errors.push(`${pointer}.queryField: Custom URL field "${binding.queryField}" must set "unique": true.`);
131
+ }
132
+ }
133
+ }
134
+ errors.push(...validateQuery(binding.query, `${pointer}.query`, schema, params, schemas));
135
+ }
136
+ if (!isRecord(rawRoute.sources)) {
137
+ continue;
138
+ }
139
+ for (const [sourceName, source] of Object.entries(rawRoute.sources)) {
140
+ if (!isRecord(source) || (source.type !== 'single' && source.type !== 'collection')) {
141
+ continue;
142
+ }
143
+ const pointer = `${displayPath}#routes.${routeId}.sources.${sourceName}`;
144
+ if (typeof source.schema !== 'string') {
145
+ continue;
146
+ }
147
+ const schema = schemas.get(source.schema);
148
+ if (!schema) {
149
+ errors.push(`${pointer}.schema: Unknown schema "${source.schema}".`);
150
+ continue;
151
+ }
152
+ if (!schema.valid) {
153
+ continue;
154
+ }
155
+ if (source.type === 'single' && !schema.isSingleton) {
156
+ errors.push(`${pointer}.schema: Source expects a Single, but "${source.schema}" is a Collection.`);
157
+ }
158
+ if (source.type === 'collection' && schema.isSingleton) {
159
+ errors.push(`${pointer}.schema: Source expects a Collection, but "${source.schema}" is a Single.`);
160
+ }
161
+ if (source.type === 'collection') {
162
+ errors.push(...validateQuery(source.query, `${pointer}.query`, schema, params, schemas));
163
+ }
164
+ }
165
+ }
166
+ return errors;
167
+ }