@jxsuite/compiler 4.0.3 → 5.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.
@@ -39,6 +39,7 @@ import {
39
39
  resolveSidecarEntry,
40
40
  } from "./bundler.ts";
41
41
  import { loadProjectConfig } from "./site-loader.ts";
42
+ import type { ResolvedProjectConfig } from "./site-loader.ts";
42
43
  import {
43
44
  discoverPages,
44
45
  expandDynamicRoutes,
@@ -95,6 +96,13 @@ import {
95
96
  registrationScript,
96
97
  tombstoneServiceWorker,
97
98
  } from "./service-worker.ts";
99
+ import {
100
+ htmlDeclaresNoindex,
101
+ isNotFoundRoute,
102
+ outputUrlPath,
103
+ servedRoute,
104
+ withAlternateLink,
105
+ } from "./static-host.ts";
98
106
  import {
99
107
  buildManifest,
100
108
  buildSecurityTxt,
@@ -579,7 +587,7 @@ export async function buildSite(
579
587
  translationKey: declaredKeys.get(r.urlPattern),
580
588
  urlPattern: r.urlPattern,
581
589
  }));
582
- const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl ?? "");
590
+ const alternateMap = localeAlternates(keyedRoutes, i18n, siteUrl ?? "", trailingSlash);
583
591
  /*
584
592
  * The same sets, site-absolute, for `$page.alternates`. Computed from the whole table for the
585
593
  * same reason the alternates are — and separately from them because a switcher must work in a
@@ -745,16 +753,16 @@ export async function buildSite(
745
753
  // Determine output path
746
754
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
747
755
  mkdirSync(dirname(outPath), { recursive: true });
748
- /* Last, after the scans above: they ask what this page REFERENCES, and the answer is a
749
- build-output path, not a deployed URL. Re-rooting first would make every one of them miss. */
750
- writeFileSync(outPath, rewriteHtmlBase(result.html, basePath), "utf8");
751
- fileCount += 1;
752
-
753
756
  // Record a sitemap entry for this concrete page (skip unexpanded dynamic routes and
754
757
  // Pages that opted out via $sitemap: false). <loc> is built like the canonical URL so
755
758
  // The two always agree.
756
759
  const isConcrete = !route.urlPattern.includes(":") && !route.urlPattern.includes("*");
