@jxsuite/compiler 8.0.1 → 8.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "8.0.1",
3
+ "version": "8.1.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,10 +52,10 @@
52
52
  "upgrade": "bun update --latest && bun install"
53
53
  },
54
54
  "dependencies": {
55
- "@jxsuite/create": "^1.5.1",
56
- "@jxsuite/runtime": "^6.0.1",
57
- "@jxsuite/schema": "^5.0.0",
58
- "@jxsuite/site": "^2.2.2",
55
+ "@jxsuite/create": "^1.5.3",
56
+ "@jxsuite/runtime": "^6.0.2",
57
+ "@jxsuite/schema": "^5.1.0",
58
+ "@jxsuite/site": "^2.3.0",
59
59
  "@vue/reactivity": "3.5.44",
60
60
  "esbuild": "^0.28.2",
61
61
  "lit-html": "^3.3.3",
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@happy-dom/global-registrator": "^21.0.4",
66
- "@jxsuite/connector": "^0.8.2",
67
- "@jxsuite/parser": "^4.1.2",
66
+ "@jxsuite/connector": "^0.8.3",
67
+ "@jxsuite/parser": "^4.2.0",
68
68
  "@jxsuite/test-support": "^0.0.0",
69
69
  "@types/mdast": "^4.0.4"
70
70
  }
@@ -16,10 +16,12 @@
16
16
  * genuinely needs a filesystem: the walk, the `$layout` peek, and `$paths` expansion.
17
17
  */
18
18
 
19
- import { readFileSync, readdirSync } from "node:fs";
19
+ import { readFileSync, readdirSync, statSync } from "node:fs";
20
20
  import { isJsonObject, isString } from "@jxsuite/schema/guards";
21
21
  import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
22
22
  import { compareRoutes, fileToRoute as routeFromFileName } from "@jxsuite/site/routes";
23
+ import { pageCount, pageSlice, pageUrl, pagedSource, pagination } from "@jxsuite/site/pagination";
24
+ import type { PagePagination, PagedSource } from "@jxsuite/site/pagination";
23
25
  import { extname, join, relative, resolve } from "node:path";
