@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.
@@ -80,6 +80,184 @@ function assetUrlFor(mounts, absolutePath) {
80
80
  }
81
81
  var URL_TERMINATOR = String.raw`"'\s<>()\\`;
82
82
 
83
+ // ../../packages/schema/src/locale.ts
84
+ var RTL_SCRIPTS = new Set([
85
+ "Adlm",
86
+ "Arab",
87
+ "Aran",
88
+ "Armi",
89
+ "Avst",
90
+ "Cprt",
91
+ "Egyd",
92
+ "Egyh",
93
+ "Elym",
94
+ "Gara",
95
+ "Hatr",
96
+ "Hebr",
97
+ "Hluw",
98
+ "Hung",
99
+ "Khar",
100
+ "Lydi",
101
+ "Mand",
102
+ "Mani",
103
+ "Mend",
104
+ "Merc",
105
+ "Mero",
106
+ "Narb",
107
+ "Nbat",
108
+ "Nkoo",
109
+ "Orkh",
110
+ "Palm",
111
+ "Phli",
112
+ "Phlp",
113
+ "Phnx",
114
+ "Prti",
115
+ "Rohg",
116
+ "Samr",
117
+ "Sarb",
118
+ "Sogd",
119
+ "Sogo",
120
+ "Syrc",
121
+ "Thaa",
122
+ "Todr",
123
+ "Yezi"
124
+ ]);
125
+ function canonicalizeLocale(tag) {
126
+ if (typeof tag !== "string" || tag.trim() === "") {
127
+ return null;
128
+ }
129
+ try {
130
+ return new Intl.Locale(tag.trim()).toString();
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+ var DEFAULT_ROUTING = "prefix-except-default";
136
+ function resolveI18n(projectConfig) {
137
+ const raw = projectConfig.i18n;
138
+ const errors = [];
139
+ if (!raw || typeof raw !== "object") {
140
+ return { errors, i18n: null };
141
+ }
142
+ const declared = Array.isArray(raw.locales) ? raw.locales : [];
143
+ const locales = [];
144
+ for (const tag of declared) {
145
+ const canonical = canonicalizeLocale(tag);
146
+ if (canonical === null) {
147
+ errors.push(`i18n.locales: "${String(tag)}" is not a well-formed BCP 47 language tag.`);
148
+ continue;
149
+ }
150
+ if (!locales.includes(canonical)) {
151
+ locales.push(canonical);
152
+ }
153
+ }
154
+ const defaultLocale = canonicalizeLocale(raw.defaultLocale) ?? locales[0] ?? null;
155
+ if (raw.defaultLocale !== undefined && canonicalizeLocale(raw.defaultLocale) === null) {
156
+ errors.push(`i18n.defaultLocale: "${String(raw.defaultLocale)}" is not a well-formed BCP 47 language tag.`);
157
+ }
158
+ if (defaultLocale === null) {
159
+ errors.push("i18n is declared with no usable locale — set `defaultLocale` or `locales`.");
160
+ return { errors, i18n: null };
161
+ }
162
+ if (!locales.includes(defaultLocale)) {
163
+ locales.unshift(defaultLocale);
164
+ }
165
+ const routing = raw.routing === "prefix-always" || raw.routing === "prefix-except-default" ? raw.routing : DEFAULT_ROUTING;
166
+ return { errors, i18n: { defaultLocale, locales, routing } };
167
+ }
168
+
169
+ // src/dates.ts
170
+ var DATE_FORMATS = ["date", "date-time"];
171
+ function isDateFormat(format) {
172
+ return typeof format === "string" && DATE_FORMATS.includes(format);
173
+ }
174
+ var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
175
+ var RFC_3339 = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})?$/;
176
+ function pad(n, width = 2) {
177
+ return String(n).padStart(width, "0");
178
+ }
179
+ function toDateOnly(d) {
180
+ return `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
181
+ }
182
+ function toInstant(d) {
183
+ return `${toDateOnly(d)}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}Z`;
184
+ }
185
+ function parseDateValue(value, format) {
186
+ if (value instanceof Date) {
187
+ return Number.isNaN(value.getTime()) ? null : format === "date" ? toDateOnly(value) : toInstant(value);
188
+ }
189
+ if (typeof value !== "string") {
190
+ return null;
191
+ }
192
+ const text = value.trim();
193
+ if (text === "") {
194
+ return null;
195
+ }
196
+ if (DATE_ONLY.test(text)) {
197
+ const d = new Date(`${text}T00:00:00Z`);
198
+ if (Number.isNaN(d.getTime())) {
199
+ return null;
200
+ }
201
+ return format === "date" ? text : toInstant(d);
202
+ }
203
+ if (RFC_3339.test(text)) {
204
+ const normalized = /[Zz]|[+-]\d{2}:\d{2}$/.test(text) ? text : `${text.replace(" ", "T")}Z`;
205
+ const d = new Date(normalized);
206
+ if (Number.isNaN(d.getTime())) {
207
+ return null;
208
+ }
209
+ return format === "date" ? toDateOnly(d) : toInstant(d);
210
+ }
211
+ return null;
212
+ }
213
+ function isCoercedDate(value) {
214
+ return DATE_ONLY.test(value) || RFC_3339.test(value);
215
+ }
216
+ function coerceEntryDates(entries, schema, contentTypeName) {
217
+ const warnings = [];
218
+ const properties = schema?.properties;
219
+ if (!properties) {
220
+ return warnings;
221
+ }
222
+ const dateFields = Object.entries(properties).map(([field, def]) => [field, def.format]).filter((pair) => isDateFormat(pair[1]));
223
+ if (dateFields.length === 0) {
224
+ return warnings;
225
+ }
226
+ for (const entry of entries) {
227
+ for (const [field, format] of dateFields) {
228
+ const raw = entry.data[field];
229
+ if (raw === undefined || raw === null || raw === "") {
230
+ continue;
231
+ }
232
+ const coerced = parseDateValue(raw, format);
233
+ if (coerced === null) {
234
+ warnings.push({
235
+ entryId: entry.id,
236
+ field,
237
+ message: `Content dates: "${contentTypeName}/${entry.id}" field "${field}" is ` + `${JSON.stringify(raw)}, which is not an unambiguous ${format}. Write it as ` + `${format === "date" ? "YYYY-MM-DD" : "YYYY-MM-DDTHH:MM:SSZ"} — a form like ` + `"03/04/2025" means two different days depending on who reads it, so it is left as ` + `authored rather than guessed.`,
238
+ value: raw
239
+ });
240
+ continue;
241
+ }
242
+ if (coerced !== raw) {
243
+ entry._meta ??= {};
244
+ entry._meta.rawDates ??= {};
245
+ entry._meta.rawDates[field] = raw;
246
+ }
247
+ entry.data[field] = coerced;
248
+ }
249
+ }
250
+ return warnings;
251
+ }
252
+ function parseComparable(a, b) {
253
+ const aDate = typeof a === "string" && (DATE_ONLY.test(a) || RFC_3339.test(a));
254
+ const bDate = typeof b === "string" && (DATE_ONLY.test(b) || RFC_3339.test(b));
255
+ if (aDate && bDate) {
256
+ return [a, b];
257
+ }
258
+ return [Number(a), Number(b)];
259
+ }
260
+
83
261
  // src/content-loader.ts
84
262
  function loadJSONEntries(filePath) {
85
263
  const raw = JSON.parse(readFileSync(filePath, "utf8"));
@@ -109,19 +287,53 @@ function discoverJSONFiles(resolvedSource) {
109
287
  return [];
110
288
  }
111
289
  }
290
+ var LOCALE_PLACEHOLDER = "{locale}";
291
+ function localesForExpansion(projectConfig) {
292
+ return resolveI18n(projectConfig ?? {}).i18n?.locales ?? [];
293
+ }
294
+ function localeSource(root, source, locale) {
295
+ const candidates = [locale, locale.toLowerCase()];
296
+ for (const spelling of candidates) {
297
+ const path = source.split(LOCALE_PLACEHOLDER).join(spelling);
298
+ if (existsSync(resolve(root, path))) {
299
+ return path;
300
+ }
301
+ }
302
+ const parent = resolve(root, source.slice(0, source.indexOf(LOCALE_PLACEHOLDER)));
303
+ if (existsSync(parent) && statSync(parent).isDirectory()) {
304
+ const match = readdirSync(parent).find((name) => name.toLowerCase() === locale.toLowerCase());
305
+ if (match !== undefined) {
306
+ return source.split(LOCALE_PLACEHOLDER).join(match);
307
+ }
308
+ }
309
+ return source.split(LOCALE_PLACEHOLDER).join(locale);
310
+ }
112
311
  var SECTION_KEY = "content";
113
312
  var SAFE_TYPE_NAME = /^[\w.~-]+$/;
114
313
  var ASSET_KEYS = ["src", "poster"];
115
314
  function isNonRelativeRef(value) {
116
315
  return value === "" || value.startsWith("/") || value.startsWith("#") || value.includes("${") || /^[a-z][\w+.-]*:/i.test(value);
117
316
  }
118
- function contentAssetMounts(section, root) {
317
+ function contentAssetMounts(section, root, projectConfig) {
119
318
  const mounts = [];
319
+ const locales = localesForExpansion(projectConfig);
120
320
  for (const [name, contentTypeDef] of Object.entries(section ?? {})) {
121
321
  const source = contentTypeDef?.source;
122
322
  if (!source || source.startsWith("http://") || source.startsWith("https://")) {
123
323
  continue;
124
324
  }
325
+ if (source.includes(LOCALE_PLACEHOLDER)) {
326
+ for (const locale of locales) {
327
+ if (!SAFE_TYPE_NAME.test(locale)) {
328
+ continue;
329
+ }
330
+ const dir = resolve(root, localeSource(root, source, locale)).split("\\").join("/");
331
+ if (SAFE_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
332
+ mounts.push({ dir, urlPrefix: `/${SECTION_KEY}/${name}/${locale}` });
333
+ }
334
+ }
335
+ continue;
336
+ }
125
337
  if (!SAFE_TYPE_NAME.test(name)) {
126
338
  console.warn(`Content type "${name}": name is not URL-safe — its source directory is not published ` + `at /${SECTION_KEY}/${name}, so content-relative asset references stay unresolved.`);
127
339
  continue;
@@ -203,12 +415,35 @@ function loadContentConfig(projectRoot, projectConfig) {
203
415
  const config = { content: projectConfig?.content ?? {} };
204
416
  return { config, contentDir };
205
417
  }
206
- async function loadContentSection(section, root, formats) {
418
+ async function loadContentSection(section, root, formats, projectConfig) {
207
419
  const contentTypes = new Map;
208
- const mounts = new Map(contentAssetMounts(section, root).map((mount) => [mount.urlPrefix.split("/").pop(), mount]));
420
+ const mounts = new Map(contentAssetMounts(section, root, projectConfig).map((mount) => [
421
+ mount.urlPrefix.slice(`/${SECTION_KEY}/`.length),
422
+ mount
423
+ ]));
424
+ const locales = localesForExpansion(projectConfig);
209
425
  for (const [name, contentTypeDef] of Object.entries(section)) {
210
- const entries = await loadContentType(name, contentTypeDef, root, formats, mounts.get(name));
211
- 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
+ if (locales.length === 0) {
433
+ console.warn(`Content type "${name}": source "${source}" uses ${LOCALE_PLACEHOLDER}, but the project ` + `declares no i18n locales — nothing to expand it over, so no entries were loaded.`);
434
+ contentTypes.set(name, []);
435
+ continue;
436
+ }
437
+ const all = [];
438
+ for (const locale of locales) {
439
+ const localized = { ...contentTypeDef, source: localeSource(root, source, locale) };
440
+ const entries = await loadContentType(name, localized, root, formats, mounts.get(`${name}/${locale}`));
441
+ for (const entry of entries) {
442
+ entry._meta = { ...entry._meta, locale };
443
+ }
444
+ all.push(...entries);
445
+ }
446
+ contentTypes.set(name, all);
212
447
  }
213
448
  return contentTypes;
214
449
  }
@@ -295,6 +530,9 @@ async function loadContentType(name, contentTypeDef, projectRoot, registry, moun
295
530
  entries.push(...fileEntries);
296
531
  }
297
532
  if (schema) {
533
+ for (const warning of coerceEntryDates(entries, schema, name)) {
534
+ console.warn(warning.message);
535
+ }
298
536
  validateEntries(entries, schema, name);
299
537
  }
300
538
  return entries;
@@ -319,7 +557,9 @@ function validateEntries(entries, schema, contentTypeName) {
319
557
  if (value == null) {
320
558
  continue;
321
559
  }
322
- if (def.type === "string" && typeof value !== "string") {
560
+ if (isDateFormat(def.format) && typeof value === "string" && !isCoercedDate(value)) {
561
+ console.warn(`Content validation: "${contentTypeName}/${entry.id}" field "${field}" is declared ` + `${def.format} but "${value}" was not coerced — it will sort and filter as plain text.`);
562
+ } else if (def.type === "string" && typeof value !== "string") {
323
563
  console.warn(`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`);
324
564
  } else if (def.type === "number" && typeof value !== "number") {
325
565
  console.warn(`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`);
@@ -389,12 +629,12 @@ function resolveContentTypeRefs(contentTypes, section) {
389
629
  class Content {
390
630
  static async projectData(sectionValue, ctx) {
391
631
  const section = sectionValue ?? {};
392
- const data = await loadContentSection(section, ctx.root, ctx.registry.formats);
632
+ const data = await loadContentSection(section, ctx.root, ctx.registry.formats, ctx.projectConfig);
393
633
  resolveContentTypeRefs(data, section);
394
634
  return data;
395
635
  }
396
636
  static assets(sectionValue, ctx) {
397
- return contentAssetMounts(sectionValue ?? {}, ctx.root);
637
+ return contentAssetMounts(sectionValue ?? {}, ctx.root, ctx.projectConfig);
398
638
  }
399
639
  static async resolvePaths(pathsDef, ctx) {
400
640
  const entries = ctx.data.get(pathsDef.contentType);
@@ -404,17 +644,33 @@ class Content {
404
644
  }
405
645
  const param = pathsDef.param ?? "slug";
406
646
  const field = pathsDef.field ?? "id";
407
- return entries.map((entry) => ({
408
- [param]: field === "id" ? entry.id : entry.data[field] ?? entry.id
409
- })).filter((p) => p[param]);
647
+ const localized = entries.some((entry) => entry._meta?.locale !== undefined);
648
+ const wanted = ctx.locale;
649
+ const scoped = localized && typeof wanted === "string" ? entries.filter((entry) => entry._meta?.locale === wanted) : entries;
650
+ const paths = [];
651
+ for (const entry of scoped) {
652
+ const value = field === "id" ? entry.id : entry.data[field] ?? entry.id;
653
+ if (!value) {
654
+ continue;
655
+ }
656
+ const pathEntry = { [param]: value };
657
+ if (entry._meta !== undefined) {
658
+ pathEntry._meta = entry._meta;
659
+ }
660
+ paths.push(pathEntry);
661
+ }
662
+ return paths;
410
663
  }
411
664
  }
412
665
  export {
413
666
  rewriteEntryAssets,
414
667
  resolveContentTypeRefs,
668
+ localesForExpansion,
669
+ localeSource,
415
670
  loadContentSection,
416
671
  loadContentConfig,
417
672
  getContentTypeElements,
418
673
  contentAssetMounts,
674
+ LOCALE_PLACEHOLDER,
419
675
  Content
420
676
  };
package/dist/content.js CHANGED
@@ -46,6 +46,98 @@ var __export = (target, all) => {
46
46
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
47
47
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
48
48
 
49
+ // src/dates.ts
50
+ var DATE_FORMATS = ["date", "date-time"];
51
+ function isDateFormat(format) {
52
+ return typeof format === "string" && DATE_FORMATS.includes(format);
53
+ }
54
+ var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
55
+ var RFC_3339 = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})?$/;
56
+ function pad(n, width = 2) {
57
+ return String(n).padStart(width, "0");
58
+ }
59
+ function toDateOnly(d) {
60
+ return `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
61
+ }
62
+ function toInstant(d) {
63
+ return `${toDateOnly(d)}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}Z`;
64
+ }
65
+ function parseDateValue(value, format) {
66
+ if (value instanceof Date) {
67
+ return Number.isNaN(value.getTime()) ? null : format === "date" ? toDateOnly(value) : 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
+ if (DATE_ONLY.test(text)) {
77
+ const d = new Date(`${text}T00:00:00Z`);
78
+ if (Number.isNaN(d.getTime())) {
79
+ return null;
80
+ }
81
+ return format === "date" ? text : toInstant(d);
82
+ }
83
+ if (RFC_3339.test(text)) {
84
+ const normalized = /[Zz]|[+-]\d{2}:\d{2}$/.test(text) ? text : `${text.replace(" ", "T")}Z`;
85
+ const d = new Date(normalized);
86
+ if (Number.isNaN(d.getTime())) {
87
+ return null;
88
+ }
89
+ return format === "date" ? toDateOnly(d) : toInstant(d);
90
+ }
91
+ return null;
92
+ }
93
+ function isCoercedDate(value) {
94
+ return DATE_ONLY.test(value) || RFC_3339.test(value);
95
+ }
96
+ function coerceEntryDates(entries, schema, contentTypeName) {
97
+ const warnings = [];
98
+ const properties = schema?.properties;
99
+ if (!properties) {
100
+ return warnings;
101
+ }
102
+ const dateFields = Object.entries(properties).map(([field, def]) => [field, def.format]).filter((pair) => isDateFormat(pair[1]));
103
+ if (dateFields.length === 0) {
104
+ return warnings;
105
+ }
106
+ for (const entry of entries) {
107
+ for (const [field, format] of dateFields) {
108
+ const raw = entry.data[field];
109
+ if (raw === undefined || raw === null || raw === "") {
110
+ continue;
111
+ }
112
+ const coerced = parseDateValue(raw, format);
113
+ if (coerced === null) {
114
+ warnings.push({
115
+ entryId: entry.id,
116
+ field,
117
+ message: `Content dates: "${contentTypeName}/${entry.id}" field "${field}" is ` + `${JSON.stringify(raw)}, which is not an unambiguous ${format}. Write it as ` + `${format === "date" ? "YYYY-MM-DD" : "YYYY-MM-DDTHH:MM:SSZ"} — a form like ` + `"03/04/2025" means two different days depending on who reads it, so it is left as ` + `authored rather than guessed.`,
118
+ value: raw
119
+ });
120
+ continue;
121
+ }
122
+ if (coerced !== raw) {
123
+ entry._meta ??= {};
124
+ entry._meta.rawDates ??= {};
125
+ entry._meta.rawDates[field] = raw;
126
+ }
127
+ entry.data[field] = coerced;
128
+ }
129
+ }
130
+ return warnings;
131
+ }
132
+ function parseComparable(a, b) {
133
+ const aDate = typeof a === "string" && (DATE_ONLY.test(a) || RFC_3339.test(a));
134
+ const bDate = typeof b === "string" && (DATE_ONLY.test(b) || RFC_3339.test(b));
135
+ if (aDate && bDate) {
136
+ return [a, b];
137
+ }
138
+ return [Number(a), Number(b)];
139
+ }
140
+
49
141
  // src/content.ts
50
142
  function evaluateFilterRule(rule, entry) {
51
143
  const actual = rule.field === "id" ? entry.id : entry.data[rule.field];
@@ -75,16 +167,20 @@ function evaluateFilterRule(rule, entry) {
75
167
  return typeof actual === "string" && !actual.includes(String(rule.value ?? ""));
76
168
  }
77
169
  case ">": {
78
- return Number(actual) > Number(rule.value);
170
+ const [a, b] = parseComparable(actual, rule.value);
171
+ return a > b;
79
172
  }
80
173
  case "<": {
81
- return Number(actual) < Number(rule.value);
174
+ const [a, b] = parseComparable(actual, rule.value);
175
+ return a < b;
82
176
  }
83
177
  case ">=": {
84
- return Number(actual) >= Number(rule.value);
178
+ const [a, b] = parseComparable(actual, rule.value);
179
+ return a >= b;
85
180
  }
86
181
  case "<=": {
87
- return Number(actual) <= Number(rule.value);
182
+ const [a, b] = parseComparable(actual, rule.value);
183
+ return a <= b;
88
184
  }
89
185
  default: {
90
186
  return true;
@@ -175,10 +271,13 @@ class ContentEntry {
175
271
  if (!resolvedId) {
176
272
  return null;
177
273
  }
274
+ const wanted = _document?.route?.locale;
275
+ const localized = entries.some((e) => e._meta?.locale !== undefined);
276
+ const scoped = localized && typeof wanted === "string" ? entries.filter((e) => e._meta?.locale === wanted) : entries;
178
277
  if (field && field !== "id") {
179
- return entries.find((e) => e.data[field] === resolvedId) ?? null;
278
+ return scoped.find((e) => e.data[field] === resolvedId) ?? null;
180
279
  }
181
- return findEntry(entries, resolvedId);
280
+ return findEntry(scoped, resolvedId);
182
281
  }
183
282
  }
184
283
  export {