@jxsuite/compiler 1.5.0 → 2.0.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.
@@ -17,6 +17,8 @@ import { extname, join, relative, resolve } from "node:path";
17
17
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
18
18
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
19
19
  import type { JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/schema/types";
20
+ import { localeOfRoute } from "./i18n.ts";
21
+ import type { ResolvedI18n } from "./i18n.ts";
20
22
 
21
23
  interface Route {
22
24
  urlPattern: string; // URL pattern (e.g. "/blog/:slug")
@@ -27,6 +29,13 @@ interface Route {
27
29
  params: string[]; // Parameter names (e.g. ["slug"])
28
30
  $layout: string | null; // Layout override from page frontmatter, if any
29
31
  _pathParams?: Record<string, string>; // Resolved path parameters
32
+ /**
33
+ * The timestamp of the thing this concrete route was generated FROM, when that is not the
34
+ * template file. A collection route's source is an entry; `sourcePath` still points at the
35
+ * `[slug].json` that rendered it, so without this every post in an archive reports the template's
36
+ * mtime and the whole collection looks edited whenever the template is.
37
+ */
38
+ sourceMtime?: string;
30
39
  }
31
40
 
32
41
  /**
@@ -57,6 +66,104 @@ export async function readPageDocument(
57
66
  return (await entry.call("parse", source)) as JxDocument;
58
67
  }
59
68
 
69
+ /**
70
+ * The `$translationKey` each route's document declares, for the routes that declare one.
71
+ *
72
+ * A **pre-pass**, because a page's alternates depend on the whole route table (§13.5): the set has
73
+ * to be complete before the first page is compiled, so a document cannot tell the build what its
74
+ * key is while it is being compiled.
75
+ *
76
+ * The file is read and the **parse is skipped** unless the text mentions the key. Most pages of a
77
+ * multilingual site never declare one — their paths are parallel and the derivation is right — so
78
+ * paying a second full parse for every page to find a key that is usually absent would be a cost
79
+ * with nothing behind it. Documents sharing a source (a `$paths` template's expansions) are read
80
+ * once.
81
+ *
82
+ * A document that fails to parse is skipped rather than thrown from here. It will fail again a
83
+ * moment later while being compiled, where the error names the page and the rest of the site still
84
+ * builds; failing in a pre-pass would turn one bad page into no site at all.
85
+ *
86
+ * **A key may name its route's parameters**, as `${slug}`, and that is what makes a collection's
87
+ * localized URLs work: one `[slug]` template expands to one route per entry, so a key that could
88
+ * not vary per entry would claim a single identity for the whole collection and the build would
89
+ * report it as a duplicate. `pages/fr-ca/expositions/[slug].json` declaring `"exhibitions/${slug}"`
90
+ * pairs each French post with the English one it translates, because two translations of an entry
91
+ * share an id (§13.3) and the id is what the parameter carries.
92
+ *
93
+ * @param {readonly {
94
+ * sourcePath: string;
95
+ * urlPattern: string;
96
+ * _pathParams?: Record<string, string>;
97
+ * }[]} routes
98
+ * - Concrete routes
99
+ * @param {FormatRegistry} [registry]
100
+ * @returns {Promise<Map<string, string>>} Keyed by `urlPattern`
101
+ */
102
+ export async function readTranslationKeys(
103
+ routes: readonly {
104
+ sourcePath: string;
105
+ urlPattern: string;
106
+ _pathParams?: Record<string, string> | undefined;
107
+ }[],
108
+ registry?: FormatRegistry,
109
+ ): Promise<Map<string, string>> {
110
+ const out = new Map<string, string>();
111
+ const byFile = new Map<string, string | null>();
112
+ for (const route of routes) {
113
+ if (!byFile.has(route.sourcePath)) {
114
+ byFile.set(route.sourcePath, await declaredTranslationKey(route.sourcePath, registry));
115
+ }
116
+ const key = byFile.get(route.sourcePath);
117
+ if (key !== null && key !== undefined) {
118
+ out.set(route.urlPattern, withRouteParams(key, route._pathParams));
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Substitute a route's own parameters into a declared key.
126
+ *
127
+ * A parameter with no value is left as it was written rather than blanked: a key that quietly
128
+ * became `exhibitions/` would pair every entry in the collection with every other, which is a
129
+ * duplicate the build reports — and the report naming `${slug}` says what is actually wrong.
130
+ *
131
+ * @param {string} key
132
+ * @param {Record<string, string> | undefined} params
133
+ * @returns {string}
134
+ */
135
+ function withRouteParams(key: string, params: Record<string, string> | undefined): string {
136
+ if (!key.includes("${")) {
137
+ return key;
138
+ }
139
+ return key.replaceAll(/\$\{(\w+)\}/g, (whole, name: string) => params?.[name] ?? whole);
140
+ }
141
+
142
+ /**
143
+ * One document's `$translationKey`, or null when it declares none or cannot be read.
144
+ *
145
+ * @param {string} sourcePath
146
+ * @param {FormatRegistry} [registry]
147
+ * @returns {Promise<string | null>}
148
+ */
149
+ async function declaredTranslationKey(
150
+ sourcePath: string,
151
+ registry?: FormatRegistry,
152
+ ): Promise<string | null> {
153
+ if (!readFileSync(sourcePath, "utf8").includes("$translationKey")) {
154
+ return null;
155
+ }
156
+ let doc: JxDocument;
157
+ try {
158
+ doc = await readPageDocument(sourcePath, registry);
159
+ } catch {
160
+ return null;
161
+ }
162
+ return typeof doc.$translationKey === "string" && doc.$translationKey !== ""
163
+ ? doc.$translationKey
164
+ : null;
165
+ }
166
+
60
167
  /**
61
168
  * Discover all routable pages in a pages/ directory.
62
169
  *
@@ -204,6 +311,35 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
204
311
  };
205
312
  }
206
313
 
314
+ /**
315
+ * The reserved key a `resolvePaths` result may carry alongside its route parameters.
316
+ *
317
+ * A route parameter comes from a `[bracket]` segment in a filename, so this name cannot collide
318
+ * with one by accident, and it matches what the content loader already calls the same data on an
319
+ * entry (`parser.md` §9.3) — so a fact about an entry keeps one name from the file it was read out
320
+ * of all the way to the sitemap.
321
+ */
322
+ const PATH_ENTRY_META = "_meta";
323
+
324
+ /**
325
+ * The entry timestamp carried on a `$paths` result, or null when there is none.
326
+ *
327
+ * Null is ordinary: the core `$paths` shapes (`values`, `$ref`, a literal array) describe route
328
+ * parameters and nothing else, and a route with no entry of its own correctly falls back to its
329
+ * template's own modification time.
330
+ *
331
+ * @param {Record<string, unknown>} pathEntry
332
+ * @returns {string | null}
333
+ */
334
+ function entryMtime(pathEntry: Record<string, unknown>): string | null {
335
+ const meta = pathEntry[PATH_ENTRY_META];
336
+ if (meta === null || typeof meta !== "object") {
337
+ return null;
338
+ }
339
+ const { mtime } = meta as { mtime?: unknown };
340
+ return typeof mtime === "string" && mtime !== "" ? mtime : null;
341
+ }
342
+
207
343
  /**
208
344
  * Expand dynamic routes by resolving $paths from each dynamic page.
209
345
  *
@@ -226,6 +362,7 @@ export async function expandDynamicRoutes(
226
362
  sections: Record<string, unknown> = {},
227
363
  registry?: ExtensionRegistry,
228
364
  projectConfig?: ProjectConfig,
365
+ i18n?: ResolvedI18n | null,
229
366
  ) {
230
367
  const expanded: Route[] = [];
231
368
 
@@ -256,23 +393,33 @@ export async function expandDynamicRoutes(
256
393
  sections,
257
394
  registry,
258
395
  projectConfig,
396
+ // The template's OWN prefix, read before expansion: `/fr/blog/:slug` is a French route
397
+ // Whatever its entries turn out to be called, and that is what scopes a localized collection.
398
+ localeOfRoute(route.urlPattern, i18n ?? null),
259
399
  );
260
400
 
261
401
  for (const pathEntry of pathEntries) {
262
402
  let concreteUrl = route.urlPattern;
263
403
  const params: Record<string, string> = {};
264
404
  for (const [param, value] of Object.entries(pathEntry)) {
405
+ // `_meta` is the reserved carrier for facts about the source ENTRY (extensions.md §8).
406
+ // It is not a route parameter and must never reach substitution.
407
+ if (param === PATH_ENTRY_META) {
408
+ continue;
409
+ }
265
410
  params[param] = String(value);
266
411
  concreteUrl = concreteUrl.replace(`:${param}`, params[param]);
267
412
  concreteUrl = concreteUrl.replace("*", params[param]);
268
413
  }
269
414
 
415
+ const mtime = entryMtime(pathEntry);
270
416
  expanded.push({
271
417
  ...route,
272
418
  _pathParams: params,
273
419
  isCatchAll: false,
274
420
  isDynamic: false,
275
421
  params: [],
422
+ ...(mtime === null ? {} : { sourceMtime: mtime }),
276
423
  urlPattern: concreteUrl,
277
424
  });
278
425
  }
@@ -301,6 +448,7 @@ async function resolvePathEntries(
301
448
  sections: Record<string, unknown>,
302
449
  registry?: ExtensionRegistry,
303
450
  projectConfig?: ProjectConfig,
451
+ locale?: string | null,
304
452
  ): Promise<Record<string, unknown>[]> {
305
453
  // Legacy: array of param objects
306
454
  if (Array.isArray($paths)) {
@@ -345,6 +493,7 @@ async function resolvePathEntries(
345
493
  }
346
494
  return (await entry.call("resolvePaths", $paths, {
347
495
  data: sections[entry.project.key],
496
+ locale,
348
497
  projectConfig,
349
498
  root: projectRoot,
350
499
  })) as Record<string, unknown>[];
@@ -12,7 +12,15 @@ import { createServer } from "node:http";
12
12
  import type { Server } from "node:http";
13
13
  import { existsSync, readFileSync, statSync } from "node:fs";
14
14
  import { extname, join, normalize, resolve, sep } from "node:path";
15
+ import { MEDIA_TYPE_BY_EXTENSION } from "@jxsuite/schema/media-type";
15
16
 
17
+ /*
18
+ * The extensions this server can be asked for. `MEDIA_TYPE_BY_EXTENSION` is spread in **last** so
19
+ * the registered spellings win: `.md` carries the `variant` that says which markdown it is, and
20
+ * `.yaml`/`.yml` are `application/yaml` rather than an `application/octet-stream` download prompt.
21
+ * Everything else is this table's own, because a shared table for `image/png` would be a second
22
+ * source of truth for something no standard is ambiguous about.
23
+ */
16
24
  const MIME_TYPES: Record<string, string> = {
17
25
  ".avif": "image/avif",
18
26
  ".css": "text/css; charset=utf-8",
@@ -23,13 +31,13 @@ const MIME_TYPES: Record<string, string> = {
23
31
  ".jpg": "image/jpeg",
24
32
  ".js": "text/javascript; charset=utf-8",
25
33
  ".json": "application/json; charset=utf-8",
26
- ".md": "text/markdown; charset=utf-8",
27
34
  ".png": "image/png",
28
35
  ".svg": "image/svg+xml",
29
36
  ".txt": "text/plain; charset=utf-8",
30
37
  ".webp": "image/webp",
31
38
  ".woff2": "font/woff2",
32
39
  ".xml": "application/xml; charset=utf-8",
40
+ ...MEDIA_TYPE_BY_EXTENSION,
33
41
  };
34
42
 
35
43
  /**
@@ -37,7 +37,7 @@ const CORE_DOCUMENT_SPECIFIER = "@jxsuite/schema/schema.json";
37
37
  * @param {string} projectRoot - Absolute path to the project directory
38
38
  * @returns {Promise<{ projectSchemaPath: string; documentSchemaPath: string }>} Written paths
39
39
  */
40
- export async function writeProjectSchemas(projectRoot: string) {
40
+ export async function composeEntryDocuments(projectRoot: string) {
41
41
  const { config } = loadProjectConfig(projectRoot);
42
42
  const registry = await buildProjectExtensionRegistry(projectRoot, config);
43
43
 
@@ -58,6 +58,17 @@ export async function writeProjectSchemas(projectRoot: string) {
58
58
  loadJson: restrictedSchemaLoader(root),
59
59
  });
60
60
 
61
+ return { documentSchema, projectSchema };
62
+ }
63
+
64
+ /**
65
+ * Compose the entry documents and WRITE them to the project root.
66
+ *
67
+ * @param {string} projectRoot - Absolute path to the project directory
68
+ * @returns {Promise<{ projectSchemaPath: string; documentSchemaPath: string }>} Written paths
69
+ */
70
+ export async function writeProjectSchemas(projectRoot: string) {
71
+ const { documentSchema, projectSchema } = await composeEntryDocuments(projectRoot);
61
72
  const projectSchemaPath = resolve(projectRoot, "project.schema.json");
62
73
  const documentSchemaPath = resolve(projectRoot, "document.schema.json");
63
74
  writeFileSync(projectSchemaPath, `${JSON.stringify(projectSchema, null, 2)}\n`, "utf8");
@@ -76,7 +87,11 @@ export async function writeProjectSchemas(projectRoot: string) {
76
87
  * @param {string} projectRoot - Absolute path to the project directory
77
88
  * @returns {Promise<{ project: Record<string, unknown>; document: Record<string, unknown> }>}
78
89
  */
79
- export async function readBundledProjectSchemas(projectRoot: string) {
90
+ export async function readBundledProjectSchemas(
91
+ projectRoot: string,
92
+ options: { write?: boolean } = {},
93
+ ) {
94
+ const { write = true } = options;
80
95
  const root = resolve(projectRoot);
81
96
  const projectJsonPath = resolve(root, "project.json");
82
97
  const projectSchemaPath = resolve(root, "project.schema.json");
@@ -92,49 +107,125 @@ export async function readBundledProjectSchemas(projectRoot: string) {
92
107
  return false;
93
108
  }
94
109
  };
95
- if (stale(projectSchemaPath) || stale(documentSchemaPath)) {
96
- await writeProjectSchemas(root);
110
+ const isStale = stale(projectSchemaPath) || stale(documentSchemaPath);
111
+ // `write: false` is what a VALIDATOR passes. A stale entry document is regenerated in memory and
112
+ // The committed file is left exactly as it is: `jx validate` reports on the repository, and a
113
+ // Checker that edits what it is checking is not a checker. This mattered — running
114
+ // `schema:validate-all` on a machine with a shadowed core silently rewrote the committed
115
+ // Artifact with a narrower one, and it was green either way, because it validated against
116
+ // Whatever it had just written.
117
+ const composed = isStale ? await composeEntryDocuments(root) : null;
118
+ if (composed && write) {
119
+ writeFileSync(
120
+ projectSchemaPath,
121
+ `${JSON.stringify(composed.projectSchema, null, 2)}\n`,
122
+ "utf8",
123
+ );
124
+ writeFileSync(
125
+ documentSchemaPath,
126
+ `${JSON.stringify(composed.documentSchema, null, 2)}\n`,
127
+ "utf8",
128
+ );
97
129
  }
98
130
 
99
131
  const loadJson = restrictedSchemaLoader(root);
100
- const projectFile = JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<
101
- string,
102
- unknown
103
- >;
104
- const documentFile = JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<
105
- string,
106
- unknown
107
- >;
132
+ const projectFile =
133
+ composed?.projectSchema ??
134
+ (JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<string, unknown>);
135
+ const documentFile =
136
+ composed?.documentSchema ??
137
+ (JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<string, unknown>);
108
138
  const project = flattenSchema(await bundleSchema(projectFile, loadJson, root));
109
139
  const document = flattenSchema(await bundleSchema(documentFile, loadJson, root));
110
140
  return { document, project };
111
141
  }
112
142
 
143
+ /** Packages whose schemas define the Jx document contract itself, never a project's own content. */
144
+ const FIRST_PARTY_SCHEMA_SCOPE = "@jxsuite/";
145
+
146
+ /**
147
+ * Is this specifier one of the shipped core schemas, as opposed to a project-local fragment?
148
+ *
149
+ * @param {string} specifier - Bare specifier, e.g. `@jxsuite/schema/schema.json`
150
+ * @returns {boolean}
151
+ */
152
+ export function isFirstPartySchema(specifier: string): boolean {
153
+ return specifier.startsWith(FIRST_PARTY_SCHEMA_SCOPE) && specifier.endsWith(".json");
154
+ }
155
+
156
+ /**
157
+ * Resolve a first-party schema from the HOST — the workspace running the generator.
158
+ *
159
+ * Throws rather than falling back to the project, because a fallback is exactly the behaviour that
160
+ * let a stale published core answer for the workspace one. A generator that cannot find its own
161
+ * schema must stop, not improvise.
162
+ *
163
+ * @param {string} specifier
164
+ * @param {string} path - The original ref, for the error message
165
+ * @returns {string} Absolute path to the resolved schema file
166
+ */
167
+ function hostResolve(specifier: string, path: string): string {
168
+ try {
169
+ return createRequire(import.meta.url).resolve(specifier);
170
+ } catch (error) {
171
+ throw new Error(
172
+ `Schema ref "${path}" names the first-party schema "${specifier}", which must resolve from ` +
173
+ `the host workspace and does not. The project's own copy is deliberately not consulted, so ` +
174
+ `the host install is missing "${specifier.split("/").slice(0, 2).join("/")}" — check that ` +
175
+ `the package ships with this build`,
176
+ { cause: error },
177
+ );
178
+ }
179
+ }
180
+
113
181
  /**
114
182
  * Bundler loader restricted to the project root, with the validate-project host fallback: a missing
115
183
  * `<root>/node_modules/<specifier>` file resolves the specifier project-first, then through the
116
184
  * host (workspace symlinks resolve transparently).
117
185
  *
186
+ * Exported for test: both refusals below are reachable only through a project whose `node_modules`
187
+ * has been arranged to provoke them, and a fixture that arranges an extension registry as well says
188
+ * less about the rule than a call that states the path directly.
189
+ *
118
190
  * @param {string} root - Absolute project root
119
191
  * @returns {SchemaJsonLoader}
120
192
  */
121
- function restrictedSchemaLoader(root: string): SchemaJsonLoader {
193
+ export function restrictedSchemaLoader(root: string): SchemaJsonLoader {
122
194
  return async (path) => {
123
195
  const abs = resolve(path);
124
196
  const rel = relative(root, abs);
125
197
  if (rel.startsWith("..") || isAbsolute(rel)) {
126
198
  throw new Error(`Schema ref "${path}" escapes the project root ${root}`);
127
199
  }
128
- if (existsSync(abs)) {
129
- return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
130
- }
131
200
  const posixRel = rel.split(sep).join("/");
132
201
  const marker = "node_modules/";
133
202
  const markerIndex = posixRel.lastIndexOf(marker);
134
- if (markerIndex === -1) {
203
+ const specifier = markerIndex === -1 ? null : posixRel.slice(markerIndex + marker.length);
204
+ // A FIRST-PARTY core comes from the host, always — before the project-local file is even
205
+ // Looked at. A project may legitimately carry its own fragments, which is what the rest of this
206
+ // Loader is for; what it may not do is answer for `@jxsuite/schema` itself.
207
+ //
208
+ // This is not hypothetical. A stray `bun install` in packages/starters/sites/shop left a
209
+ // Gitignored node_modules holding @jxsuite/schema@0.35.0 beside a 1.5.0 workspace, and BOTH the
210
+ // `existsSync` shortcut below and the project-first `createRequire` resolved to it. The
211
+ // Generator then emitted a shop document schema missing Statement, StatementList, PathsValue
212
+ // And the three admission blocks, with ClassMethodDef.role down from 16 members to 6 and
213
+ // ChildrenValue.oneOf down from 3 to 2 — dropping the computed-children branch, which made
214
+ // Shop's own pages/products/[sku].json invalid against its own committed schema. Nothing
215
+ // Noticed for six weeks, because the artifact was only ever regenerated on the machine that
216
+ // Had the shadow.
217
+ if (specifier !== null && isFirstPartySchema(specifier)) {
218
+ return JSON.parse(readFileSync(hostResolve(specifier, path), "utf8")) as Record<
219
+ string,
220
+ unknown
221
+ >;
222
+ }
223
+ if (existsSync(abs)) {
224
+ return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
225
+ }
226
+ if (specifier === null) {
135
227
  throw new Error(`Schema ref "${path}" does not exist`);
136
228
  }
137
- const specifier = posixRel.slice(markerIndex + marker.length);
138
229
  let resolved: string;
139
230
  try {
140
231
  const projRequire = createRequire(resolve(root, "package.json"));