24
26
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
25
27
  import type {
@@ -31,6 +33,8 @@ import type { JsonValue, JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/
31
33
  import { localeOfRoute } from "./i18n.ts";
32
34
  import type { ResolvedI18n } from "./i18n.ts";
33
35
  import type { ProjectSections } from "./project-sections.ts";
36
+ import { nodeImportRebaser } from "./context-injection.ts";
37
+ import { resolvePrototypes } from "./prototype-resolver.ts";
34
38
 
35
39
  interface Route {
36
40
  urlPattern: string; // URL pattern (e.g. "/blog/:slug")
@@ -48,6 +52,19 @@ interface Route {
48
52
  * mtime and the whole collection looks edited whenever the template is.
49
53
  */
50
54
  sourceMtime?: string;
55
+ /** Set on a page of a paged list (site-architecture.md §4.3); becomes `$page.pagination`. */
56
+ pagination?: PagePagination;
57
+ /**
58
+ * The page's slice of a paged list, seeded into `state` before prototypes resolve, so the list is
59
+ * resolved once while the route is expanded and not again for every page.
60
+ */
61
+ pagedState?: { key: string; items: JsonValue[] };
62
+ }
63
+
64
+ /** Where `expandDynamicRoutes` reports a page it cannot expand. */
65
+ export interface ExpandOptions {
66
+ /** Collects one message per misconfigured route; without it the first one throws. */
67
+ errors?: string[];
51
68
  }
52
69
 
53
70
  /**
@@ -346,8 +363,16 @@ export async function expandDynamicRoutes(
346
363
  registry?: ExtensionRegistry,
347
364
  projectConfig?: ProjectConfig,
348
365
  i18n?: ResolvedI18n | null,
366
+ options: ExpandOptions = {},
349
367
  ) {
350
368
  const expanded: Route[] = [];
369
+ const report = (route: Route, message: string): void => {
370
+ const text = `Dynamic route ${route.urlPattern}: ${message}`;
371
+ if (options.errors === undefined) {
372
+ throw new Error(text);
373
+ }
374
+ options.errors.push(text);
375
+ };
351
376
  /**
352
377
  * Every dynamic page's pattern, so an extension can tell a route no page produces from one a
353
378
  * different page does.
@@ -374,6 +399,43 @@ export async function expandDynamicRoutes(
374
399
  continue;
375
400
  }
376
401
 
402
+ let paged: PagedSource | null;
403
+ try {
404
+ paged = pagedSource(raw.$paths);
405
+ } catch (error) {
406
+ report(route, errorMessage(error));
407
+ continue;
408
+ }
409
+ if (paged !== null) {
410
+ const locale = localeOfRoute(route.urlPattern, i18n ?? null);
411
+ const each =
412
+ paged.each === undefined
413
+ ? [{}]
414
+ : await resolvePathEntries(
415
+ // SAFETY: `pagedSource` proved `each` an object or an array, the two `$paths` forms.
416
+ paged.each as JxPathsDef,
417
+ projectRoot,
418
+ sections,
419
+ registry,
420
+ projectConfig,
421
+ locale,
422
+ route,
423
+ dynamicPatterns,
424
+ );
425
+ for (const outer of each) {
426
+ const pages = await expandPagedRoute(route, raw, paged, outer, {
427
+ locale,
428
+ projectConfig,
429
+ projectRoot,
430
+ registry,
431
+ report,
432
+ sections,
433
+ });
434
+ expanded.push(...pages);
435
+ }
436
+ continue;
437
+ }
438
+
377
439
  const pathEntries = await resolvePathEntries(
378
440
  raw.$paths,
379
441
  projectRoot,
@@ -433,6 +495,166 @@ export async function expandDynamicRoutes(
433
495
  return expanded;
434
496
  }
435
497
 
498
+ /** What {@link expandPagedRoute} needs from the expansion around it. */
499
+ interface PagedContext {
500
+ locale: string | null;
501
+ projectConfig: ProjectConfig | undefined;
502
+ projectRoot: string;
503
+ registry: ExtensionRegistry | undefined;
504
+ report: (route: Route, message: string) => void;
505
+ sections: ProjectSections;
506
+ }
507
+
508
+ /**
509
+ * A route pattern with the given parameters substituted: a named one replaces its `:name`, and the
510
+ * one the pattern does not name is the catch-all's `*`.
511
+ */
512
+ function substitute(urlPattern: string, params: Record<string, string>): string {
513
+ let url = urlPattern;
514
+ for (const [name, value] of Object.entries(params)) {
515
+ const named = new RegExp(`:${name.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)}(?=/|$)`);
516
+ url = named.test(url) ? url.replace(named, () => value) : url.replace("*", () => value);
517
+ }
518
+ return url;
519
+ }
520
+
521
+ /**
522
+ * The pages of one paged list: the route's other parameters fixed by `outer` (one value of its
523
+ * `each` source, or none), the page number filling the one parameter left.
524
+ *
525
+ * @param {Route} route - The dynamic page
526
+ * @param {JxDocument} raw - Its document
527
+ * @param {PagedSource} paged - Its `$paths`
528
+ * @param {PathParams} outer - The parameters `each` gave this list
529
+ * @param {PagedContext} ctx
530
+ * @returns {Promise<Route[]>} One concrete route per page; none when the source is misconfigured,
531
+ * which is reported
532
+ */
533
+ async function expandPagedRoute(
534
+ route: Route,
535
+ raw: JxDocument,
536
+ paged: PagedSource,
537
+ outer: PathParams,
538
+ ctx: PagedContext,
539
+ ): Promise<Route[]> {
540
+ const fixed: Record<string, string> = {};
541
+ for (const [name, value] of Object.entries(outer)) {
542
+ if (name !== PATH_ENTRY_META) {
543
+ fixed[name] = String(value);
544
+ }
545
+ }
546
+ const left = route.params.filter((name) => !(name in fixed));
547
+ if (left.length !== 1) {
548
+ ctx.report(
549
+ route,
550
+ `a paged route takes exactly one parameter besides those its each source fills; ` +
551
+ `this one has ${left.length}`,
552
+ );
553
+ return [];
554
+ }
555
+ /* SAFETY: `left` was just proved to hold exactly one name. */
556
+ const pageParam = left[0] as string;
557
+ if (raw.state?.[paged.key] === undefined) {
558
+ ctx.report(
559
+ route,
560
+ `$paths.paginate names state entry "${paged.key}", which this page does not declare`,
561
+ );
562
+ return [];
563
+ }
564
+ const listPattern = substitute(route.urlPattern, fixed);
565
+ const escapes = traversalIn(listPattern);
566
+ if (escapes !== null) {
567
+ console.warn(
568
+ `Warning: $paths for ${route.urlPattern} produced "${listPattern}", which ${escapes}; skipping it`,
569
+ );
570
+ return [];
571
+ }
572
+ const items = await resolvePagedEntry(raw, paged.key, route, fixed, ctx);
573
+ if (!Array.isArray(items)) {
574
+ ctx.report(route, `state entry "${paged.key}" did not resolve to an array at build time`);
575
+ return [];
576
+ }
577
+ const newest = newestItemMtime(items, route.sourcePath);
578
+ const count = pageCount(items.length, paged.pageSize);
579
+ const pages: Route[] = [];
580
+ for (let number = 1; number <= count; number++) {
581
+ const concrete: Route = {
582
+ ...route,
583
+ _pathParams: { ...fixed, [pageParam]: String(number) },
584
+ isCatchAll: false,
585
+ isDynamic: false,
586
+ pagedState: { items: pageSlice(items, number, paged.pageSize), key: paged.key },
587
+ pagination: pagination(listPattern, number, paged.pageSize, items.length),
588
+ params: [],
589
+ urlPattern: pageUrl(listPattern, number),
590
+ };
591
+ if (newest !== null) {
592
+ concrete.sourceMtime = newest;
593
+ }
594
+ pages.push(concrete);
595
+ }
596
+ return pages;
597
+ }
598
+
599
+ /**
600
+ * The newest `_meta.mtime` of the list's items when it is later than the template's own, kept as
601
+ * the item wrote it; null otherwise. One new or edited entry can move every page, so every page is
602
+ * dated by the newest of them (§8.4.1).
603
+ */
604
+ function newestItemMtime(items: readonly JsonValue[], sourcePath: string): string | null {
605
+ let newest: string | null = null;
606
+ let newestTime = statSync(sourcePath).mtime.getTime();
607
+ for (const item of items) {
608
+ const meta = isJsonObject(item) ? item[PATH_ENTRY_META] : undefined;
609
+ const mtime = isJsonObject(meta) ? meta.mtime : undefined;
610
+ if (isString(mtime) && Date.parse(mtime) > newestTime) {
611
+ newestTime = Date.parse(mtime);
612
+ newest = mtime;
613
+ }
614
+ }
615
+ return newest;
616
+ }
617
+
618
+ /**
619
+ * The paged entry as the build resolves it for the route `fixed` names: a literal array as it is, a
620
+ * class entry through {@link resolvePrototypes} over a document holding that entry alone and the
621
+ * page's imports merged with the project's (rebased as the page's own would be). Only the entry is
622
+ * resolved, so a filter in it may read the route's parameters but not the page's other state.
623
+ */
624
+ async function resolvePagedEntry(
625
+ raw: JxDocument,
626
+ key: string,
627
+ route: Route,
628
+ fixed: Record<string, string>,
629
+ ctx: PagedContext,
630
+ ): Promise<JsonValue | undefined> {
631
+ const def = raw.state?.[key];
632
+ if (def === undefined || Array.isArray(def)) {
633
+ return def;
634
+ }
635
+ const imports: Record<string, string> = {};
636
+ const rebase = nodeImportRebaser(ctx.projectRoot);
637
+ for (const [name, src] of Object.entries(ctx.projectConfig?.imports ?? {})) {
638
+ imports[name] = src.startsWith("./") || src.startsWith("../") ? rebase(src, route) : src;
639
+ }
640
+ Object.assign(imports, raw.imports);
641
+ const mini: JxDocument = { imports, state: { [key]: structuredClone(def) } };
642
+ const pageRoute = {
643
+ _pathParams: fixed,
644
+ locale: ctx.locale,
645
+ sourcePath: route.sourcePath,
646
+ urlPattern: substitute(route.urlPattern, fixed),
647
+ };
648
+ await resolvePrototypes(mini, pageRoute, ctx.projectRoot, {
649
+ ...(ctx.projectConfig && { config: ctx.projectConfig }),
650
+ ...(ctx.registry && { registry: ctx.registry }),
651
+ sections: ctx.sections,
652
+ });
653
+ /* SAFETY: a resolved class entry is the JSON its `resolve()` answered with, and an unresolved
654
+ one is the definition object, which the caller rejects as not an array. */
655
+ return mini.state?.[key] as JsonValue | undefined;
656
+ }
657
+
436
658
  /**
437
659
  * Resolve $paths into an array of param objects.
438
660
  *
@@ -72,6 +72,7 @@ import { layoutChain, resolveLayout } from "./layout-resolver.ts";
72
72
  import { dedupeServerEntries, documentServerEntries } from "./server-entries.ts";
73
73
  import type { DedupedServerEntries, ServerEntryRecord } from "./server-entries.ts";
74
74
  import { mergeHead, renderHead, withPageDescription } from "@jxsuite/site/head-merger";
75
+ import type { PagePagination } from "@jxsuite/site/pagination";
75
76
  import { unregisteredHeadRelations } from "./link-relations.ts";
76
77
  import { injectContext } from "./context-injection.ts";
77
78
  import { compile, compileSiteServer } from "../compiler.ts";
@@ -430,6 +431,7 @@ export async function buildSite(
430
431
  registry,
431
432
  projectConfig,
432
433
  i18n,
434
+ { errors },
433
435
  );
434
436
  /*
435
437
  * Every route carries the locale it serves, resolved once here rather than re-derived by each
@@ -1351,6 +1353,9 @@ interface PageRoute {
1351
1353
  urlPattern: string;
1352
1354
  sourcePath: string;
1353
1355
  _pathParams?: Record<string, string>;
1356
+ /** A page of a paged list (site-architecture.md §4.3): its pager values and its slice. */
1357
+ pagination?: PagePagination;
1358
+ pagedState?: { key: string; items: JsonValue[] };
1354
1359
  }
1355
1360
 
1356
1361
  /**
@@ -1448,6 +1453,14 @@ async function compilePage(
1448
1453
  routeLocale: localeOfRoute(route.urlPattern, locale.i18n),
1449
1454
  });
1450
1455
 
1456
+ /*
1457
+ * A page of a paged list gets its slice in place of the entry it pages. The entry was resolved
1458
+ * once while the route was expanded, so it is not queried again for every page.
1459
+ */
1460
+ if (route.pagedState && layoutDoc.state && route.pagedState.key in layoutDoc.state) {
1461
+ layoutDoc.state[route.pagedState.key] = route.pagedState.items;
1462
+ }
1463
+
1451
1464
  // Resolve generic $prototype entries via .class.json imports (and lower registry classes)
1452
1465
  await resolvePrototypes(layoutDoc, route, projectRoot, {
1453
1466
  config: projectConfig,