@jxsuite/parser 1.4.1 → 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.
- package/dist/content-loader.js +267 -11
- package/dist/content.js +105 -6
- package/dist/markdown.js +129 -12
- package/dist/serialize.js +29 -4
- package/package.json +8 -8
- package/src/Markdown.class.json +1 -1
- package/src/content-loader.ts +200 -14
- package/src/content.ts +38 -7
- package/src/dates.ts +184 -0
- package/src/markdown.ts +13 -1
- package/src/md.ts +52 -4
- package/src/serialize.ts +28 -3
- package/src/transpile.ts +25 -6
package/src/content-loader.ts
CHANGED
|
@@ -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(
|
|
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) => [
|
|
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
|
|
320
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
670
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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?: {
|
|
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
|
-
|
|
90
|
+
const [a, b] = parseComparable(actual, rule.value);
|
|
91
|
+
return a > b;
|
|
81
92
|
}
|
|
82
93
|
case "<": {
|
|
83
|
-
|
|
94
|
+
const [a, b] = parseComparable(actual, rule.value);
|
|
95
|
+
return a < b;
|
|
84
96
|
}
|
|
85
97
|
case ">=": {
|
|
86
|
-
|
|
98
|
+
const [a, b] = parseComparable(actual, rule.value);
|
|
99
|
+
return a >= b;
|
|
87
100
|
}
|
|
88
101
|
case "<=": {
|
|
89
|
-
|
|
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
|
|
260
|
+
return scoped.find((e: ContentLoaderEntry) => e.data[field] === resolvedId) ?? null;
|
|
230
261
|
}
|
|
231
|
-
return findEntry(
|
|
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
|
|
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
|
-
|
|
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
|
|
250
|
+
$wordCount: countWords(plainText),
|
|
203
251
|
frontmatter,
|
|
204
252
|
path: filePath,
|
|
205
253
|
slug,
|