@ascribed/astro 0.2.1-next.15 → 0.2.1-next.17
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/README.md +21 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +62 -0
- package/dist/cli.js.map +1 -0
- package/dist/content.d.ts +27 -0
- package/dist/content.js +71 -1
- package/dist/content.js.map +1 -1
- package/dist/files.d.ts +5 -2
- package/dist/files.js +13 -5
- package/dist/files.js.map +1 -1
- package/dist/head-check.d.ts +6 -0
- package/dist/head-check.js +28 -0
- package/dist/head-check.js.map +1 -0
- package/dist/head.d.ts +54 -0
- package/dist/head.js +114 -0
- package/dist/head.js.map +1 -0
- package/dist/headers.d.ts +50 -0
- package/dist/headers.js +155 -0
- package/dist/headers.js.map +1 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +124 -14
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +41 -0
- package/dist/init.js +130 -0
- package/dist/init.js.map +1 -0
- package/dist/nav.d.ts +51 -0
- package/dist/nav.js +97 -0
- package/dist/nav.js.map +1 -0
- package/dist/permalinks.d.ts +6 -0
- package/dist/permalinks.js +13 -0
- package/dist/permalinks.js.map +1 -1
- package/dist/project.d.ts +26 -0
- package/dist/project.js +32 -0
- package/dist/project.js.map +1 -1
- package/dist/projects.d.ts +0 -6
- package/dist/projects.js +1 -14
- package/dist/projects.js.map +1 -1
- package/dist/records.d.ts +136 -0
- package/dist/records.js +182 -0
- package/dist/records.js.map +1 -0
- package/dist/root-files.d.ts +68 -0
- package/dist/root-files.js +103 -0
- package/dist/root-files.js.map +1 -0
- package/dist/run.js +3 -2
- package/dist/run.js.map +1 -1
- package/package.json +12 -6
- package/src/Head.astro +58 -0
- package/starter/src/content.config.ts +8 -0
- package/starter/src/layouts/Docs.astro +87 -0
- package/starter/src/nav.ts +13 -0
- package/starter/src/pages/[...slug].astro +20 -0
package/dist/head.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tags of a page's head that are about the page, in order: the title and
|
|
3
|
+
* the description; the canonical link; the robots rule; the link to the
|
|
4
|
+
* Markdown version, when the build publishes one and the page is offered; the
|
|
5
|
+
* Open Graph tags a link preview reads, the card's among them when there is
|
|
6
|
+
* one; the breadcrumbs, as structured data, when there are two steps or more;
|
|
7
|
+
* and, on the page at the host's root, the site's name, as structured data.
|
|
8
|
+
* The title, the description, and the address are the record's, so the head
|
|
9
|
+
* can't disagree with the sitemap or `llms.txt`.
|
|
10
|
+
*/
|
|
11
|
+
export function headTags(record, options) {
|
|
12
|
+
const tags = [];
|
|
13
|
+
const meta = (attributes) => tags.push({ tag: "meta", attributes });
|
|
14
|
+
const link = (attributes) => tags.push({ tag: "link", attributes });
|
|
15
|
+
// A step's address is on the record's site when it has one.
|
|
16
|
+
const absolute = (route) => new URL(route, record.canonical ?? options.address).href;
|
|
17
|
+
const title = record.title === undefined
|
|
18
|
+
? undefined
|
|
19
|
+
: options.title === undefined
|
|
20
|
+
? record.title
|
|
21
|
+
: options.title(record.title, record);
|
|
22
|
+
if (title !== undefined)
|
|
23
|
+
tags.push({ tag: "title", text: title });
|
|
24
|
+
if (record.description !== undefined)
|
|
25
|
+
meta({ name: "description", content: record.description });
|
|
26
|
+
if (record.canonical !== undefined)
|
|
27
|
+
link({ rel: "canonical", href: record.canonical });
|
|
28
|
+
if (record.listing === "unlisted")
|
|
29
|
+
meta({ name: "robots", content: "noindex" });
|
|
30
|
+
if (record.listing === "unquoted")
|
|
31
|
+
meta({ name: "robots", content: "nosnippet" });
|
|
32
|
+
if (record.markdown !== undefined && record.listing !== "unlisted")
|
|
33
|
+
link({ rel: "alternate", type: "text/markdown", href: record.markdown });
|
|
34
|
+
meta({ property: "og:type", content: "website" });
|
|
35
|
+
if (options.name !== undefined)
|
|
36
|
+
meta({ property: "og:site_name", content: options.name });
|
|
37
|
+
if (title !== undefined)
|
|
38
|
+
meta({ property: "og:title", content: title });
|
|
39
|
+
if (record.description !== undefined)
|
|
40
|
+
meta({ property: "og:description", content: record.description });
|
|
41
|
+
if (record.canonical !== undefined)
|
|
42
|
+
meta({ property: "og:url", content: record.canonical });
|
|
43
|
+
const card = options.card;
|
|
44
|
+
if (card !== undefined) {
|
|
45
|
+
meta({ property: "og:image", content: new URL(card.src, options.address).href });
|
|
46
|
+
if (card.width !== undefined)
|
|
47
|
+
meta({ property: "og:image:width", content: String(card.width) });
|
|
48
|
+
if (card.height !== undefined)
|
|
49
|
+
meta({ property: "og:image:height", content: String(card.height) });
|
|
50
|
+
meta({ property: "og:image:alt", content: card.alt });
|
|
51
|
+
}
|
|
52
|
+
// Google reads a trail of two steps or more.
|
|
53
|
+
const steps = record.breadcrumbs ?? [];
|
|
54
|
+
if (steps.length >= 2) {
|
|
55
|
+
tags.push({
|
|
56
|
+
tag: "script",
|
|
57
|
+
json: {
|
|
58
|
+
"@context": "https://schema.org",
|
|
59
|
+
"@type": "BreadcrumbList",
|
|
60
|
+
itemListElement: steps.map((step, at) => ({
|
|
61
|
+
"@type": "ListItem",
|
|
62
|
+
position: at + 1,
|
|
63
|
+
name: step.title,
|
|
64
|
+
item: absolute(step.route),
|
|
65
|
+
})),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// The site's name, on the home page alone: the host's root.
|
|
70
|
+
if (options.name !== undefined && record.route === "/") {
|
|
71
|
+
tags.push({
|
|
72
|
+
tag: "script",
|
|
73
|
+
json: {
|
|
74
|
+
"@context": "https://schema.org",
|
|
75
|
+
"@type": "WebSite",
|
|
76
|
+
name: options.name,
|
|
77
|
+
url: absolute("/"),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return tags;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* JSON for a `<script>` element: `<` written as `\u003c`, so no string in it
|
|
85
|
+
* can close the element.
|
|
86
|
+
*/
|
|
87
|
+
export function scriptJson(json) {
|
|
88
|
+
return JSON.stringify(json).replaceAll("<", "\\u003c");
|
|
89
|
+
}
|
|
90
|
+
/** The tags `Head.astro` writes that a head should have once, each with how to find it. */
|
|
91
|
+
const ONCE = [
|
|
92
|
+
{ name: "<title>", pattern: /<title[\s>]/gi },
|
|
93
|
+
...["description", "robots"].map((name) => ({
|
|
94
|
+
name: `<meta name="${name}">`,
|
|
95
|
+
pattern: new RegExp(`<meta\\s[^>]*name=["']?${name}["'\\s/>]`, "gi"),
|
|
96
|
+
})),
|
|
97
|
+
{ name: '<link rel="canonical">', pattern: /<link\s[^>]*rel=["']?canonical["'\s/>]/gi },
|
|
98
|
+
...["og:type", "og:site_name", "og:title", "og:description", "og:url"].map((name) => ({
|
|
99
|
+
name: `<meta property="${name}">`,
|
|
100
|
+
pattern: new RegExp(`<meta\\s[^>]*property=["']?${name}["'\\s/>]`, "gi"),
|
|
101
|
+
})),
|
|
102
|
+
];
|
|
103
|
+
/**
|
|
104
|
+
* The tags that a page's `<head>` has more than once, of those `Head.astro`
|
|
105
|
+
* writes, named as `<title>` or `<meta property="og:title">` are. A layout
|
|
106
|
+
* that includes the component and writes one of them too has two.
|
|
107
|
+
*/
|
|
108
|
+
export function repeatedHeadTags(html) {
|
|
109
|
+
const head = /<head[\s>][\s\S]*?<\/head>/i.exec(html)?.[0];
|
|
110
|
+
if (head === undefined)
|
|
111
|
+
return [];
|
|
112
|
+
return ONCE.filter(({ pattern }) => (head.match(pattern) ?? []).length > 1).map(({ name }) => name);
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=head.js.map
|
package/dist/head.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"head.js","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAiCA;;;;;;;;;GASG;AACH,MAAM,UAAU,QAAQ,CAAC,MAAkB,EAAE,OAAoB;IAC/D,MAAM,IAAI,GAAc,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,CAAC,UAAkC,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,CAAC;IAC5F,MAAM,IAAI,GAAG,CAAC,UAAkC,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,CAAC;IAC5F,4DAA4D;IAC5D,MAAM,QAAQ,GAAG,CAAC,KAAa,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC;IAE7F,MAAM,KAAK,GACT,MAAM,CAAC,KAAK,KAAK,SAAS;QACxB,CAAC,CAAC,SAAS;QACX,CAAC,CAAC,OAAO,CAAC,KAAK,KAAK,SAAS;YAC3B,CAAC,CAAC,MAAM,CAAC,KAAK;YACd,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IAC5C,IAAI,KAAK,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAClE,IAAI,MAAM,CAAC,WAAW,KAAK,SAAS;QAAE,IAAI,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC;IACjG,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS;QAAE,IAAI,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC;IACvF,IAAI,MAAM,CAAC,OAAO,KAAK,UAAU;QAAE,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;IAChF,IAAI,MAAM,CAAC,OAAO,KAAK,UAAU;QAAE,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;IAClF,IAAI,MAAM,CAAC,QAAQ,KAAK,SAAS,IAAI,MAAM,CAAC,OAAO,KAAK,UAAU;QAChE,IAAI,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;IAE3E,IAAI,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;IAClD,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;QAAE,IAAI,CAAC,EAAE,QAAQ,EAAE,cAAc,EAAE,OAAO,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IAC1F,IAAI,KAAK,KAAK,SAAS;QAAE,IAAI,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;IACxE,IAAI,MAAM,CAAC,WAAW,KAAK,SAAS;QAClC,IAAI,CAAC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC;IACpE,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS;QAAE,IAAI,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC;IAC5F,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAC1B,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,IAAI,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACjF,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS;YAAE,IAAI,CAAC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAChG,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;YAC3B,IAAI,CAAC,EAAE,QAAQ,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACtE,IAAI,CAAC,EAAE,QAAQ,EAAE,cAAc,EAAE,OAAO,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IACxD,CAAC;IAED,6CAA6C;IAC7C,MAAM,KAAK,GAAG,MAAM,CAAC,WAAW,IAAI,EAAE,CAAC;IACvC,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;QACtB,IAAI,CAAC,IAAI,CAAC;YACR,GAAG,EAAE,QAAQ;YACb,IAAI,EAAE;gBACJ,UAAU,EAAE,oBAAoB;gBAChC,OAAO,EAAE,gBAAgB;gBACzB,eAAe,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;oBACxC,OAAO,EAAE,UAAU;oBACnB,QAAQ,EAAE,EAAE,GAAG,CAAC;oBAChB,IAAI,EAAE,IAAI,CAAC,KAAK;oBAChB,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC;iBAC3B,CAAC,CAAC;aACJ;SACF,CAAC,CAAC;IACL,CAAC;IACD,4DAA4D;IAC5D,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS,IAAI,MAAM,CAAC,KAAK,KAAK,GAAG,EAAE,CAAC;QACvD,IAAI,CAAC,IAAI,CAAC;YACR,GAAG,EAAE,QAAQ;YACb,IAAI,EAAE;gBACJ,UAAU,EAAE,oBAAoB;gBAChC,OAAO,EAAE,SAAS;gBAClB,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,GAAG,EAAE,QAAQ,CAAC,GAAG,CAAC;aACnB;SACF,CAAC,CAAC;IACL,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,IAAa;IACtC,OAAO,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;AACzD,CAAC;AAED,2FAA2F;AAC3F,MAAM,IAAI,GAAwC;IAChD,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,eAAe,EAAE;IAC7C,GAAG,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAC1C,IAAI,EAAE,eAAe,IAAI,IAAI;QAC7B,OAAO,EAAE,IAAI,MAAM,CAAC,0BAA0B,IAAI,WAAW,EAAE,IAAI,CAAC;KACrE,CAAC,CAAC;IACH,EAAE,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,0CAA0C,EAAE;IACvF,GAAG,CAAC,SAAS,EAAE,cAAc,EAAE,UAAU,EAAE,gBAAgB,EAAE,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACpF,IAAI,EAAE,mBAAmB,IAAI,IAAI;QACjC,OAAO,EAAE,IAAI,MAAM,CAAC,8BAA8B,IAAI,WAAW,EAAE,IAAI,CAAC;KACzE,CAAC,CAAC;CACJ,CAAC;AAEF;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,MAAM,IAAI,GAAG,6BAA6B,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC3D,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IAClC,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,GAAG,CAC7E,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CACnB,CAAC;AACJ,CAAC","sourcesContent":["// What a page's head says about the page, from its record: the tags\n// `Head.astro` writes, as data, so each can be tested against the record. And\n// the check `astro dev` makes that a layout doesn't write one of them again.\nimport type { PageRecord } from \"./records.js\";\n\n/** A page's social card: its image, what it shows, and its size in pixels. */\nexport interface Card {\n /** The image's address: absolute, or from the host's root, resolved against the site's address. */\n src: string;\n /** What the image shows, for a reader who can't see it. */\n alt: string;\n width?: number;\n height?: number;\n}\n\n/** What a site says about itself, which a record doesn't hold. */\nexport interface HeadOptions {\n /** The whole title, from the page's title and its record. Default: the page's title. */\n title?: ((title: string, record: PageRecord) => string) | undefined;\n /** The site's name, for `og:site_name` and, on the page at the host's root, the site-name structured data. */\n name?: string | undefined;\n /** The page's social card. Without one, no image tags. */\n card?: Card | undefined;\n /** The site's address (Astro's `site`, or the request's), for addresses the record doesn't make absolute. */\n address: string | URL;\n}\n\n/** One tag of the head. */\nexport type HeadTag =\n | { tag: \"title\"; text: string }\n | { tag: \"meta\" | \"link\"; attributes: Record<string, string> }\n | { tag: \"script\"; json: unknown };\n\n/**\n * The tags of a page's head that are about the page, in order: the title and\n * the description; the canonical link; the robots rule; the link to the\n * Markdown version, when the build publishes one and the page is offered; the\n * Open Graph tags a link preview reads, the card's among them when there is\n * one; the breadcrumbs, as structured data, when there are two steps or more;\n * and, on the page at the host's root, the site's name, as structured data.\n * The title, the description, and the address are the record's, so the head\n * can't disagree with the sitemap or `llms.txt`.\n */\nexport function headTags(record: PageRecord, options: HeadOptions): HeadTag[] {\n const tags: HeadTag[] = [];\n const meta = (attributes: Record<string, string>) => tags.push({ tag: \"meta\", attributes });\n const link = (attributes: Record<string, string>) => tags.push({ tag: \"link\", attributes });\n // A step's address is on the record's site when it has one.\n const absolute = (route: string) => new URL(route, record.canonical ?? options.address).href;\n\n const title =\n record.title === undefined\n ? undefined\n : options.title === undefined\n ? record.title\n : options.title(record.title, record);\n if (title !== undefined) tags.push({ tag: \"title\", text: title });\n if (record.description !== undefined) meta({ name: \"description\", content: record.description });\n if (record.canonical !== undefined) link({ rel: \"canonical\", href: record.canonical });\n if (record.listing === \"unlisted\") meta({ name: \"robots\", content: \"noindex\" });\n if (record.listing === \"unquoted\") meta({ name: \"robots\", content: \"nosnippet\" });\n if (record.markdown !== undefined && record.listing !== \"unlisted\")\n link({ rel: \"alternate\", type: \"text/markdown\", href: record.markdown });\n\n meta({ property: \"og:type\", content: \"website\" });\n if (options.name !== undefined) meta({ property: \"og:site_name\", content: options.name });\n if (title !== undefined) meta({ property: \"og:title\", content: title });\n if (record.description !== undefined)\n meta({ property: \"og:description\", content: record.description });\n if (record.canonical !== undefined) meta({ property: \"og:url\", content: record.canonical });\n const card = options.card;\n if (card !== undefined) {\n meta({ property: \"og:image\", content: new URL(card.src, options.address).href });\n if (card.width !== undefined) meta({ property: \"og:image:width\", content: String(card.width) });\n if (card.height !== undefined)\n meta({ property: \"og:image:height\", content: String(card.height) });\n meta({ property: \"og:image:alt\", content: card.alt });\n }\n\n // Google reads a trail of two steps or more.\n const steps = record.breadcrumbs ?? [];\n if (steps.length >= 2) {\n tags.push({\n tag: \"script\",\n json: {\n \"@context\": \"https://schema.org\",\n \"@type\": \"BreadcrumbList\",\n itemListElement: steps.map((step, at) => ({\n \"@type\": \"ListItem\",\n position: at + 1,\n name: step.title,\n item: absolute(step.route),\n })),\n },\n });\n }\n // The site's name, on the home page alone: the host's root.\n if (options.name !== undefined && record.route === \"/\") {\n tags.push({\n tag: \"script\",\n json: {\n \"@context\": \"https://schema.org\",\n \"@type\": \"WebSite\",\n name: options.name,\n url: absolute(\"/\"),\n },\n });\n }\n return tags;\n}\n\n/**\n * JSON for a `<script>` element: `<` written as `\\u003c`, so no string in it\n * can close the element.\n */\nexport function scriptJson(json: unknown): string {\n return JSON.stringify(json).replaceAll(\"<\", \"\\\\u003c\");\n}\n\n/** The tags `Head.astro` writes that a head should have once, each with how to find it. */\nconst ONCE: { name: string; pattern: RegExp }[] = [\n { name: \"<title>\", pattern: /<title[\\s>]/gi },\n ...[\"description\", \"robots\"].map((name) => ({\n name: `<meta name=\"${name}\">`,\n pattern: new RegExp(`<meta\\\\s[^>]*name=[\"']?${name}[\"'\\\\s/>]`, \"gi\"),\n })),\n { name: '<link rel=\"canonical\">', pattern: /<link\\s[^>]*rel=[\"']?canonical[\"'\\s/>]/gi },\n ...[\"og:type\", \"og:site_name\", \"og:title\", \"og:description\", \"og:url\"].map((name) => ({\n name: `<meta property=\"${name}\">`,\n pattern: new RegExp(`<meta\\\\s[^>]*property=[\"']?${name}[\"'\\\\s/>]`, \"gi\"),\n })),\n];\n\n/**\n * The tags that a page's `<head>` has more than once, of those `Head.astro`\n * writes, named as `<title>` or `<meta property=\"og:title\">` are. A layout\n * that includes the component and writes one of them too has two.\n */\nexport function repeatedHeadTags(html: string): string[] {\n const head = /<head[\\s>][\\s\\S]*?<\\/head>/i.exec(html)?.[0];\n if (head === undefined) return [];\n return ONCE.filter(({ pattern }) => (head.match(pattern) ?? []).length > 1).map(\n ({ name }) => name,\n );\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The headers a host sends with one path, matched exactly. */
|
|
2
|
+
export interface HeaderRule {
|
|
3
|
+
/** The path, from the host's root. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Each header's value, by its name. */
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
/** The rules in a build's site output, or `undefined` when it has no list. */
|
|
9
|
+
export declare function readHeaders(siteRoot: string): HeaderRule[] | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* The headers the rules in the build whose site output is at `siteRoot` give
|
|
12
|
+
* `pathname`, a path from the host's root: none when no rule names it. Read
|
|
13
|
+
* again when the file changes, as it does with each build in `astro dev`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function headersFor(siteRoot: string, pathname: string): Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Every project's rules as one list, sorted by path: a path two projects
|
|
18
|
+
* both have keeps the first's rule, as the first entry's files win.
|
|
19
|
+
*/
|
|
20
|
+
export declare function mergeRules(projects: readonly (readonly HeaderRule[])[]): HeaderRule[];
|
|
21
|
+
/** The rules in Netlify's `_headers` format: each path, then its headers indented below it. */
|
|
22
|
+
export declare function netlifyHeaders(rules: readonly HeaderRule[]): string;
|
|
23
|
+
/**
|
|
24
|
+
* The rules, each with only the headers the site's own `_headers` doesn't
|
|
25
|
+
* give its path, leaving out a rule it has whole. Empty when it has every
|
|
26
|
+
* line.
|
|
27
|
+
*/
|
|
28
|
+
export declare function missingRules(own: string, rules: readonly HeaderRule[]): HeaderRule[];
|
|
29
|
+
/** What the build does about the host's headers. */
|
|
30
|
+
export type HeadersOutcome = {
|
|
31
|
+
kind: "none";
|
|
32
|
+
} | {
|
|
33
|
+
kind: "write";
|
|
34
|
+
text: string;
|
|
35
|
+
} | {
|
|
36
|
+
kind: "note";
|
|
37
|
+
text: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "fail";
|
|
40
|
+
text: string;
|
|
41
|
+
};
|
|
42
|
+
/** What the build does about `_headers`, given every project's rules and the site. */
|
|
43
|
+
export declare function headersOutcome(sources: {
|
|
44
|
+
/** The rules of every project that sets `[consumer] headers = "netlify"`. */
|
|
45
|
+
rules: readonly HeaderRule[];
|
|
46
|
+
/** Astro's `base`. */
|
|
47
|
+
base: string;
|
|
48
|
+
/** The site's own `public/_headers`, when it has one. */
|
|
49
|
+
own: string | undefined;
|
|
50
|
+
}): HeadersOutcome;
|
package/dist/headers.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The response headers a build's files need from the host:
|
|
2
|
+
// `_ascribe/headers.json` in its site output (output-layout contract §1.4).
|
|
3
|
+
// A Markdown version has no head, so its page's rules are headers, which only
|
|
4
|
+
// the host sends. With `[consumer] headers = "netlify"`, `astro build` writes
|
|
5
|
+
// them into the site's `_headers`, Netlify's format; `astro dev` sends them
|
|
6
|
+
// with each Markdown version whatever the setting, so a site can be tried
|
|
7
|
+
// with no host.
|
|
8
|
+
//
|
|
9
|
+
// A site's own `public/_headers` is never rewritten: the build checks it has
|
|
10
|
+
// the lines, and fails with the lines to add when it doesn't.
|
|
11
|
+
import { readFileSync, statSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { normalizeBase } from "./project.js";
|
|
14
|
+
/** The format version of `_ascribe/headers.json` this reads. */
|
|
15
|
+
const VERSION = 1;
|
|
16
|
+
function headersFile(siteRoot) {
|
|
17
|
+
return path.join(siteRoot, "_ascribe", "headers.json");
|
|
18
|
+
}
|
|
19
|
+
/** The rules in a build's site output, or `undefined` when it has no list. */
|
|
20
|
+
export function readHeaders(siteRoot) {
|
|
21
|
+
const file = headersFile(siteRoot);
|
|
22
|
+
let text;
|
|
23
|
+
try {
|
|
24
|
+
text = readFileSync(file, "utf8");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const list = JSON.parse(text);
|
|
30
|
+
if (list.version !== VERSION) {
|
|
31
|
+
throw new Error(`@ascribed/astro: ${file} is version ${String(list.version)} of the headers list, and this version of the integration reads version ${VERSION}. Update @ascribed/astro and @ascribed/cli together.`);
|
|
32
|
+
}
|
|
33
|
+
return list.headers;
|
|
34
|
+
}
|
|
35
|
+
/** A build's rules by path, as last read, and when its file was written. */
|
|
36
|
+
const byPath = new Map();
|
|
37
|
+
/**
|
|
38
|
+
* The headers the rules in the build whose site output is at `siteRoot` give
|
|
39
|
+
* `pathname`, a path from the host's root: none when no rule names it. Read
|
|
40
|
+
* again when the file changes, as it does with each build in `astro dev`.
|
|
41
|
+
*/
|
|
42
|
+
export function headersFor(siteRoot, pathname) {
|
|
43
|
+
let written;
|
|
44
|
+
try {
|
|
45
|
+
written = statSync(headersFile(siteRoot)).mtimeMs;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
let known = byPath.get(siteRoot);
|
|
51
|
+
if (known === undefined || known.written !== written) {
|
|
52
|
+
const rules = new Map();
|
|
53
|
+
for (const rule of readHeaders(siteRoot) ?? []) {
|
|
54
|
+
if (!rules.has(rule.path))
|
|
55
|
+
rules.set(rule.path, rule.headers);
|
|
56
|
+
}
|
|
57
|
+
known = { written, rules };
|
|
58
|
+
byPath.set(siteRoot, known);
|
|
59
|
+
}
|
|
60
|
+
return known.rules.get(pathname) ?? {};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Every project's rules as one list, sorted by path: a path two projects
|
|
64
|
+
* both have keeps the first's rule, as the first entry's files win.
|
|
65
|
+
*/
|
|
66
|
+
export function mergeRules(projects) {
|
|
67
|
+
const rules = new Map();
|
|
68
|
+
for (const list of projects) {
|
|
69
|
+
for (const rule of list)
|
|
70
|
+
if (!rules.has(rule.path))
|
|
71
|
+
rules.set(rule.path, rule);
|
|
72
|
+
}
|
|
73
|
+
return [...rules.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
74
|
+
}
|
|
75
|
+
/** The rules in Netlify's `_headers` format: each path, then its headers indented below it. */
|
|
76
|
+
export function netlifyHeaders(rules) {
|
|
77
|
+
return rules
|
|
78
|
+
.map((rule) => `${rule.path}\n${Object.entries(rule.headers)
|
|
79
|
+
.map(([name, value]) => ` ${name}: ${value}\n`)
|
|
80
|
+
.join("")}`)
|
|
81
|
+
.join("");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The headers `_headers` gives each path exactly, as Netlify reads the file:
|
|
85
|
+
* a line that isn't indented is a path, an indented `Name: value` line is a
|
|
86
|
+
* header of the path above it, and a line starting with `#` is a comment.
|
|
87
|
+
* Names are compared in lower case, as Netlify compares them. A path with a
|
|
88
|
+
* wildcard or a placeholder is kept as written, and matches only itself here.
|
|
89
|
+
*/
|
|
90
|
+
function parseHeaders(text) {
|
|
91
|
+
const paths = new Map();
|
|
92
|
+
let current;
|
|
93
|
+
for (const line of text.split(/\r?\n/)) {
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
96
|
+
continue;
|
|
97
|
+
if (!/^\s/.test(line)) {
|
|
98
|
+
current = paths.get(trimmed) ?? new Set();
|
|
99
|
+
paths.set(trimmed, current);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const colon = trimmed.indexOf(":");
|
|
103
|
+
if (current === undefined || colon === -1)
|
|
104
|
+
continue;
|
|
105
|
+
current.add(headerKey(trimmed.slice(0, colon), trimmed.slice(colon + 1)));
|
|
106
|
+
}
|
|
107
|
+
return paths;
|
|
108
|
+
}
|
|
109
|
+
function headerKey(name, value) {
|
|
110
|
+
return `${name.trim().toLowerCase()}: ${value.trim()}`;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The rules, each with only the headers the site's own `_headers` doesn't
|
|
114
|
+
* give its path, leaving out a rule it has whole. Empty when it has every
|
|
115
|
+
* line.
|
|
116
|
+
*/
|
|
117
|
+
export function missingRules(own, rules) {
|
|
118
|
+
const has = parseHeaders(own);
|
|
119
|
+
const missing = [];
|
|
120
|
+
for (const rule of rules) {
|
|
121
|
+
const given = has.get(rule.path);
|
|
122
|
+
const headers = Object.fromEntries(Object.entries(rule.headers).filter(([name, value]) => !given?.has(headerKey(name, value))));
|
|
123
|
+
if (Object.keys(headers).length > 0)
|
|
124
|
+
missing.push({ path: rule.path, headers });
|
|
125
|
+
}
|
|
126
|
+
return missing;
|
|
127
|
+
}
|
|
128
|
+
/** What the build does about `_headers`, given every project's rules and the site. */
|
|
129
|
+
export function headersOutcome(sources) {
|
|
130
|
+
if (sources.rules.length === 0)
|
|
131
|
+
return { kind: "none" };
|
|
132
|
+
const text = netlifyHeaders(sources.rules);
|
|
133
|
+
const base = normalizeBase(sources.base);
|
|
134
|
+
if (base !== "/") {
|
|
135
|
+
return {
|
|
136
|
+
kind: "note",
|
|
137
|
+
text: `no _headers: Astro's \`base\` is "${base}", and Netlify reads _headers only at the root of what's deployed. Put these lines in the host's own:\n${text}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (sources.own !== undefined) {
|
|
141
|
+
const missing = missingRules(sources.own, sources.rules);
|
|
142
|
+
if (missing.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
kind: "note",
|
|
145
|
+
text: "no _headers: public/_headers is the site's own, and has Ascribe's lines.",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
kind: "fail",
|
|
150
|
+
text: `public/_headers is the site's own, and lacks the headers Ascribe's pages need. Add these lines to it:\n${netlifyHeaders(missing)}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { kind: "write", text };
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=headers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA,2DAA2D;AAC3D,4EAA4E;AAC5E,8EAA8E;AAC9E,8EAA8E;AAC9E,4EAA4E;AAC5E,0EAA0E;AAC1E,gBAAgB;AAChB,EAAE;AACF,6EAA6E;AAC7E,8DAA8D;AAC9D,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,gEAAgE;AAChE,MAAM,OAAO,GAAG,CAAC,CAAC;AAgBlB,SAAS,WAAW,CAAC,QAAgB;IACnC,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,UAAU,EAAE,cAAc,CAAC,CAAC;AACzD,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,WAAW,CAAC,QAAgB;IAC1C,MAAM,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;IACnC,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAe,CAAC;IAC5C,IAAI,IAAI,CAAC,OAAO,KAAK,OAAO,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CACb,oBAAoB,IAAI,eAAe,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,2EAA2E,OAAO,sDAAsD,CACpM,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC,OAAO,CAAC;AACtB,CAAC;AAED,4EAA4E;AAC5E,MAAM,MAAM,GAAG,IAAI,GAAG,EAA2E,CAAC;AAElG;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,QAAgB,EAAE,QAAgB;IAC3D,IAAI,OAAe,CAAC;IACpB,IAAI,CAAC;QACH,OAAO,GAAG,QAAQ,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC;IACpD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,IAAI,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACjC,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,OAAO,KAAK,OAAO,EAAE,CAAC;QACrD,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkC,CAAC;QACxD,KAAK,MAAM,IAAI,IAAI,WAAW,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC;YAC/C,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;gBAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QAChE,CAAC;QACD,KAAK,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QAC3B,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;AACzC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,QAA4C;IACrE,MAAM,KAAK,GAAG,IAAI,GAAG,EAAsB,CAAC;IAC5C,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,KAAK,MAAM,IAAI,IAAI,IAAI;YAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;gBAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACjF,CAAC;IACD,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAC9F,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,cAAc,CAAC,KAA4B;IACzD,OAAO,KAAK;SACT,GAAG,CACF,CAAC,IAAI,EAAE,EAAE,CACP,GAAG,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC;SAC1C,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,IAAI,KAAK,KAAK,IAAI,CAAC;SAC/C,IAAI,CAAC,EAAE,CAAC,EAAE,CAChB;SACA,IAAI,CAAC,EAAE,CAAC,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,SAAS,YAAY,CAAC,IAAY;IAChC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC7C,IAAI,OAAgC,CAAC;IACrC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QACvC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,OAAO,KAAK,EAAE,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QACxD,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YACtB,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC;YAC1C,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC5B,SAAS;QACX,CAAC;QACD,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACnC,IAAI,OAAO,KAAK,SAAS,IAAI,KAAK,KAAK,CAAC,CAAC;YAAE,SAAS;QACpD,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5E,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,SAAS,CAAC,IAAY,EAAE,KAAa;IAC5C,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,KAAK,CAAC,IAAI,EAAE,EAAE,CAAC;AACzD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW,EAAE,KAA4B;IACpE,MAAM,GAAG,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IAC9B,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjC,MAAM,OAAO,GAAG,MAAM,CAAC,WAAW,CAChC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,CAC5F,CAAC;QACF,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;IAClF,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AASD,sFAAsF;AACtF,MAAM,UAAU,cAAc,CAAC,OAO9B;IACC,IAAI,OAAO,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IACxD,MAAM,IAAI,GAAG,cAAc,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3C,MAAM,IAAI,GAAG,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;QACjB,OAAO;YACL,IAAI,EAAE,MAAM;YACZ,IAAI,EAAE,qCAAqC,IAAI,0GAA0G,IAAI,EAAE;SAChK,CAAC;IACJ,CAAC;IACD,IAAI,OAAO,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;QAC9B,MAAM,OAAO,GAAG,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;QACzD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACzB,OAAO;gBACL,IAAI,EAAE,MAAM;gBACZ,IAAI,EAAE,0EAA0E;aACjF,CAAC;QACJ,CAAC;QACD,OAAO;YACL,IAAI,EAAE,MAAM;YACZ,IAAI,EAAE,0GAA0G,cAAc,CAAC,OAAO,CAAC,EAAE;SAC1I,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AACjC,CAAC","sourcesContent":["// The response headers a build's files need from the host:\n// `_ascribe/headers.json` in its site output (output-layout contract §1.4).\n// A Markdown version has no head, so its page's rules are headers, which only\n// the host sends. With `[consumer] headers = \"netlify\"`, `astro build` writes\n// them into the site's `_headers`, Netlify's format; `astro dev` sends them\n// with each Markdown version whatever the setting, so a site can be tried\n// with no host.\n//\n// A site's own `public/_headers` is never rewritten: the build checks it has\n// the lines, and fails with the lines to add when it doesn't.\nimport { readFileSync, statSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { normalizeBase } from \"./project.js\";\n\n/** The format version of `_ascribe/headers.json` this reads. */\nconst VERSION = 1;\n\n/** The headers a host sends with one path, matched exactly. */\nexport interface HeaderRule {\n /** The path, from the host's root. */\n path: string;\n /** Each header's value, by its name. */\n headers: Record<string, string>;\n}\n\n/** `_ascribe/headers.json`. */\ninterface HeaderList {\n version: number;\n headers: HeaderRule[];\n}\n\nfunction headersFile(siteRoot: string): string {\n return path.join(siteRoot, \"_ascribe\", \"headers.json\");\n}\n\n/** The rules in a build's site output, or `undefined` when it has no list. */\nexport function readHeaders(siteRoot: string): HeaderRule[] | undefined {\n const file = headersFile(siteRoot);\n let text: string;\n try {\n text = readFileSync(file, \"utf8\");\n } catch {\n return undefined;\n }\n const list = JSON.parse(text) as HeaderList;\n if (list.version !== VERSION) {\n throw new Error(\n `@ascribed/astro: ${file} is version ${String(list.version)} of the headers list, and this version of the integration reads version ${VERSION}. Update @ascribed/astro and @ascribed/cli together.`,\n );\n }\n return list.headers;\n}\n\n/** A build's rules by path, as last read, and when its file was written. */\nconst byPath = new Map<string, { written: number; rules: Map<string, Record<string, string>> }>();\n\n/**\n * The headers the rules in the build whose site output is at `siteRoot` give\n * `pathname`, a path from the host's root: none when no rule names it. Read\n * again when the file changes, as it does with each build in `astro dev`.\n */\nexport function headersFor(siteRoot: string, pathname: string): Record<string, string> {\n let written: number;\n try {\n written = statSync(headersFile(siteRoot)).mtimeMs;\n } catch {\n return {};\n }\n let known = byPath.get(siteRoot);\n if (known === undefined || known.written !== written) {\n const rules = new Map<string, Record<string, string>>();\n for (const rule of readHeaders(siteRoot) ?? []) {\n if (!rules.has(rule.path)) rules.set(rule.path, rule.headers);\n }\n known = { written, rules };\n byPath.set(siteRoot, known);\n }\n return known.rules.get(pathname) ?? {};\n}\n\n/**\n * Every project's rules as one list, sorted by path: a path two projects\n * both have keeps the first's rule, as the first entry's files win.\n */\nexport function mergeRules(projects: readonly (readonly HeaderRule[])[]): HeaderRule[] {\n const rules = new Map<string, HeaderRule>();\n for (const list of projects) {\n for (const rule of list) if (!rules.has(rule.path)) rules.set(rule.path, rule);\n }\n return [...rules.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));\n}\n\n/** The rules in Netlify's `_headers` format: each path, then its headers indented below it. */\nexport function netlifyHeaders(rules: readonly HeaderRule[]): string {\n return rules\n .map(\n (rule) =>\n `${rule.path}\\n${Object.entries(rule.headers)\n .map(([name, value]) => ` ${name}: ${value}\\n`)\n .join(\"\")}`,\n )\n .join(\"\");\n}\n\n/**\n * The headers `_headers` gives each path exactly, as Netlify reads the file:\n * a line that isn't indented is a path, an indented `Name: value` line is a\n * header of the path above it, and a line starting with `#` is a comment.\n * Names are compared in lower case, as Netlify compares them. A path with a\n * wildcard or a placeholder is kept as written, and matches only itself here.\n */\nfunction parseHeaders(text: string): Map<string, Set<string>> {\n const paths = new Map<string, Set<string>>();\n let current: Set<string> | undefined;\n for (const line of text.split(/\\r?\\n/)) {\n const trimmed = line.trim();\n if (trimmed === \"\" || trimmed.startsWith(\"#\")) continue;\n if (!/^\\s/.test(line)) {\n current = paths.get(trimmed) ?? new Set();\n paths.set(trimmed, current);\n continue;\n }\n const colon = trimmed.indexOf(\":\");\n if (current === undefined || colon === -1) continue;\n current.add(headerKey(trimmed.slice(0, colon), trimmed.slice(colon + 1)));\n }\n return paths;\n}\n\nfunction headerKey(name: string, value: string): string {\n return `${name.trim().toLowerCase()}: ${value.trim()}`;\n}\n\n/**\n * The rules, each with only the headers the site's own `_headers` doesn't\n * give its path, leaving out a rule it has whole. Empty when it has every\n * line.\n */\nexport function missingRules(own: string, rules: readonly HeaderRule[]): HeaderRule[] {\n const has = parseHeaders(own);\n const missing: HeaderRule[] = [];\n for (const rule of rules) {\n const given = has.get(rule.path);\n const headers = Object.fromEntries(\n Object.entries(rule.headers).filter(([name, value]) => !given?.has(headerKey(name, value))),\n );\n if (Object.keys(headers).length > 0) missing.push({ path: rule.path, headers });\n }\n return missing;\n}\n\n/** What the build does about the host's headers. */\nexport type HeadersOutcome =\n | { kind: \"none\" }\n | { kind: \"write\"; text: string }\n | { kind: \"note\"; text: string }\n | { kind: \"fail\"; text: string };\n\n/** What the build does about `_headers`, given every project's rules and the site. */\nexport function headersOutcome(sources: {\n /** The rules of every project that sets `[consumer] headers = \"netlify\"`. */\n rules: readonly HeaderRule[];\n /** Astro's `base`. */\n base: string;\n /** The site's own `public/_headers`, when it has one. */\n own: string | undefined;\n}): HeadersOutcome {\n if (sources.rules.length === 0) return { kind: \"none\" };\n const text = netlifyHeaders(sources.rules);\n const base = normalizeBase(sources.base);\n if (base !== \"/\") {\n return {\n kind: \"note\",\n text: `no _headers: Astro's \\`base\\` is \"${base}\", and Netlify reads _headers only at the root of what's deployed. Put these lines in the host's own:\\n${text}`,\n };\n }\n if (sources.own !== undefined) {\n const missing = missingRules(sources.own, sources.rules);\n if (missing.length === 0) {\n return {\n kind: \"note\",\n text: \"no _headers: public/_headers is the site's own, and has Ascribe's lines.\",\n };\n }\n return {\n kind: \"fail\",\n text: `public/_headers is the site's own, and lacks the headers Ascribe's pages need. Add these lines to it:\\n${netlifyHeaders(missing)}`,\n };\n }\n return { kind: \"write\", text };\n}\n"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -30,6 +30,15 @@ interface SharedOptions {
|
|
|
30
30
|
* `<figure class="code-title">` with a `<figcaption>`. Default `true`.
|
|
31
31
|
*/
|
|
32
32
|
codeTitles?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The site's one sitemap, at `<base>sitemap.xml`, of every listed page of
|
|
35
|
+
* every project and every page of the site's own. `exclude` lists routes,
|
|
36
|
+
* from the host's root, of the site's own pages to leave out of it, such
|
|
37
|
+
* as one that carries `noindex`.
|
|
38
|
+
*/
|
|
39
|
+
sitemap?: {
|
|
40
|
+
exclude?: string[];
|
|
41
|
+
};
|
|
33
42
|
}
|
|
34
43
|
/** Options of the integration with one project. */
|
|
35
44
|
export interface OneProjectOptions extends SharedOptions {
|
|
@@ -51,6 +60,8 @@ export type AscribeOptions = OneProjectOptions | ProjectsOptions;
|
|
|
51
60
|
/**
|
|
52
61
|
* Runs `ascribe build` for each project, checks the site's routing, adds the markdown plugin, adds a
|
|
53
62
|
* page for each permalink's address, and serves published files, and, with `[consumer] agents = true`,
|
|
54
|
-
* `llms.txt` and each page's Markdown, each at its project's base path
|
|
63
|
+
* `llms.txt` and each page's Markdown, each at its project's base path; and writes the site's one
|
|
64
|
+
* sitemap and `robots.txt` from every project's page records, and, with `[consumer] headers =
|
|
65
|
+
* "netlify"`, its `_headers` from their headers lists.
|
|
55
66
|
*/
|
|
56
67
|
export default function ascribe(options: AscribeOptions): AstroIntegration;
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// // astro.config.mjs
|
|
5
5
|
// import ascribe from "@ascribed/astro";
|
|
6
6
|
// export default defineConfig({
|
|
7
|
-
// base
|
|
7
|
+
// // `site`, `base`, and `trailingSlash` come from ascribe.toml's
|
|
8
|
+
// // [consumer] when they're left out here; set here, they must agree.
|
|
8
9
|
// integrations: [ascribe({ build: "site" })],
|
|
9
10
|
// });
|
|
10
11
|
//
|
|
@@ -19,6 +20,11 @@
|
|
|
19
20
|
// // a layout
|
|
20
21
|
// import Elements from "@ascribed/astro/Elements.astro"; // <Elements /> in <head>
|
|
21
22
|
// import Availability from "@ascribed/astro/Availability.astro"; // the page's availability
|
|
23
|
+
// import Head from "@ascribed/astro/Head.astro"; // <Head entry={entry} /> in <head>
|
|
24
|
+
//
|
|
25
|
+
// // the sidebar: src/nav.ts's list, checked against the collection
|
|
26
|
+
// import { sidebar } from "@ascribed/astro/content";
|
|
27
|
+
// const groups = sidebar(nav, await getCollection("docs"));
|
|
22
28
|
//
|
|
23
29
|
// // src/pages/404.astro
|
|
24
30
|
// import MissingPermalink from "@ascribed/astro/MissingPermalink.astro"; // a permalink that isn't here
|
|
@@ -33,22 +39,27 @@
|
|
|
33
39
|
// review: "docs",
|
|
34
40
|
// })
|
|
35
41
|
// ascribeCollection({ schema, name: "docs" })
|
|
36
|
-
import { mkdirSync } from "node:fs";
|
|
42
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
43
|
+
import { writeFile } from "node:fs/promises";
|
|
37
44
|
import path from "node:path";
|
|
38
45
|
import { fileURLToPath } from "node:url";
|
|
39
46
|
import { findBinary } from "./binary.js";
|
|
40
47
|
import { codeTitles } from "./code-titles.js";
|
|
41
48
|
import { SeenFiles, watchDev } from "./dev.js";
|
|
42
49
|
import { agentsMiddleware, copyAgentFiles, copyPublishedFiles, filesMiddleware, folderUnder, } from "./files.js";
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
50
|
+
import { WARN as HEAD_WARN } from "./head-check.js";
|
|
51
|
+
import { headersFor, headersOutcome, mergeRules, readHeaders } from "./headers.js";
|
|
52
|
+
import { forwardingRoutes, hasPermalinks, permalinkPattern, readPermalinks } from "./permalinks.js";
|
|
53
|
+
import { consumerMismatches, homeRoute, normalizeBase, readProject, supplyRouting, } from "./project.js";
|
|
45
54
|
import { basePathClashes, nameProblems, pagesUnderOtherPaths, permalinkProblems, } from "./projects.js";
|
|
55
|
+
import { pageList, readRecords, routeAs, routeKey, sitemapRoute, } from "./records.js";
|
|
46
56
|
import rehypeAscribeAttributes from "./rehype.js";
|
|
47
57
|
import { removeDevFile, siteUrl, writeDevFile } from "./review/devfile.js";
|
|
48
58
|
import { runDiff, runPrompt } from "./review/diff.js";
|
|
49
59
|
import { APP_ID } from "./review/protocol.js";
|
|
50
60
|
import { readRoutes } from "./review/routes.js";
|
|
51
61
|
import { channelProblem, ReviewServer } from "./review/server.js";
|
|
62
|
+
import { devRouted, devSitePages, rootFiles, rootFilesMiddleware, } from "./root-files.js";
|
|
52
63
|
import { anchorsFor, hasWarnings, reviewFor, runBuild } from "./run.js";
|
|
53
64
|
import { satteriAscribeAttributes } from "./satteri.js";
|
|
54
65
|
import { copySchema } from "./schema.js";
|
|
@@ -65,7 +76,9 @@ function fail(problems, what) {
|
|
|
65
76
|
/**
|
|
66
77
|
* Runs `ascribe build` for each project, checks the site's routing, adds the markdown plugin, adds a
|
|
67
78
|
* page for each permalink's address, and serves published files, and, with `[consumer] agents = true`,
|
|
68
|
-
* `llms.txt` and each page's Markdown, each at its project's base path
|
|
79
|
+
* `llms.txt` and each page's Markdown, each at its project's base path; and writes the site's one
|
|
80
|
+
* sitemap and `robots.txt` from every project's page records, and, with `[consumer] headers =
|
|
81
|
+
* "netlify"`, its `_headers` from their headers lists.
|
|
69
82
|
*/
|
|
70
83
|
export default function ascribe(options) {
|
|
71
84
|
/** With `projects`; otherwise the one entry has no name a collection gives. */
|
|
@@ -80,11 +93,47 @@ export default function ascribe(options) {
|
|
|
80
93
|
let devCommand = false;
|
|
81
94
|
let devHttps = false;
|
|
82
95
|
let review;
|
|
96
|
+
/** The site's routes, as Astro resolved them, for what `astro dev` serves of the site's own. */
|
|
97
|
+
let astroRoutes = [];
|
|
98
|
+
/** The site's `public/` folder, where its own `robots.txt` would be. */
|
|
99
|
+
let publicDir = "";
|
|
100
|
+
/** What the sitemap and `robots.txt` are drawn from, given the site's pages and its routes. */
|
|
101
|
+
const rootSources = (sitePages, routed) => {
|
|
102
|
+
const base = routing?.base ?? "/";
|
|
103
|
+
const records = entries.map((entry) => readRecords(entry.siteRoot));
|
|
104
|
+
// Entries of one project share its `ascribe.toml`, and so what it says to crawlers.
|
|
105
|
+
const robots = new Map();
|
|
106
|
+
for (const [i, entry] of entries.entries()) {
|
|
107
|
+
if (!robots.has(entry.project.dir))
|
|
108
|
+
robots.set(entry.project.dir, {
|
|
109
|
+
name: entry.name,
|
|
110
|
+
robots: records[i]?.robots ?? { lines: [] },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const root = normalizeBase(base);
|
|
114
|
+
return {
|
|
115
|
+
projects: records.map(pageList),
|
|
116
|
+
routed,
|
|
117
|
+
sitePages,
|
|
118
|
+
excluded: [
|
|
119
|
+
...forwardingRoutes(entries.map((entry) => readPermalinks(entry.siteRoot))),
|
|
120
|
+
`${root}404`,
|
|
121
|
+
`${root}500`,
|
|
122
|
+
...(options.sitemap?.exclude ?? []),
|
|
123
|
+
],
|
|
124
|
+
robots: [...robots.values()],
|
|
125
|
+
site: routing?.site,
|
|
126
|
+
base,
|
|
127
|
+
ownRobots: existsSync(path.join(publicDir, "robots.txt")),
|
|
128
|
+
ownSitemap: existsSync(path.join(publicDir, "sitemap.xml")),
|
|
129
|
+
};
|
|
130
|
+
};
|
|
83
131
|
return {
|
|
84
132
|
name: "@ascribed/astro",
|
|
85
133
|
hooks: {
|
|
86
|
-
"astro:config:setup": async ({ config, command, logger, updateConfig, addDevToolbarApp, createCodegenDir, injectRoute, }) => {
|
|
134
|
+
"astro:config:setup": async ({ config, command, logger, updateConfig, addDevToolbarApp, createCodegenDir, injectRoute, addMiddleware, }) => {
|
|
87
135
|
const root = fileURLToPath(config.root);
|
|
136
|
+
publicDir = fileURLToPath(config.publicDir);
|
|
88
137
|
const list = options.projects ?? [
|
|
89
138
|
{
|
|
90
139
|
name: "",
|
|
@@ -107,8 +156,11 @@ export default function ascribe(options) {
|
|
|
107
156
|
schemaDir: named ? path.join(codegen, entry.name) : codegen,
|
|
108
157
|
};
|
|
109
158
|
});
|
|
159
|
+
// What astro.config leaves unset comes from [consumer]: `site` and `trailingSlash` from the
|
|
160
|
+
// first project that has them, and with one project, `base` from its base path. With
|
|
161
|
+
// `projects`, `base` stays the site's, the root every base path is under.
|
|
162
|
+
const { routing: astro, supplied } = supplyRouting(entries.map((entry) => entry.project), { base: config.base, trailingSlash: config.trailingSlash, site: config.site }, !named);
|
|
110
163
|
// `ascribe.toml`'s routing must be Astro's, or every link Ascribe writes is wrong.
|
|
111
|
-
const astro = { base: config.base, trailingSlash: config.trailingSlash, site: config.site };
|
|
112
164
|
for (const entry of entries) {
|
|
113
165
|
fail(consumerMismatches(entry.project, astro), named
|
|
114
166
|
? `"${entry.name}"'s ascribe.toml and astro.config disagree`
|
|
@@ -210,21 +262,30 @@ export default function ascribe(options) {
|
|
|
210
262
|
await buildEntry(entry);
|
|
211
263
|
checkShared();
|
|
212
264
|
devCommand = command === "dev";
|
|
265
|
+
if (devCommand) {
|
|
266
|
+
// A tag of the head written twice, by Head.astro and the layout, is warned about.
|
|
267
|
+
globalThis[HEAD_WARN] = (message) => logger.warn(message);
|
|
268
|
+
addMiddleware({
|
|
269
|
+
entrypoint: new URL("./head-check.js", import.meta.url),
|
|
270
|
+
order: "post",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
213
273
|
// Each permalink's address, a page that forwards to its heading. One route serves every
|
|
214
274
|
// list, at the path of the first that names anything.
|
|
215
275
|
const lists = entries.flatMap((entry) => readPermalinks(entry.siteRoot) ?? []);
|
|
216
276
|
const served = lists.find(hasPermalinks) ?? lists[0];
|
|
217
277
|
if (served !== undefined) {
|
|
218
278
|
injectRoute({
|
|
219
|
-
pattern: permalinkPattern(served.path,
|
|
279
|
+
pattern: permalinkPattern(served.path, astro.base),
|
|
220
280
|
entrypoint: new URL("../src/Permalink.astro", import.meta.url),
|
|
221
281
|
prerender: true,
|
|
222
282
|
});
|
|
223
283
|
}
|
|
224
284
|
}
|
|
225
|
-
const projectHome = (entry) => homeRoute(entry.project.consumer.basePath,
|
|
285
|
+
const projectHome = (entry) => homeRoute(entry.project.consumer.basePath, astro.trailingSlash);
|
|
226
286
|
// Dev serving of `_ascribe/files/`, and of the plain output for agents; the build copies them in `astro:build:done`.
|
|
227
287
|
updateConfig({
|
|
288
|
+
...supplied,
|
|
228
289
|
...(options.codeTitles === false
|
|
229
290
|
? {}
|
|
230
291
|
: { markdown: { shikiConfig: { transformers: [codeTitles] } } }),
|
|
@@ -237,9 +298,15 @@ export default function ascribe(options) {
|
|
|
237
298
|
resolveId: (id) => (id === SITE_MODULE ? `\0${SITE_MODULE}` : undefined),
|
|
238
299
|
load: (id) => id === `\0${SITE_MODULE}`
|
|
239
300
|
? `export const siteRoot = ${JSON.stringify(entries[0]?.siteRoot ?? "")};\n` +
|
|
240
|
-
`export const
|
|
301
|
+
`export const astroRoot = ${JSON.stringify(root)};\n` +
|
|
302
|
+
`export const home = ${JSON.stringify(homeRoute(astro.base, astro.trailingSlash))};\n` +
|
|
241
303
|
`export const collections = ${JSON.stringify(named ? Object.fromEntries(entries.map((e) => [e.name, e.siteRoot])) : {})};\n` +
|
|
242
|
-
`export const permalinks = ${JSON.stringify(entries.map((e) => ({ siteRoot: e.siteRoot, home: projectHome(e) })))}
|
|
304
|
+
`export const permalinks = ${JSON.stringify(entries.map((e) => ({ siteRoot: e.siteRoot, home: projectHome(e) })))};\n` +
|
|
305
|
+
`export const routes = ${JSON.stringify(entries.map((e) => ({
|
|
306
|
+
siteRoot: e.siteRoot,
|
|
307
|
+
basePath: e.project.consumer.basePath,
|
|
308
|
+
trailingSlash: e.project.consumer.trailingSlash,
|
|
309
|
+
})))};`
|
|
243
310
|
: undefined,
|
|
244
311
|
},
|
|
245
312
|
{
|
|
@@ -248,15 +315,22 @@ export default function ascribe(options) {
|
|
|
248
315
|
for (const entry of entries) {
|
|
249
316
|
const base = entry.project.consumer.basePath;
|
|
250
317
|
server.middlewares.use(filesMiddleware(entry.siteRoot, base));
|
|
251
|
-
if (entry.agentsRoot !== undefined)
|
|
252
|
-
|
|
318
|
+
if (entry.agentsRoot !== undefined) {
|
|
319
|
+
const siteRoot = entry.siteRoot;
|
|
320
|
+
server.middlewares.use(agentsMiddleware(entry.agentsRoot, base, (pathname) => headersFor(siteRoot, pathname)));
|
|
321
|
+
}
|
|
253
322
|
}
|
|
323
|
+
// The sitemap and robots.txt, as the build would write them now.
|
|
324
|
+
server.middlewares.use(rootFilesMiddleware(astro.base, () => rootFiles(rootSources(devSitePages(astroRoutes, astro), devRouted(astroRoutes, astro)))));
|
|
254
325
|
},
|
|
255
326
|
},
|
|
256
327
|
],
|
|
257
328
|
},
|
|
258
329
|
});
|
|
259
330
|
},
|
|
331
|
+
"astro:routes:resolved": ({ routes }) => {
|
|
332
|
+
astroRoutes = routes;
|
|
333
|
+
},
|
|
260
334
|
"astro:server:setup": ({ server, refreshContent, logger, toolbar }) => {
|
|
261
335
|
if (!devCommand || !routing)
|
|
262
336
|
return;
|
|
@@ -331,8 +405,44 @@ export default function ascribe(options) {
|
|
|
331
405
|
for (const group of groups)
|
|
332
406
|
removeDevFile(group.project.dir, process.pid);
|
|
333
407
|
},
|
|
334
|
-
"astro:build:done": async ({ dir, logger }) => {
|
|
408
|
+
"astro:build:done": async ({ dir, pages, logger }) => {
|
|
335
409
|
const base = routing?.base ?? "/";
|
|
410
|
+
const trailingSlash = routing?.trailingSlash ?? "ignore";
|
|
411
|
+
// Every page Astro built, by its route from the host's root. Astro lists
|
|
412
|
+
// pages only, not endpoints such as a feed, so a dot in a page's last
|
|
413
|
+
// segment, as in `/v1.2`, is still a page.
|
|
414
|
+
const built = pages.map((page) => routeAs(`${normalizeBase(base)}${page.pathname.replace(/^\//, "")}`, trailingSlash));
|
|
415
|
+
const builtKeys = new Set(built.map(routeKey));
|
|
416
|
+
const files = rootFiles(rootSources(built, (route) => builtKeys.has(routeKey(route))));
|
|
417
|
+
fail(files.problems, "the site's robots.txt");
|
|
418
|
+
for (const note of files.notes)
|
|
419
|
+
logger[note.level](note.text);
|
|
420
|
+
const out = fileURLToPath(dir);
|
|
421
|
+
if (files.sitemap !== undefined) {
|
|
422
|
+
await writeFile(path.join(out, "sitemap.xml"), files.sitemap);
|
|
423
|
+
logger.info(`wrote ${sitemapRoute(base)}`);
|
|
424
|
+
}
|
|
425
|
+
if (files.robots !== undefined) {
|
|
426
|
+
await writeFile(path.join(out, "robots.txt"), files.robots);
|
|
427
|
+
logger.info("wrote /robots.txt");
|
|
428
|
+
}
|
|
429
|
+
// The host's headers, from the projects that name its format.
|
|
430
|
+
const ownHeaders = path.join(publicDir, "_headers");
|
|
431
|
+
const headers = headersOutcome({
|
|
432
|
+
rules: mergeRules(entries
|
|
433
|
+
.filter((entry) => entry.project.consumer.headers === "netlify")
|
|
434
|
+
.map((entry) => readHeaders(entry.siteRoot) ?? [])),
|
|
435
|
+
base,
|
|
436
|
+
own: existsSync(ownHeaders) ? readFileSync(ownHeaders, "utf8") : undefined,
|
|
437
|
+
});
|
|
438
|
+
if (headers.kind === "fail")
|
|
439
|
+
throw new Error(`@ascribed/astro: ${headers.text}`);
|
|
440
|
+
if (headers.kind === "note")
|
|
441
|
+
logger.info(headers.text);
|
|
442
|
+
if (headers.kind === "write") {
|
|
443
|
+
await writeFile(path.join(out, "_headers"), headers.text);
|
|
444
|
+
logger.info("wrote /_headers");
|
|
445
|
+
}
|
|
336
446
|
// Entries of one project publish at one base path: the first entry's copies go last, so they win.
|
|
337
447
|
for (const entry of [...entries].reverse()) {
|
|
338
448
|
const folder = folderUnder(entry.project.consumer.basePath, base);
|