@jxsuite/parser 1.4.0 → 1.5.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.
@@ -20,6 +20,7 @@
20
20
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21
21
  import { basename, dirname, extname, resolve } from "node:path";
22
22
  import { assetUrlFor } from "@jxsuite/schema/asset-paths";
23
+ import { resolveI18n } from "@jxsuite/schema/locale";
23
24
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
24
25
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
25
26
  import type { FormatEntry, FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
@@ -29,6 +30,7 @@ import type {
29
30
  JxMutableNode,
30
31
  ProjectConfig,
31
32
  } from "@jxsuite/schema/types";
33
+ import { coerceEntryDates, isCoercedDate, isDateFormat } from "./dates.ts";
32
34
  import type { ContentLoaderEntry, ContentTypeDef } from "./types.ts";
33
35
 
34
36
  export type { ContentEntry, ContentLoaderEntry, TocEntry } from "./types.ts";
@@ -56,6 +58,11 @@ export interface ResolvePathsContext {
56
58
  projectConfig?: ProjectConfig;
57
59
  /** Absolute project root directory. */
58
60
  root: string;
61
+ /**
62
+ * The locale of the route being expanded, when the project has any. Only a localized collection
63
+ * reads it — it is the discriminator between two translations sharing an id.
64
+ */
65
+ locale?: string | null;
59
66
  }
60
67
 
61
68
  /** The `$paths` value shape routed to this extension by its `contentType` discriminator. */
@@ -110,6 +117,70 @@ function discoverJSONFiles(resolvedSource: string): string[] {
110
117
 
111
118
  // ─── Asset mounts and content-relative references ────────────────────────────
112
119
 
120
+ /**
121
+ * The placeholder a content type's `source` may carry to say "one directory per locale".
122
+ *
123
+ * `./blog/{locale}/` is one content type spread over N directories, not N content types. That
124
+ * distinction is what keeps a translated post the same post: it keeps one schema, one set of
125
+ * relationship targets, and one name in `$paths`, and differs only in which directory it was read
126
+ * from — which is exactly the model `site-architecture.md` §13 uses for routes.
127
+ */
128
+ export const LOCALE_PLACEHOLDER = "{locale}";
129
+
130
+ /**
131
+ * The locales a `{locale}` source expands over, or `[]` when the project declares none.
132
+ *
133
+ * The **canonical** tags, from the same resolver the compiler and Studio run — it lives in
134
+ * `@jxsuite/schema/locale` precisely so that three hosts cannot disagree about what `EN-us` means.
135
+ * This module used to read the raw list, on the reasoning that canonicalization was the compiler's
136
+ * job; that was true when the function was the compiler's, and it left this the one place where a
137
+ * locale's spelling came from the config rather than from the parser.
138
+ *
139
+ * @param {ProjectConfig | undefined} projectConfig
140
+ * @returns {string[]}
141
+ */
142
+ export function localesForExpansion(projectConfig: ProjectConfig | undefined): string[] {
143
+ return resolveI18n(projectConfig ?? {}).i18n?.locales ?? [];
144
+ }
145
+
146
+ /**
147
+ * The directory a locale's entries live in, out of the spellings that can legitimately be on disk.
148
+ *
149
+ * A locale directory is matched **case-insensitively**, because two writers name it and they
150
+ * differ: a project that declared `fr-CA` most likely typed `fr-CA/`, while Studio creates
151
+ * `content/<type>/fr-ca/` — lowercase, because a page directory becomes a URL and the site's own
152
+ * URLs are lowercase (`site-architecture.md` §13.2). Reading only one of the two spellings makes a
153
+ * translation somebody just created invisible to the build, with no error: the directory is there,
154
+ * the entries are there, and the collection loads none of them.
155
+ *
156
+ * The canonical spelling is preferred so an existing tree keeps its own answer, and a scan of the
157
+ * parent runs only when neither exact spelling exists.
158
+ *
159
+ * @param {string} root
160
+ * @param {string} source - The `{locale}`-bearing source
161
+ * @param {string} locale - Canonical tag
162
+ * @returns {string} The source with `{locale}` replaced by the spelling that is on disk
163
+ */
164
+ export function localeSource(root: string, source: string, locale: string): string {
165
+ const candidates = [locale, locale.toLowerCase()];
166
+ for (const spelling of candidates) {
167
+ const path = source.split(LOCALE_PLACEHOLDER).join(spelling);
168
+ if (existsSync(resolve(root, path))) {
169
+ return path;
170
+ }
171
+ }
172
+ const parent = resolve(root, source.slice(0, source.indexOf(LOCALE_PLACEHOLDER)));
173
+ if (existsSync(parent) && statSync(parent).isDirectory()) {
174
+ const match = readdirSync(parent).find((name) => name.toLowerCase() === locale.toLowerCase());
175
+ if (match !== undefined) {
176
+ return source.split(LOCALE_PLACEHOLDER).join(match);
177
+ }
178
+ }
179
+ /* Nothing on disk: the canonical spelling, so the miss is reported against the name that was
180
+ declared rather than against a spelling nobody wrote. */
181
+ return source.split(LOCALE_PLACEHOLDER).join(locale);
182
+ }
183
+
113
184
  /** The project.json section key this class owns; also the URL namespace for its asset mounts. */
114
185
  const SECTION_KEY = "content";
115
186
 
@@ -140,13 +211,37 @@ function isNonRelativeRef(value: string): boolean {
140
211
  * @param {string} root - Absolute project root directory
141
212
  * @returns {AssetMount[]} One mount per eligible content type, in declaration order
142
213
  */
143
- export function contentAssetMounts(section: ContentSection, root: string): AssetMount[] {
214
+ export function contentAssetMounts(
215
+ section: ContentSection,
216
+ root: string,
217
+ projectConfig?: ProjectConfig,
218
+ ): AssetMount[] {
144
219
  const mounts: AssetMount[] = [];
220
+ const locales = localesForExpansion(projectConfig);
145
221
  for (const [name, contentTypeDef] of Object.entries(section ?? {})) {
146
222
  const source = contentTypeDef?.source;
147
223
  if (!source || source.startsWith("http://") || source.startsWith("https://")) {
148
224
  continue;
149
225
  }
226
+ /*
227
+ * A localized source is N directories, so it is N mounts — published under
228
+ * `/content/<type>/<locale>` so a French post's `./hero.png` and its English translation's
229
+ * cannot collide at one URL.
230
+ */
231
+ if (source.includes(LOCALE_PLACEHOLDER)) {
232
+ for (const locale of locales) {
233
+ if (!SAFE_TYPE_NAME.test(locale)) {
234
+ continue;
235
+ }
236
+ const dir = resolve(root, localeSource(root, source, locale))
237
+ .split("\\")
238
+ .join("/");
239
+ if (SAFE_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
240
+ mounts.push({ dir, urlPrefix: `/${SECTION_KEY}/${name}/${locale}` });
241
+ }
242
+ }
243
+ continue;
244
+ }
150
245
  if (!SAFE_TYPE_NAME.test(name)) {
151
246
  console.warn(
152
247
  `Content type "${name}": name is not URL-safe — its source directory is not published ` +
@@ -310,14 +405,59 @@ export async function loadContentSection(
310
405
  section: ContentSection,
311
406
  root: string,
312
407
  formats: FormatRegistry,
408
+ projectConfig?: ProjectConfig,
313
409
  ): Promise<Map<string, ContentLoaderEntry[]>> {
314
410
  const contentTypes = new Map<string, ContentLoaderEntry[]>();
315
411
  const mounts = new Map(
316
- contentAssetMounts(section, root).map((mount) => [mount.urlPrefix.split("/").pop()!, mount]),
412
+ contentAssetMounts(section, root, projectConfig).map((mount) => [
413
+ /*
414
+ * The path below `/content/` — `<type>` for a plain source, `<type>/<locale>` for a
415
+ * localized one — which is exactly the key each lookup below builds. Keying by the LAST
416
+ * segment made every localized mount unreachable (`posts/fr` never matching `fr`), so a
417
+ * translated entry's `./hero.png` was left unrewritten; and it collided any two types that
418
+ * shared a locale.
419
+ */
420
+ mount.urlPrefix.slice(`/${SECTION_KEY}/`.length),
421
+ mount,
422
+ ]),
317
423
  );
424
+ const locales = localesForExpansion(projectConfig);
318
425
  for (const [name, contentTypeDef] of Object.entries(section)) {
319
- const entries = await loadContentType(name, contentTypeDef, root, formats, mounts.get(name));
320
- contentTypes.set(name, entries);
426
+ const source = contentTypeDef?.source;
427
+ if (typeof source !== "string" || !source.includes(LOCALE_PLACEHOLDER)) {
428
+ const entries = await loadContentType(name, contentTypeDef, root, formats, mounts.get(name));
429
+ contentTypes.set(name, entries);
430
+ continue;
431
+ }
432
+ /*
433
+ * One content type, N directories. Each pass loads a real path and stamps the locale it came
434
+ * from onto every entry, which is the only thing distinguishing two translations that share an
435
+ * id — `resolvePaths` reads it back to expand the right ones under a locale-prefixed route.
436
+ */
437
+ if (locales.length === 0) {
438
+ console.warn(
439
+ `Content type "${name}": source "${source}" uses ${LOCALE_PLACEHOLDER}, but the project ` +
440
+ `declares no i18n locales — nothing to expand it over, so no entries were loaded.`,
441
+ );
442
+ contentTypes.set(name, []);
443
+ continue;
444
+ }
445
+ const all: ContentLoaderEntry[] = [];
446
+ for (const locale of locales) {
447
+ const localized = { ...contentTypeDef, source: localeSource(root, source, locale) };
448
+ const entries = await loadContentType(
449
+ name,
450
+ localized,
451
+ root,
452
+ formats,
453
+ mounts.get(`${name}/${locale}`),
454
+ );
455
+ for (const entry of entries) {
456
+ entry._meta = { ...entry._meta, locale };
457
+ }
458
+ all.push(...entries);
459
+ }
460
+ contentTypes.set(name, all);
321
461
  }
322
462
  return contentTypes;
323
463
  }
@@ -470,8 +610,15 @@ async function loadContentType(
470
610
  entries.push(...fileEntries);
471
611
  }
472
612
 
473
- // Validate entries against schema if present
613
+ /*
614
+ * Coerce declared date fields before validating. This is the one point in the pipeline that holds
615
+ * both the entries and the schema: `Csv.load` receives a schema and `Markdown.load` does not, so
616
+ * doing it per-format would mean doing it twice and missing every third-party format class.
617
+ */
474
618
  if (schema) {
619
+ for (const warning of coerceEntryDates(entries, schema, name)) {
620
+ console.warn(warning.message);
621
+ }
475
622
  validateEntries(entries, schema, name);
476
623
  }
477
624
 
@@ -526,7 +673,12 @@ function validateEntries(
526
673
  continue;
527
674
  }
528
675
 
529
- if (def.type === "string" && typeof value !== "string") {
676
+ if (isDateFormat(def.format) && typeof value === "string" && !isCoercedDate(value)) {
677
+ console.warn(
678
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" is declared ` +
679
+ `${def.format} but "${value}" was not coerced — it will sort and filter as plain text.`,
680
+ );
681
+ } else if (def.type === "string" && typeof value !== "string") {
530
682
  console.warn(
531
683
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
532
684
  );
@@ -652,7 +804,12 @@ export class Content {
652
804
  ctx: ProjectDataContext,
653
805
  ): Promise<Map<string, ContentLoaderEntry[]>> {
654
806
  const section = (sectionValue ?? {}) as ContentSection;
655
- const data = await loadContentSection(section, ctx.root, ctx.registry.formats);
807
+ const data = await loadContentSection(
808
+ section,
809
+ ctx.root,
810
+ ctx.registry.formats,
811
+ ctx.projectConfig,
812
+ );
656
813
  resolveContentTypeRefs(data, section);
657
814
  return data;
658
815
  }
@@ -666,14 +823,23 @@ export class Content {
666
823
  * @param {{ projectConfig?: ProjectConfig; root: string }} ctx - Host context
667
824
  * @returns {AssetMount[]} One mount per content type with a local directory source
668
825
  */
669
- static assets(sectionValue: unknown, ctx: { root: string }): AssetMount[] {
670
- return contentAssetMounts((sectionValue ?? {}) as ContentSection, ctx.root);
826
+ static assets(
827
+ sectionValue: unknown,
828
+ ctx: { root: string; projectConfig?: ProjectConfig },
829
+ ): AssetMount[] {
830
+ return contentAssetMounts((sectionValue ?? {}) as ContentSection, ctx.root, ctx.projectConfig);
671
831
  }
672
832
 
673
833
  /**
674
834
  * Expand a content-type `$paths` source into route-param maps: one `{ [param]: value }` per
675
835
  * entry, with `param` defaulting to "slug" and `field` to "id" (the entry id).
676
836
  *
837
+ * Each map also carries the entry's own `_meta`, under that reserved name. `_meta` is not a route
838
+ * parameter and the host strips it before substitution — it is how a fact about the _entry_
839
+ * reaches a route that would otherwise know only about the template that generated it. The one
840
+ * that matters today is `mtime`: without it every post in a collection reports the template's
841
+ * timestamp in `<lastmod>`, so the whole archive looks edited whenever the template is.
842
+ *
677
843
  * @param {ContentPathsSource} pathsDef - The `$paths` value ({ contentType, param?, field? })
678
844
  * @param {ResolvePathsContext} ctx - Host context ({ data, projectConfig, root })
679
845
  * @returns {Promise<Record<string, unknown>[]>} Array of route-param objects
@@ -691,10 +857,30 @@ export class Content {
691
857
  }
692
858
  const param = pathsDef.param ?? "slug";
693
859
  const field = pathsDef.field ?? "id";
694
- return entries
695
- .map((entry) => ({
696
- [param]: field === "id" ? entry.id : (entry.data[field] ?? entry.id),
697
- }))
698
- .filter((p) => p[param]);
860
+ /*
861
+ * A localized collection holds every language at once, and two translations of one post share
862
+ * an id — so expanding all of them under `/fr/[slug]` would emit each route twice and let the
863
+ * second overwrite the first. The route's own locale is the discriminator, and it is applied
864
+ * only when the entries actually carry one: an unlocalized collection is untouched.
865
+ */
866
+ const localized = entries.some((entry) => entry._meta?.locale !== undefined);
867
+ const wanted = ctx.locale;
868
+ const scoped =
869
+ localized && typeof wanted === "string"
870
+ ? entries.filter((entry) => entry._meta?.locale === wanted)
871
+ : entries;
872
+ const paths: Record<string, unknown>[] = [];
873
+ for (const entry of scoped) {
874
+ const value = field === "id" ? entry.id : (entry.data[field] ?? entry.id);
875
+ if (!value) {
876
+ continue;
877
+ }
878
+ const pathEntry: Record<string, unknown> = { [param]: value };
879
+ if (entry._meta !== undefined) {
880
+ pathEntry._meta = entry._meta;
881
+ }
882
+ paths.push(pathEntry);
883
+ }
884
+ return paths;
699
885
  }
700
886
  }
package/src/content.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * queryContentType, findEntry) used by both the classes and the server endpoint.
7
7
  */
8
8
 
9
+ import { parseComparable } from "./dates.ts";
9
10
  import type { ContentLoaderEntry } from "./types.ts";
10
11
 
11
12
  interface CollectionConfig {
@@ -31,7 +32,11 @@ interface EntryConfig {
31
32
  [k: string]: unknown;
32
33
  };
33
34
  _document?: {
34
- route?: { _pathParams?: Record<string, string> };
35
+ route?: {
36
+ _pathParams?: Record<string, string>;
37
+ /** The language this route serves, when the project declares any (§13.4). */
38
+ locale?: string | null;
39
+ };
35
40
  [k: string]: unknown;
36
41
  };
37
42
  [k: string]: unknown;
@@ -76,17 +81,26 @@ export function evaluateFilterRule(
76
81
  }
77
82
  return typeof actual === "string" && !actual.includes(String(rule.value ?? ""));
78
83
  }
84
+ /*
85
+ * `Number()` on both sides made every date comparison false, since NaN compares false against
86
+ * everything. `parseComparable` keeps two normalized dates in string space, where RFC 3339
87
+ * compares chronologically.
88
+ */
79
89
  case ">": {
80
- return Number(actual) > Number(rule.value);
90
+ const [a, b] = parseComparable(actual, rule.value);
91
+ return a > b;
81
92
  }
82
93
  case "<": {
83
- return Number(actual) < Number(rule.value);
94
+ const [a, b] = parseComparable(actual, rule.value);
95
+ return a < b;
84
96
  }
85
97
  case ">=": {
86
- return Number(actual) >= Number(rule.value);
98
+ const [a, b] = parseComparable(actual, rule.value);
99
+ return a >= b;
87
100
  }
88
101
  case "<=": {
89
- return Number(actual) <= Number(rule.value);
102
+ const [a, b] = parseComparable(actual, rule.value);
103
+ return a <= b;
90
104
  }
91
105
  default: {
92
106
  return true;
@@ -225,9 +239,26 @@ export class ContentEntry {
225
239
  return null;
226
240
  }
227
241
 
242
+ /*
243
+ * Scoped to the route's language, exactly as `resolvePaths` scopes an expansion and for the
244
+ * same reason: two translations of one entry share an id (site-architecture.md §13.3), so an
245
+ * unscoped lookup answers with whichever language was loaded first. Under `/fr-ca/…` that is
246
+ * the English copy, rendered on a page whose `<html lang>` and every `hreflang` say French —
247
+ * a defect no error reports and every reader of the other language sees.
248
+ *
249
+ * Applied only when the entries actually carry a locale: an unlocalized collection is not in
250
+ * one language, and filtering it by the route's would empty it.
251
+ */
252
+ const wanted = _document?.route?.locale;
253
+ const localized = entries.some((e: ContentLoaderEntry) => e._meta?.locale !== undefined);
254
+ const scoped =
255
+ localized && typeof wanted === "string"
256
+ ? entries.filter((e: ContentLoaderEntry) => e._meta?.locale === wanted)
257
+ : entries;
258
+
228
259
  if (field && field !== "id") {
229
- return entries.find((e: ContentLoaderEntry) => e.data[field] === resolvedId) ?? null;
260
+ return scoped.find((e: ContentLoaderEntry) => e.data[field] === resolvedId) ?? null;
230
261
  }
231
- return findEntry(entries, resolvedId as string);
262
+ return findEntry(scoped, resolvedId as string);
232
263
  }
233
264
  }
package/src/dates.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Date coercion for content entries — the one pass that gives a `format: "date"` field a value the
3
+ * rest of the pipeline can actually compare.
4
+ *
5
+ * **Why a normalized string and not a `Date`.** `JSON.stringify(new Date("2025-03-04"))` yields an
6
+ * instant, so a Studio save would silently rewrite `2025-03-04` to `2025-03-04T00:00:00.000Z` —
7
+ * which is _March 3_ anywhere west of UTC. That off-by-one is the classic blog-date bug, and a
8
+ * format that round-trips through JSON cannot afford it. A `Temporal.PlainDate` is semantically
9
+ * right and fails differently: `<` and `>` on one yield NaN, and the sort in `content.ts` compares
10
+ * with exactly those.
11
+ *
12
+ * **Why UTC for date-times.** Mixed offsets do not sort lexicographically —
13
+ * `2025-03-04T01:00:00+02:00` sorts _after_ `2025-03-04T00:00:00Z` as text and is _earlier_ in
14
+ * time. Normalizing to `Z` makes the existing raw string comparison correct by construction rather
15
+ * than correct by accident.
16
+ *
17
+ * **Why an ambiguous string is refused rather than guessed.** `03/04/2025` is March 4th or April
18
+ * 3rd depending on where the author lives, and `new Date()` resolves it by implementation-defined
19
+ * rules. Silently picking one is the bug this pass exists to prevent, so refusing it _is_ the
20
+ * feature.
21
+ *
22
+ * @docs framework/site/content-collections
23
+ */
24
+
25
+ import type { ContentTypeSchema } from "@jxsuite/schema/types";
26
+ import type { ContentLoaderEntry } from "./types.ts";
27
+
28
+ /** The two JSON Schema `format` values that carry build behavior. */
29
+ export const DATE_FORMATS = ["date", "date-time"] as const;
30
+ export type DateFormat = (typeof DATE_FORMATS)[number];
31
+
32
+ export function isDateFormat(format: unknown): format is DateFormat {
33
+ return typeof format === "string" && (DATE_FORMATS as readonly string[]).includes(format);
34
+ }
35
+
36
+ /** `YYYY-MM-DD`, with no time part. */
37
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
38
+ /** RFC 3339 date-time: a date, a `T`, a time, and an offset or `Z`. */
39
+ const RFC_3339 = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})?$/;
40
+
41
+ function pad(n: number, width = 2): string {
42
+ return String(n).padStart(width, "0");
43
+ }
44
+
45
+ /** A `Date` to `YYYY-MM-DD`, read in UTC so the calendar day matches what was stored. */
46
+ function toDateOnly(d: Date): string {
47
+ return `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
48
+ }
49
+
50
+ /** A `Date` to RFC 3339 in UTC, without fractional seconds. */
51
+ function toInstant(d: Date): string {
52
+ return `${toDateOnly(d)}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}Z`;
53
+ }
54
+
55
+ /**
56
+ * Coerce one authored value to RFC 3339, or null when it is not an unambiguous date.
57
+ *
58
+ * Accepts, in order: a `Date` (YAML frontmatter parsers routinely produce one for an unquoted
59
+ * `date: 2025-03-04`), an RFC 3339 string, and a bare `YYYY-MM-DD`. Everything else is refused.
60
+ */
61
+ export function parseDateValue(value: unknown, format: DateFormat): string | null {
62
+ if (value instanceof Date) {
63
+ return Number.isNaN(value.getTime())
64
+ ? null
65
+ : format === "date"
66
+ ? toDateOnly(value)
67
+ : toInstant(value);
68
+ }
69
+ if (typeof value !== "string") {
70
+ return null;
71
+ }
72
+ const text = value.trim();
73
+ if (text === "") {
74
+ return null;
75
+ }
76
+
77
+ if (DATE_ONLY.test(text)) {
78
+ // A bare date has no zone. Reading it as UTC midnight is the only reading that does not move
79
+ // The calendar day, which is the whole point of a date-only field.
80
+ const d = new Date(`${text}T00:00:00Z`);
81
+ if (Number.isNaN(d.getTime())) {
82
+ return null;
83
+ }
84
+ return format === "date" ? text : toInstant(d);
85
+ }
86
+
87
+ if (RFC_3339.test(text)) {
88
+ // A date-time with no offset is a local time with no way to know which locality, so it is read
89
+ // As UTC rather than as the build machine's zone — a build must not depend on where it runs.
90
+ const normalized = /[Zz]|[+-]\d{2}:\d{2}$/.test(text) ? text : `${text.replace(" ", "T")}Z`;
91
+ const d = new Date(normalized);
92
+ if (Number.isNaN(d.getTime())) {
93
+ return null;
94
+ }
95
+ return format === "date" ? toDateOnly(d) : toInstant(d);
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ /** Whether a value is already in one of the two normalized forms this module emits. */
102
+ export function isCoercedDate(value: string): boolean {
103
+ return DATE_ONLY.test(value) || RFC_3339.test(value);
104
+ }
105
+
106
+ /** Where a coercion pass records what it could not read. */
107
+ export interface DateCoercionWarning {
108
+ entryId: string;
109
+ field: string;
110
+ value: unknown;
111
+ message: string;
112
+ }
113
+
114
+ /**
115
+ * Coerce every schema-declared date field on every entry, in place.
116
+ *
117
+ * The original text is kept at `entry._meta.rawDates[field]`, because an events collection that
118
+ * genuinely means "7pm local" needs it back and the normalized instant has thrown that away.
119
+ */
120
+ export function coerceEntryDates(
121
+ entries: ContentLoaderEntry[],
122
+ schema: ContentTypeSchema | undefined,
123
+ contentTypeName: string,
124
+ ): DateCoercionWarning[] {
125
+ const warnings: DateCoercionWarning[] = [];
126
+ const properties = schema?.properties;
127
+ if (!properties) {
128
+ return warnings;
129
+ }
130
+
131
+ const dateFields: [string, DateFormat][] = Object.entries(properties)
132
+ .map(([field, def]): [string, unknown] => [field, (def as { format?: unknown }).format])
133
+ .filter((pair): pair is [string, DateFormat] => isDateFormat(pair[1]));
134
+ if (dateFields.length === 0) {
135
+ return warnings;
136
+ }
137
+
138
+ for (const entry of entries) {
139
+ for (const [field, format] of dateFields) {
140
+ const raw = entry.data[field];
141
+ if (raw === undefined || raw === null || raw === "") {
142
+ continue;
143
+ }
144
+ const coerced = parseDateValue(raw, format);
145
+ if (coerced === null) {
146
+ warnings.push({
147
+ entryId: entry.id,
148
+ field,
149
+ message:
150
+ `Content dates: "${contentTypeName}/${entry.id}" field "${field}" is ` +
151
+ `${JSON.stringify(raw)}, which is not an unambiguous ${format}. Write it as ` +
152
+ `${format === "date" ? "YYYY-MM-DD" : "YYYY-MM-DDTHH:MM:SSZ"} — a form like ` +
153
+ `"03/04/2025" means two different days depending on who reads it, so it is left as ` +
154
+ `authored rather than guessed.`,
155
+ value: raw,
156
+ });
157
+ continue;
158
+ }
159
+ if (coerced !== raw) {
160
+ entry._meta ??= {};
161
+ entry._meta.rawDates ??= {};
162
+ entry._meta.rawDates[field] = raw;
163
+ }
164
+ entry.data[field] = coerced;
165
+ }
166
+ }
167
+ return warnings;
168
+ }
169
+
170
+ /**
171
+ * Comparable form for a filter comparison.
172
+ *
173
+ * `>` and `<` pushed both sides through `Number()`, so a date comparison was ALWAYS false — `NaN`
174
+ * compares false against everything. Normalized RFC 3339 strings compare chronologically as text,
175
+ * so the fix is to notice when both sides are dates and stay in string space.
176
+ */
177
+ export function parseComparable(a: unknown, b: unknown): [number, number] | [string, string] {
178
+ const aDate = typeof a === "string" && (DATE_ONLY.test(a) || RFC_3339.test(a));
179
+ const bDate = typeof b === "string" && (DATE_ONLY.test(b) || RFC_3339.test(b));
180
+ if (aDate && bDate) {
181
+ return [a as string, b as string];
182
+ }
183
+ return [Number(a), Number(b)];
184
+ }
package/src/markdown.ts CHANGED
@@ -82,7 +82,7 @@ export class Markdown {
82
82
  options: MarkdownLoadOptions = {},
83
83
  ): Promise<ContentLoaderEntry[]> {
84
84
  const { processMarkdown } = await import("./md.ts");
85
- const { readFileSync } = await import("node:fs");
85
+ const { readFileSync, statSync } = await import("node:fs");
86
86
  const source = readFileSync(path, "utf8");
87
87
  const result = processMarkdown(source, path, {
88
88
  ...(options.directives !== undefined && { directives: options.directives }),
@@ -92,6 +92,18 @@ export class Markdown {
92
92
  ...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
93
93
  });
94
94
  const _meta: ContentLoaderEntry["_meta"] = {};
95
+ /*
96
+ * The modification time is the only date a file always has. A feed falls back to it when the
97
+ * frontmatter carries none, and it is what would let the sitemap stop giving every page
98
+ * generated from one template that template's `<lastmod>`.
99
+ */
100
+ try {
101
+ _meta.mtime = statSync(path)
102
+ .mtime.toISOString()
103
+ .replace(/\.\d{3}Z$/, "Z");
104
+ } catch {
105
+ // A format class may be handed content that is not on disk; an absent mtime is not an error.
106
+ }
95
107
  if (result.$excerpt != null) {
96
108
  _meta.excerpt = result.$excerpt;
97
109
  }
package/src/md.ts CHANGED
@@ -18,6 +18,7 @@ import remarkParseFrontmatter from "remark-parse-frontmatter";
18
18
  import remarkGfm from "remark-gfm";
19
19
  import remarkDirective from "remark-directive";
20
20
  import { readFileSync } from "node:fs";
21
+ import { DEFAULT_FORMAT_LOCALE } from "@jxsuite/schema/intl";
21
22
  import { basename, extname, relative, resolve as resolvePath } from "node:path";
22
23
  import { globSync } from "glob";
23
24
  import { assignHeadingIds, mdastNodeToJx } from "./transpile.ts";
@@ -82,15 +83,62 @@ function mdastToString(node: MdastNode): string {
82
83
 
83
84
  // ─── Helpers ──────────────────────────────────────────────────────────────────
84
85
 
86
+ /** Average reading speed. See {@link readingTime} for why this is one number and not a table. */
87
+ const WORDS_PER_MINUTE = 200;
88
+
89
+ /**
90
+ * One segmenter, built once.
91
+ *
92
+ * The locale is fixed rather than the host's: word boundaries are a property of the text, and a
93
+ * word count that changed with the build machine's locale would be the same determinism bug the
94
+ * `Intl` helpers exist to avoid.
95
+ */
96
+ const WORD_SEGMENTER = new Intl.Segmenter(DEFAULT_FORMAT_LOCALE, { granularity: "word" });
97
+
98
+ /** A segment is a word when it carries a letter or a digit. See {@link countWords}. */
99
+ const WORD_LIKE = /[\p{L}\p{N}]/u;
100
+
101
+ /**
102
+ * Count the words in a string by **segmenting** it, not by splitting on whitespace (UAX #29).
103
+ *
104
+ * Splitting on `\s+` assumes a script that puts spaces between its words. Japanese, Chinese and
105
+ * Thai do not, so an entire article in any of them counted as **one word** — and therefore one
106
+ * minute to read, whatever its length. `Intl.Segmenter` applies the Unicode word-boundary rules.
107
+ *
108
+ * **A segment counts when it contains a letter or a digit, rather than when `isWordLike` says so.**
109
+ * That property is what `isWordLike` is defined to mean, but Bun's engine answers `false` for a
110
+ * mixed alphanumeric segment — `w0`, `v3`, `h1` — so trusting it would drop every token of that
111
+ * shape from the count, silently and only for some documents. The predicate is spelled out because
112
+ * it has to be correct on the runtime this actually runs on.
113
+ *
114
+ * This runs in the parser under Bun, so there is no browser-support question to weigh.
115
+ *
116
+ * @param {string} text
117
+ * @returns {number}
118
+ */
119
+ function countWords(text: string): number {
120
+ let words = 0;
121
+ for (const { segment } of WORD_SEGMENTER.segment(text)) {
122
+ if (WORD_LIKE.test(segment)) {
123
+ words += 1;
124
+ }
125
+ }
126
+ return words;
127
+ }
128
+
85
129
  /**
86
- * Estimate reading time based on word count (~200 wpm average).
130
+ * Estimate reading time from the word count.
131
+ *
132
+ * **200 wpm, and the limitation is stated rather than modelled.** Real reading speed varies by
133
+ * script, by density and by reader — a Japanese character carries more than a Latin one, so a
134
+ * per-script table would be more accurate and would also be a table of guesses nobody could verify.
135
+ * One honest constant that the docs name beats a plausible one that they cannot.
87
136
  *
88
137
  * @param {string} text
89
138
  * @returns {number} Minutes (rounded up, minimum 1)
90
139
  */
91
140
  function readingTime(text: string) {
92
- const words = text.split(/\s+/).filter(Boolean).length;
93
- return Math.max(1, Math.ceil(words / 200));
141
+ return Math.max(1, Math.ceil(countWords(text) / WORDS_PER_MINUTE));
94
142
  }
95
143
 
96
144
  /**
@@ -199,7 +247,7 @@ export function processMarkdown(
199
247
  $excerpt: excerpt,
200
248
  $readingTime: readingTime(plainText),
201
249
  $toc: toc,
202
- $wordCount: plainText.split(/\s+/).filter(Boolean).length,
250
+ $wordCount: countWords(plainText),
203
251
  frontmatter,
204
252
  path: filePath,
205
253
  slug,