@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.
- package/dist/cli.js +175 -174
- package/dist/compiler.js +2 -2
- package/package.json +7 -7
- package/src/site/asset-mounts.ts +2 -0
- package/src/site/format-host.ts +8 -2
- package/src/site/i18n.ts +7 -2
- package/src/site/pages-discovery.ts +48 -0
- package/src/site/site-build.ts +115 -20
- package/src/site/static-host.ts +153 -0
package/src/site/site-build.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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("${", "${");
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1891
|
-
(
|
|
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
|
-
|
|
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
|
+
}
|