@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/dist/cli.js +151 -151
- package/package.json +7 -7
- package/src/site/pages-discovery.ts +223 -1
- package/src/site/site-build.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "8.0
|
|
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.
|
|
56
|
-
"@jxsuite/runtime": "^6.0.
|
|
57
|
-
"@jxsuite/schema": "^5.
|
|
58
|
-
"@jxsuite/site": "^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.
|
|
67
|
-
"@jxsuite/parser": "^4.
|
|
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
|
*
|
package/src/site/site-build.ts
CHANGED
|
@@ -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,
|