757
- if (sitemapEnabled && !result.excludeFromSitemap && isConcrete) {
760
+ /*
761
+ * A page the sitemap would list and the page itself disowns sends two answers to one question,
762
+ * so a page that asks crawlers for `noindex` is left out, and so is the not-found page.
763
+ */
764
+ const indexable = !isNotFoundRoute(route.urlPattern) && !htmlDeclaresNoindex(result.html);
765
+ if (sitemapEnabled && !result.excludeFromSitemap && indexable && isConcrete) {
758
766
  const routeAlternates = alternateMap.get(route.urlPattern) ?? [];
759
767
  sitemapEntries.push({
760
768
  /*
@@ -768,12 +776,17 @@ export async function buildSite(
768
776
  typeof route.sourceMtime === "string" && route.sourceMtime !== ""
769
777
  ? route.sourceMtime
770
778
  : toRfc3339(statSync(route.sourcePath).mtime),
771
- loc: siteAbsoluteUrl(route.urlPattern, siteUrl),
779
+ loc: siteAbsoluteUrl(servedRoute(route.urlPattern, trailingSlash), siteUrl),
772
780
  ...(routeAlternates.length > 0 && { alternates: routeAlternates }),
773
781
  });
774
782
  }
775
783
 
776
- // Write serialized export sidecars alongside HTML (formats with exportTarget: true)
784
+ /*
785
+ * Serialize the export sidecars (formats with exportTarget: true, e.g. Markdown) BEFORE the page
786
+ * is written, so its `<head>` advertises exactly the twins that exist: a link to a file the
787
+ * build did not write is worse than no link.
788
+ */
789
+ const sidecars: { content: string; path: string; type: string | null }[] = [];
777
790
  for (const fmt of formatRegistry.withCapability("serialize")) {
778
791
  if (!fmt.exportTarget) {
779
792
  continue;
@@ -793,14 +806,34 @@ export async function buildSite(
793
806
  })) as string;
794
807
  if (content) {
795
808
  const sidecarPath = outPath.replace(/\.html$/, fmt.extensions[0]!);
796
- writeFileSync(sidecarPath, content, "utf8");
797
- fileCount += 1;
809
+ sidecars.push({ content, path: sidecarPath, type: fmt.mediaTypeEssence });
798
810
  }
799
811
  } catch (error) {
800
812
  const err = error as Error;
801
813
  errors.push(`Error exporting ${fmt.name} for ${route.urlPattern}: ${err.message}`);
802
814
  }
803
815
  }
816
+ /* The not-found page is served for whatever URL was mistyped, so what it would advertise is
817
+ not a twin of anything. */
818
+ if (!isNotFoundRoute(route.urlPattern)) {
819
+ for (const sidecar of sidecars) {
820
+ if (sidecar.type !== null) {
821
+ result.html = withAlternateLink(result.html, {
822
+ href: outputUrlPath(outDir, sidecar.path),
823
+ type: sidecar.type,
824
+ });
825
+ }
826
+ }
827
+ }
828
+
829
+ /* Last, after the scans above: they ask what this page REFERENCES, and the answer is a
830
+ build-output path, not a deployed URL. Re-rooting first would make every one of them miss. */
831
+ writeFileSync(outPath, rewriteHtmlBase(result.html, basePath), "utf8");
832
+ fileCount += 1;
833
+ for (const sidecar of sidecars) {
834
+ writeFileSync(sidecar.path, sidecar.content, "utf8");
835
+ fileCount += 1;
836
+ }
804
837
 
805
838
  // Write any additional files (island modules, etc.)
806
839
  for (const file of result.files) {
@@ -1239,7 +1272,7 @@ export async function buildSite(
1239
1272
  */
1240
1273
  async function compilePage(
1241
1274
  route: SiteRoute,
1242
- projectConfig: ProjectConfig,
1275
+ projectConfig: ResolvedProjectConfig,
1243
1276
  projectRoot: string,
1244
1277
  sections: Record<string, unknown>,
1245
1278
  imageCache: CacheManifest | null,
@@ -1427,7 +1460,14 @@ async function compilePage(
1427
1460
  ...(projectConfig.name != null && { siteName: projectConfig.name }),
1428
1461
  ...(projectConfig.url != null && { siteUrl: projectConfig.url }),
1429
1462
  ...(locale.alternates.length > 0 && { alternates: locale.alternates }),
1430
- pageUrl: route.urlPattern,
1463
+ /*
1464
+ * The canonical link and og:url name the URL the host answers with the page: the route as
1465
+ * `build.trailingSlash` serves it. The not-found page gets neither, since the URL it answers at
1466
+ * is whatever the visitor mistyped.
1467
+ */
1468
+ ...(!isNotFoundRoute(route.urlPattern) && {
1469
+ pageUrl: servedRoute(route.urlPattern, projectConfig.build.trailingSlash),
1470
+ }),
1431
1471
  });
1432
1472
 
1433
1473
  // Merge project-level $media into the layout document so responsive queries are available
@@ -1775,6 +1815,33 @@ function isBareSpecifier(s: string) {
1775
1815
  );
1776
1816
  }
1777
1817
 
1818
+ /**
1819
+ * The resolved value of a text template that still contains `${`, as escaped `innerHTML`.
1820
+ *
1821
+ * A template's result is data: an excerpt, a heading, a title. When that data itself holds `${` (a
1822
+ * note that explains template syntax) it is text to show, and left as `textContent` the compile
1823
+ * phase would read it as a template a second time, bind it to an expression that throws or renders
1824
+ * nothing, and the words would be gone. The same entity spelling the `innerHTML` resolution below
1825
+ * uses keeps it text.
1826
+ *
1827
+ * @param {string} resolved
1828
+ * @returns {string}
1829
+ */
1830
+ function inertTextHtml(resolved: string): string {
1831
+ return escapeHtml(resolved).replaceAll("${", "&#36;{");
1832
+ }
1833
+
1834
+ /**
1835
+ * A resolved attribute value with any `${` split by a zero-width space. An entity cannot be written
1836
+ * into an attribute, and a value that kept the sequence would be bound as a template again.
1837
+ *
1838
+ * @param {string} resolved
1839
+ * @returns {string}
1840
+ */
1841
+ function inertAttribute(resolved: string): string {
1842
+ return resolved.replaceAll("${", "$\u200B{");
1843
+ }
1844
+
1778
1845
  /**
1779
1846
  * Deep-clone a map template, resolving template strings and $ref values against the given scope.
1780
1847
  *
@@ -1808,7 +1875,11 @@ function expandMapTemplate(template: JxElement, scope: Record<string, unknown>):
1808
1875
  const attrs = { ...(v as Record<string, JxAttributeValue>) };
1809
1876
  for (const [ak, av] of Object.entries(attrs)) {
1810
1877
  if (isTemplateString(av)) {
1811
- attrs[ak] = (evaluateMapTemplate(av, scope) as JxAttributeValue | undefined) ?? av;
1878
+ const resolved = evaluateMapTemplate(av, scope) as JxAttributeValue | undefined;
1879
+ attrs[ak] =
1880
+ typeof resolved === "string" && resolved.includes("${")
1881
+ ? inertAttribute(resolved)
1882
+ : (resolved ?? av);
1812
1883
  }
1813
1884
  }
1814
1885
  node.attributes = attrs;
@@ -1823,7 +1894,12 @@ function expandMapTemplate(template: JxElement, scope: Record<string, unknown>):
1823
1894
  }
1824
1895
  node.$props = props;
1825
1896
  } else if (typeof v === "string" && isTemplateString(v)) {
1826
- node[k] = evaluateMapTemplate(v, scope) ?? v;
1897
+ const resolved = evaluateMapTemplate(v, scope);
1898
+ if (k === "textContent" && typeof resolved === "string" && resolved.includes("${")) {
1899
+ node.innerHTML = inertTextHtml(resolved);
1900
+ } else {
1901
+ node[k] = resolved ?? v;
1902
+ }
1827
1903
  } else {
1828
1904
  node[k] = v;
1829
1905
  }
@@ -1886,9 +1962,16 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1886
1962
  }
1887
1963
  }
1888
1964
  if (typeof node.textContent === "string" && isTemplateString(node.textContent)) {
1889
- node.textContent =
1890
- (evaluateStaticTemplate(node.textContent, scope) as string | null) ??
1891
- (node.textContent as string | null);
1965
+ const resolved = evaluateStaticTemplate(node.textContent, scope);
1966
+ if (typeof resolved === "string" && resolved.includes("${")) {
1967
+ // The resolved value is data (an excerpt, a heading, a title) that happens to contain `${`, not
1968
+ // A template. Left as `textContent` the compile phase would read it as one again and bind it
1969
+ // To an expression that throws or renders nothing, so it stays text as escaped `innerHTML`.
1970
+ node.innerHTML = inertTextHtml(resolved);
1971
+ delete node.textContent;
1972
+ } else {
1973
+ node.textContent = (resolved as string | null) ?? (node.textContent as string | null);
1974
+ }
1892
1975
  }
1893
1976
  if (node.style && typeof node.style === "object") {
1894
1977
  for (const [k, v] of Object.entries(node.style)) {
@@ -1902,7 +1985,13 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1902
1985
  if (node.attributes && typeof node.attributes === "object") {
1903
1986
  for (const [k, v] of Object.entries(node.attributes)) {
1904
1987
  if (typeof v === "string" && isTemplateString(v)) {
1905
- node.attributes[k] = (evaluateStaticTemplate(v, scope) as JxAttributeValue | null) ?? v;
1988
+ const resolved = evaluateStaticTemplate(v, scope) as JxAttributeValue | null;
1989
+ // A resolved value is data: a `${` left in it must not become a template a second time. An
1990
+ // Entity cannot be written into an attribute, so the sequence is split with a zero-width space.
1991
+ node.attributes[k] =
1992
+ typeof resolved === "string" && resolved.includes("${")
1993
+ ? inertAttribute(resolved)
1994
+ : (resolved ?? v);
1906
1995
  }
1907
1996
  }
1908
1997
  }
@@ -2351,7 +2440,7 @@ function injectHead(
2351
2440
  * Convert a URL pattern to an output file path.
2352
2441
  *
2353
2442
  * "/" → dist/index.html "/about" → dist/about/index.html (with trailingSlash: "always")
2354
- * "/blog/hello" → dist/blog/hello/index.html
2443
+ * "/blog/hello" → dist/blog/hello/index.html "/404" → dist/404.html
2355
2444
  *
2356
2445
  * @param {string} urlPattern
2357
2446
  * @param {string} outDir
@@ -2363,6 +2452,12 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
2363
2452
  return join(outDir, "index.html");
2364
2453
  }
2365
2454
 
2455
+ // The not-found page is the one file a static host looks for by name (specs/site-architecture.md
2456
+ // §8.4.2), whatever the trailingSlash setting.
2457
+ if (isNotFoundRoute(urlPattern)) {
2458
+ return join(outDir, "404.html");
2459
+ }
2460
+
2366
2461
  // Remove leading slash
2367
2462
  const segments = urlPattern.replace(/^\//, "");
2368
2463
 
@@ -0,0 +1,153 @@
1
+ /**
2
+ * What a static host and a crawler are told about each page, decided in one place.
3
+ *
4
+ * Four facts, each of which the build used to leave to the author or get wrong:
5
+ *
6
+ * - The path a host serves a route at, which `build.trailingSlash` decides, so the canonical link,
7
+ * `og:url`, the sitemap `<loc>` and the `hreflang` alternates name the URL that answers with the
8
+ * page and not one that redirects to it ({@link servedRoute});
9
+ * - Which route is the site's not-found page ({@link isNotFoundRoute}), which hosts expect at
10
+ * `/404.html` and which is never a page to index;
11
+ * - Whether a built page asks crawlers not to index it ({@link htmlDeclaresNoindex}), because a
12
+ * sitemap that lists a page the page itself disowns sends two answers to the same question;
13
+ * - Where a page's machine-readable twin lives, and the `<link rel="alternate">` that tells a reader
14
+ * of the HTML about it ({@link withAlternateLink}), because a file nothing points to is a file
15
+ * nobody finds.
16
+ *
17
+ * @docs framework/site/deployment
18
+ * @docs framework/agents/machine-readable
19
+ */
20
+
21
+ import { relative, sep } from "node:path";
22
+ import { escapeHtml } from "../shared.ts";
23
+
24
+ /**
25
+ * The route of the site's not-found page, written to `404.html` (specs/site-architecture.md
26
+ * §8.4.2).
27
+ */
28
+ export const NOT_FOUND_ROUTE = "/404";
29
+
30
+ /** Whether `urlPattern` is the site's not-found page. */
31
+ export function isNotFoundRoute(urlPattern: string): boolean {
32
+ return urlPattern === NOT_FOUND_ROUTE;
33
+ }
34
+
35
+ /**
36
+ * The path a host serves `urlPattern` at, given `build.trailingSlash`.
37
+ *
38
+ * `"always"` writes `/about` as `about/index.html`, which hosts serve at `/about/` and answer
39
+ * `/about` with a redirect to it; `"never"` writes `about.html`, served at `/about`. The root is
40
+ * `/` either way, and any other setting leaves the route as written.
41
+ *
42
+ * @param {string} urlPattern - A concrete route (`/about`, `/kb/a/b`)
43
+ * @param {string} [trailingSlash] - `build.trailingSlash`
44
+ * @returns {string} The route as the host serves it
45
+ */
46
+ export function servedRoute(urlPattern: string, trailingSlash?: string): string {
47
+ if (urlPattern === "/") {
48
+ return "/";
49
+ }
50
+ if (trailingSlash === "always") {
51
+ return urlPattern.endsWith("/") ? urlPattern : `${urlPattern}/`;
52
+ }
53
+ if (trailingSlash === "never") {
54
+ return urlPattern.replace(/\/+$/, "") || "/";
55
+ }
56
+ return urlPattern;
57
+ }
58
+
59
+ /** The value of one attribute in a start tag, quoted either way or bare; `null` when absent. */
60
+ function attributeOf(tag: string, name: string): string | null {
61
+ const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(
62
+ tag,
63
+ );
64
+ return match === null ? null : (match[1] ?? match[2] ?? match[3] ?? "");
65
+ }
66
+
67
+ /**
68
+ * Whether the `<head>` of a built page carries `<meta name="robots">` with `noindex` (or `none`,
69
+ * which means `noindex, nofollow`). Reads the finished HTML, like the CSP scan: a page can set the
70
+ * tag from a layout, a state entry or `$head`, and only the output says what it ended up with.
71
+ *
72
+ * @param {string} html - A built page
73
+ * @returns {boolean} True when crawlers are asked to leave the page out of their index
74
+ */
75
+ export function htmlDeclaresNoindex(html: string): boolean {
76
+ const end = html.search(/<\/head\s*>/i);
77
+ const head = end === -1 ? html : html.slice(0, end);
78
+ for (const [tag] of head.matchAll(/<meta\b[^>]*>/gi)) {
79
+ if (attributeOf(tag, "name")?.trim().toLowerCase() !== "robots") {
80
+ continue;
81
+ }
82
+ const directives = new Set(
83
+ (attributeOf(tag, "content") ?? "")
84
+ .toLowerCase()
85
+ .split(",")
86
+ .map((part) => part.trim()),
87
+ );
88
+ if (directives.has("noindex") || directives.has("none")) {
89
+ return true;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
95
+ /** The `<head>` of a built page, or the whole string when it has no closing tag. */
96
+ function headOf(html: string): { head: string; end: number } {
97
+ const end = html.search(/<\/head\s*>/i);
98
+ return { end, head: end === -1 ? html : html.slice(0, end) };
99
+ }
100
+
101
+ /**
102
+ * Whether a page's `<head>` already has a `<link rel="alternate">` of this media type, one the
103
+ * author wrote or an earlier step added. `type` is compared by essence and without regard to case.
104
+ *
105
+ * @param {string} html - A built page
106
+ * @param {string} type - A media type essence, `text/markdown`
107
+ * @returns {boolean} True when the page already advertises an alternate of that type
108
+ */
109
+ export function hasAlternateLink(html: string, type: string): boolean {
110
+ const wanted = type.trim().toLowerCase();
111
+ for (const [tag] of headOf(html).head.matchAll(/<link\b[^>]*>/gi)) {
112
+ const rel = (attributeOf(tag, "rel") ?? "").toLowerCase().split(/\s+/);
113
+ const linked = (attributeOf(tag, "type") ?? "").split(";")[0]?.trim().toLowerCase();
114
+ if (rel.includes("alternate") && linked === wanted) {
115
+ return true;
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Adds `<link rel="alternate" type="…" href="…">` to the end of a built page's `<head>`, unless the
123
+ * page already has an alternate of that type (an author's own wins, like every auto-injected entry,
124
+ * specs/site-architecture.md §8.4) or has no `<head>` to put it in.
125
+ *
126
+ * This is the discovery mechanism the llms.txt proposal recommends for a Markdown twin
127
+ * (`rel="alternate" type="text/markdown"`): the twin's URL is not one a reader can guess, and
128
+ * `/templates/` is not `/templates/index.md` to anyone who has not been told.
129
+ *
130
+ * @param {string} html - A built page
131
+ * @param {{ href: string; type: string }} link - Site-absolute `href`, and the media type essence
132
+ * @returns {string} The page, with the link when it was needed
133
+ */
134
+ export function withAlternateLink(html: string, link: { href: string; type: string }): string {
135
+ const { end } = headOf(html);
136
+ if (end === -1 || hasAlternateLink(html, link.type)) {
137
+ return html;
138
+ }
139
+ const tag = `<link href="${escapeHtml(link.href)}" rel="alternate" type="${escapeHtml(link.type)}">`;
140
+ return `${html.slice(0, end)}${tag}\n${html.slice(end)}`;
141
+ }
142
+
143
+ /**
144
+ * The site-absolute URL path a file in the build output is served at, percent-encoded the way a
145
+ * `<link href>` and a canonical URL are (`/kb/a b/index.md` is `/kb/a%20b/index.md`).
146
+ *
147
+ * @param {string} outDir - The build output directory
148
+ * @param {string} file - A file inside it
149
+ * @returns {string} `/templates/index.md`
150
+ */
151
+ export function outputUrlPath(outDir: string, file: string): string {
152
+ return new URL(`/${relative(outDir, file).split(sep).join("/")}`, "http://localhost").pathname;
153
+ }