@ascribed/astro 0.2.1-next.9 → 0.3.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/README.md +41 -9
- 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 +30 -0
- package/dist/content.js +101 -2
- package/dist/content.js.map +1 -1
- package/dist/dev.d.ts +2 -1
- package/dist/dev.js +13 -3
- package/dist/dev.js.map +1 -1
- package/dist/files.d.ts +21 -4
- package/dist/files.js +46 -8
- 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 +40 -9
- package/dist/index.js +330 -95
- 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/names.d.ts +5 -0
- package/dist/names.js +5 -0
- package/dist/names.js.map +1 -1
- 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 +78 -0
- package/dist/permalinks.js +114 -0
- package/dist/permalinks.js.map +1 -0
- package/dist/project.d.ts +37 -0
- package/dist/project.js +47 -2
- package/dist/project.js.map +1 -1
- package/dist/projects.d.ts +38 -0
- package/dist/projects.js +127 -0
- package/dist/projects.js.map +1 -0
- package/dist/records.d.ts +136 -0
- package/dist/records.js +182 -0
- package/dist/records.js.map +1 -0
- package/dist/review/devfile.d.ts +6 -1
- package/dist/review/devfile.js +12 -2
- package/dist/review/devfile.js.map +1 -1
- package/dist/review/diff.d.ts +13 -0
- package/dist/review/diff.js +34 -1
- package/dist/review/diff.js.map +1 -1
- package/dist/review/protocol.d.ts +6 -1
- package/dist/review/protocol.js.map +1 -1
- package/dist/review/routes.d.ts +3 -0
- package/dist/review/routes.js +49 -35
- package/dist/review/routes.js.map +1 -1
- package/dist/review/server.d.ts +15 -0
- package/dist/review/server.js +58 -7
- package/dist/review/server.js.map +1 -1
- 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.d.ts +7 -6
- package/dist/run.js +14 -6
- package/dist/run.js.map +1 -1
- package/dist/shapes.d.ts +8 -1
- package/dist/shapes.js.map +1 -1
- package/dist/toolbar/app.js +98 -0
- package/dist/toolbar/app.js.map +1 -1
- package/dist/toolbar/style.d.ts +1 -1
- package/dist/toolbar/style.js +31 -23
- package/dist/toolbar/style.js.map +1 -1
- package/package.json +32 -9
- package/src/Head.astro +58 -0
- package/src/MissingPermalink.astro +59 -0
- package/src/Permalink.astro +78 -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/files.js
CHANGED
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
// Serving `_ascribe/files/`: the files pages link to that aren't pages or
|
|
2
2
|
// images. Astro doesn't copy them, so the integration
|
|
3
3
|
// does: into the build's output directory, and in dev through a middleware.
|
|
4
|
-
// Both put them at `<base>_ascribe/files/`, the URL the site output links
|
|
4
|
+
// Both put them at `<base-path>_ascribe/files/`, the URL the site output links
|
|
5
|
+
// to, where `<base-path>` is the project's `[consumer] base-path`: Astro's
|
|
6
|
+
// `base` or a path under it.
|
|
7
|
+
//
|
|
8
|
+
// With `[consumer] agents = true`, the same for the plain output, which is
|
|
9
|
+
// laid out by URL: `llms.txt`, each page's Markdown, and their files, all at
|
|
10
|
+
// `<base-path>` and below.
|
|
5
11
|
import { createReadStream } from "node:fs";
|
|
6
12
|
import { cp, stat } from "node:fs/promises";
|
|
7
13
|
import path from "node:path";
|
|
8
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { normalizeBase } from "./project.js";
|
|
9
16
|
/** The directory of published files inside a site output root. */
|
|
10
17
|
function publishedDir(siteRoot) {
|
|
11
18
|
return path.join(siteRoot, "_ascribe", "files");
|
|
12
19
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The base path's folder in Astro's build output, which is at Astro's `base`:
|
|
22
|
+
* `docs/` for a base path of `/docs/` under a `base` of `/`, `""` when they're the same.
|
|
23
|
+
*/
|
|
24
|
+
export function folderUnder(basePath, astroBase) {
|
|
25
|
+
const root = normalizeBase(astroBase);
|
|
26
|
+
return basePath.startsWith(root) ? basePath.slice(root.length) : "";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Copies the published files to `<outDir>/<folder>_ascribe/files/`, `folder` being the
|
|
30
|
+
* base path's (`folderUnder`). Returns whether there were any.
|
|
31
|
+
*/
|
|
32
|
+
export async function copyPublishedFiles(siteRoot, outDir, folder = "") {
|
|
33
|
+
return copyDirectory(publishedDir(siteRoot), path.join(fileURLToPath(outDir), folder, "_ascribe", "files"));
|
|
34
|
+
}
|
|
35
|
+
/** Copies the plain output for agents to the base path's folder of the build output. Returns whether there was one. */
|
|
36
|
+
export async function copyAgentFiles(plainRoot, outDir, folder = "") {
|
|
37
|
+
return copyDirectory(plainRoot, path.join(fileURLToPath(outDir), folder));
|
|
38
|
+
}
|
|
39
|
+
async function copyDirectory(from, to) {
|
|
16
40
|
if (!(await stat(from).then((s) => s.isDirectory(), () => false)))
|
|
17
41
|
return false;
|
|
18
|
-
await cp(from,
|
|
42
|
+
await cp(from, to, { recursive: true });
|
|
19
43
|
return true;
|
|
20
44
|
}
|
|
21
45
|
const TYPES = {
|
|
@@ -37,12 +61,24 @@ const TYPES = {
|
|
|
37
61
|
".webp": "image/webp",
|
|
38
62
|
".svg": "image/svg+xml",
|
|
39
63
|
};
|
|
40
|
-
/** A dev-server middleware serving the published files at `<base>_ascribe/files
|
|
64
|
+
/** A dev-server middleware serving the published files at `<base>_ascribe/files/`, `base` being the project's base path. */
|
|
41
65
|
export function filesMiddleware(siteRoot, base) {
|
|
42
|
-
|
|
66
|
+
return directoryMiddleware(publishedDir(siteRoot), `${base}_ascribe/files/`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A dev-server middleware serving the plain output for agents at `<base>`: `llms.txt`, each page's
|
|
70
|
+
* `.md`, and their files, each with the headers `headers` gives its path from the host's root.
|
|
71
|
+
*/
|
|
72
|
+
export function agentsMiddleware(plainRoot, base, headers = () => ({})) {
|
|
73
|
+
return directoryMiddleware(plainRoot, base, headers);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A middleware serving the files in `root` at the URL path `prefix`, which ends in `/`, each with
|
|
77
|
+
* the headers `headers` gives its path.
|
|
78
|
+
*/
|
|
79
|
+
function directoryMiddleware(root, prefix, headers = () => ({})) {
|
|
43
80
|
// Only under the base path, as in the build. Vite strips the base from
|
|
44
81
|
// `req.url` before a plugin's middleware runs, but connect keeps the original in `originalUrl`.
|
|
45
|
-
const prefix = `${base}_ascribe/files/`;
|
|
46
82
|
return (req, res, next) => {
|
|
47
83
|
const pathname = (req.originalUrl ?? req.url ?? "").split(/[?#]/, 1)[0] ?? "";
|
|
48
84
|
if (!pathname.startsWith(prefix) || (req.method !== "GET" && req.method !== "HEAD"))
|
|
@@ -62,6 +98,8 @@ export function filesMiddleware(siteRoot, base) {
|
|
|
62
98
|
return next();
|
|
63
99
|
res.setHeader("Content-Type", TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream");
|
|
64
100
|
res.setHeader("Content-Length", info.size);
|
|
101
|
+
for (const [name, value] of Object.entries(headers(pathname)))
|
|
102
|
+
res.setHeader(name, value);
|
|
65
103
|
if (req.method === "HEAD")
|
|
66
104
|
return void res.end();
|
|
67
105
|
createReadStream(file).pipe(res);
|
package/dist/files.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,sDAAsD;AACtD,4EAA4E;AAC5E,
|
|
1
|
+
{"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"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","sourcesContent":["// Serving `_ascribe/files/`: the files pages link to that aren't pages or\n// images. Astro doesn't copy them, so the integration\n// does: into the build's output directory, and in dev through a middleware.\n// Both put them at `<base-path>_ascribe/files/`, the URL the site output links\n// to, where `<base-path>` is the project's `[consumer] base-path`: Astro's\n// `base` or a path under it.\n//\n// With `[consumer] agents = true`, the same for the plain output, which is\n// laid out by URL: `llms.txt`, each page's Markdown, and their files, all at\n// `<base-path>` and below.\nimport { createReadStream } from \"node:fs\";\nimport { cp, stat } from \"node:fs/promises\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { normalizeBase } from \"./project.js\";\n\n/** The directory of published files inside a site output root. */\nfunction publishedDir(siteRoot: string): string {\n return path.join(siteRoot, \"_ascribe\", \"files\");\n}\n\n/**\n * The base path's folder in Astro's build output, which is at Astro's `base`:\n * `docs/` for a base path of `/docs/` under a `base` of `/`, `\"\"` when they're the same.\n */\nexport function folderUnder(basePath: string, astroBase: string): string {\n const root = normalizeBase(astroBase);\n return basePath.startsWith(root) ? basePath.slice(root.length) : \"\";\n}\n\n/**\n * Copies the published files to `<outDir>/<folder>_ascribe/files/`, `folder` being the\n * base path's (`folderUnder`). Returns whether there were any.\n */\nexport async function copyPublishedFiles(\n siteRoot: string,\n outDir: URL,\n folder = \"\",\n): Promise<boolean> {\n return copyDirectory(\n publishedDir(siteRoot),\n path.join(fileURLToPath(outDir), folder, \"_ascribe\", \"files\"),\n );\n}\n\n/** Copies the plain output for agents to the base path's folder of the build output. Returns whether there was one. */\nexport async function copyAgentFiles(\n plainRoot: string,\n outDir: URL,\n folder = \"\",\n): Promise<boolean> {\n return copyDirectory(plainRoot, path.join(fileURLToPath(outDir), folder));\n}\n\nasync function copyDirectory(from: string, to: string): Promise<boolean> {\n if (\n !(await stat(from).then(\n (s) => s.isDirectory(),\n () => false,\n ))\n )\n return false;\n await cp(from, to, { recursive: true });\n return true;\n}\n\nconst TYPES: Record<string, string> = {\n \".pdf\": \"application/pdf\",\n \".zip\": \"application/zip\",\n \".json\": \"application/json\",\n \".yaml\": \"text/yaml; charset=utf-8\",\n \".yml\": \"text/yaml; charset=utf-8\",\n \".txt\": \"text/plain; charset=utf-8\",\n \".csv\": \"text/csv; charset=utf-8\",\n \".md\": \"text/markdown; charset=utf-8\",\n \".html\": \"text/html; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".gif\": \"image/gif\",\n \".webp\": \"image/webp\",\n \".svg\": \"image/svg+xml\",\n};\n\ntype Middleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void;\n\n/** A dev-server middleware serving the published files at `<base>_ascribe/files/`, `base` being the project's base path. */\nexport function filesMiddleware(siteRoot: string, base: string): Middleware {\n return directoryMiddleware(publishedDir(siteRoot), `${base}_ascribe/files/`);\n}\n\n/**\n * A dev-server middleware serving the plain output for agents at `<base>`: `llms.txt`, each page's\n * `.md`, and their files, each with the headers `headers` gives its path from the host's root.\n */\nexport function agentsMiddleware(\n plainRoot: string,\n base: string,\n headers: (pathname: string) => Record<string, string> = () => ({}),\n): Middleware {\n return directoryMiddleware(plainRoot, base, headers);\n}\n\n/**\n * A middleware serving the files in `root` at the URL path `prefix`, which ends in `/`, each with\n * the headers `headers` gives its path.\n */\nfunction directoryMiddleware(\n root: string,\n prefix: string,\n headers: (pathname: string) => Record<string, string> = () => ({}),\n): Middleware {\n // Only under the base path, as in the build. Vite strips the base from\n // `req.url` before a plugin's middleware runs, but connect keeps the original in `originalUrl`.\n return (req, res, next) => {\n const pathname =\n ((req as { originalUrl?: string }).originalUrl ?? req.url ?? \"\").split(/[?#]/, 1)[0] ?? \"\";\n if (!pathname.startsWith(prefix) || (req.method !== \"GET\" && req.method !== \"HEAD\"))\n return next();\n let relative: string;\n try {\n relative = decodeURIComponent(pathname.slice(prefix.length));\n } catch {\n return next();\n }\n const file = path.resolve(root, relative);\n if (file !== root && !file.startsWith(root + path.sep)) return next();\n stat(file).then(\n (info) => {\n if (!info.isFile()) return next();\n res.setHeader(\n \"Content-Type\",\n TYPES[path.extname(file).toLowerCase()] ?? \"application/octet-stream\",\n );\n res.setHeader(\"Content-Length\", info.size);\n for (const [name, value] of Object.entries(headers(pathname))) res.setHeader(name, value);\n if (req.method === \"HEAD\") return void res.end();\n createReadStream(file).pipe(res);\n },\n () => next(),\n );\n };\n}\n"]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "astro";
|
|
2
|
+
/** Where the integration leaves its logger's `warn` for this middleware, which Vite loads apart from it. */
|
|
3
|
+
export declare const WARN: unique symbol;
|
|
4
|
+
/** The warnings for `html`, the page at `route`: one per repeated tag not yet warned about. */
|
|
5
|
+
export declare function headWarnings(html: string, route: string, seen?: Set<string>): string[];
|
|
6
|
+
export declare const onRequest: MiddlewareHandler;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { repeatedHeadTags } from "./head.js";
|
|
2
|
+
/** Where the integration leaves its logger's `warn` for this middleware, which Vite loads apart from it. */
|
|
3
|
+
export const WARN = Symbol.for("@ascribed/astro:head-warn");
|
|
4
|
+
/** The tags already warned about. */
|
|
5
|
+
const warned = new Set();
|
|
6
|
+
/** The warnings for `html`, the page at `route`: one per repeated tag not yet warned about. */
|
|
7
|
+
export function headWarnings(html, route, seen = warned) {
|
|
8
|
+
return repeatedHeadTags(html)
|
|
9
|
+
.filter((tag) => {
|
|
10
|
+
if (seen.has(tag))
|
|
11
|
+
return false;
|
|
12
|
+
seen.add(tag);
|
|
13
|
+
return true;
|
|
14
|
+
})
|
|
15
|
+
.map((tag) => `${route}'s head has ${tag} twice. Head.astro writes it from the page's record, so the layout doesn't need to.`);
|
|
16
|
+
}
|
|
17
|
+
export const onRequest = async (context, next) => {
|
|
18
|
+
const response = await next();
|
|
19
|
+
if (!(response.headers.get("content-type") ?? "").startsWith("text/html"))
|
|
20
|
+
return response;
|
|
21
|
+
const html = await response.text();
|
|
22
|
+
const warn = globalThis[WARN] ??
|
|
23
|
+
((message) => console.warn(`[@ascribed/astro] ${message}`));
|
|
24
|
+
for (const message of headWarnings(html, context.url.pathname))
|
|
25
|
+
warn(message);
|
|
26
|
+
return new Response(html, response);
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=head-check.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"head-check.js","sourceRoot":"","sources":["../src/head-check.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE7C,4GAA4G;AAC5G,MAAM,CAAC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAC;AAE5D,qCAAqC;AACrC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAU,CAAC;AAEjC,+FAA+F;AAC/F,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,KAAa,EAAE,IAAI,GAAgB,MAAM;IAClF,OAAO,gBAAgB,CAAC,IAAI,CAAC;SAC1B,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE;QACd,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,OAAO,KAAK,CAAC;QAChC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,OAAO,IAAI,CAAC;IACd,CAAC,CAAC;SACD,GAAG,CACF,CAAC,GAAG,EAAE,EAAE,CACN,GAAG,KAAK,eAAe,GAAG,qFAAqF,CAClH,CAAC;AACN,CAAC;AAED,MAAM,CAAC,MAAM,SAAS,GAAsB,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;IAClE,MAAM,QAAQ,GAAG,MAAM,IAAI,EAAE,CAAC;IAC9B,IAAI,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC;QAAE,OAAO,QAAQ,CAAC;IAC3F,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IACnC,MAAM,IAAI,GACN,UAAsC,CAAC,IAAI,CAA6C;QAC1F,CAAC,CAAC,OAAe,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,qBAAqB,OAAO,EAAE,CAAC,CAAC,CAAC;IACtE,KAAK,MAAM,OAAO,IAAI,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IAC9E,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AACtC,CAAC,CAAC","sourcesContent":["// `astro dev`'s check of each page's head: a tag `Head.astro` writes that the\n// head has twice, because the layout writes it too, is warned about once,\n// naming the tag. Not an error: a site may have a reason. The integration adds\n// this middleware in `astro dev` only.\nimport type { MiddlewareHandler } from \"astro\";\nimport { repeatedHeadTags } from \"./head.js\";\n\n/** Where the integration leaves its logger's `warn` for this middleware, which Vite loads apart from it. */\nexport const WARN = Symbol.for(\"@ascribed/astro:head-warn\");\n\n/** The tags already warned about. */\nconst warned = new Set<string>();\n\n/** The warnings for `html`, the page at `route`: one per repeated tag not yet warned about. */\nexport function headWarnings(html: string, route: string, seen: Set<string> = warned): string[] {\n return repeatedHeadTags(html)\n .filter((tag) => {\n if (seen.has(tag)) return false;\n seen.add(tag);\n return true;\n })\n .map(\n (tag) =>\n `${route}'s head has ${tag} twice. Head.astro writes it from the page's record, so the layout doesn't need to.`,\n );\n}\n\nexport const onRequest: MiddlewareHandler = async (context, next) => {\n const response = await next();\n if (!(response.headers.get(\"content-type\") ?? \"\").startsWith(\"text/html\")) return response;\n const html = await response.text();\n const warn =\n ((globalThis as Record<symbol, unknown>)[WARN] as ((message: string) => void) | undefined) ??\n ((message: string) => console.warn(`[@ascribed/astro] ${message}`));\n for (const message of headWarnings(html, context.url.pathname)) warn(message);\n return new Response(html, response);\n};\n"]}
|
package/dist/head.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PageRecord } from "./records.js";
|
|
2
|
+
/** A page's social card: its image, what it shows, and its size in pixels. */
|
|
3
|
+
export interface Card {
|
|
4
|
+
/** The image's address: absolute, or from the host's root, resolved against the site's address. */
|
|
5
|
+
src: string;
|
|
6
|
+
/** What the image shows, for a reader who can't see it. */
|
|
7
|
+
alt: string;
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
}
|
|
11
|
+
/** What a site says about itself, which a record doesn't hold. */
|
|
12
|
+
export interface HeadOptions {
|
|
13
|
+
/** The whole title, from the page's title and its record. Default: the page's title. */
|
|
14
|
+
title?: ((title: string, record: PageRecord) => string) | undefined;
|
|
15
|
+
/** The site's name, for `og:site_name` and, on the page at the host's root, the site-name structured data. */
|
|
16
|
+
name?: string | undefined;
|
|
17
|
+
/** The page's social card. Without one, no image tags. */
|
|
18
|
+
card?: Card | undefined;
|
|
19
|
+
/** The site's address (Astro's `site`, or the request's), for addresses the record doesn't make absolute. */
|
|
20
|
+
address: string | URL;
|
|
21
|
+
}
|
|
22
|
+
/** One tag of the head. */
|
|
23
|
+
export type HeadTag = {
|
|
24
|
+
tag: "title";
|
|
25
|
+
text: string;
|
|
26
|
+
} | {
|
|
27
|
+
tag: "meta" | "link";
|
|
28
|
+
attributes: Record<string, string>;
|
|
29
|
+
} | {
|
|
30
|
+
tag: "script";
|
|
31
|
+
json: unknown;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The tags of a page's head that are about the page, in order: the title and
|
|
35
|
+
* the description; the canonical link; the robots rule; the link to the
|
|
36
|
+
* Markdown version, when the build publishes one and the page is offered; the
|
|
37
|
+
* Open Graph tags a link preview reads, the card's among them when there is
|
|
38
|
+
* one; the breadcrumbs, as structured data, when there are two steps or more;
|
|
39
|
+
* and, on the page at the host's root, the site's name, as structured data.
|
|
40
|
+
* The title, the description, and the address are the record's, so the head
|
|
41
|
+
* can't disagree with the sitemap or `llms.txt`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function headTags(record: PageRecord, options: HeadOptions): HeadTag[];
|
|
44
|
+
/**
|
|
45
|
+
* JSON for a `<script>` element: `<` written as `\u003c`, so no string in it
|
|
46
|
+
* can close the element.
|
|
47
|
+
*/
|
|
48
|
+
export declare function scriptJson(json: unknown): string;
|
|
49
|
+
/**
|
|
50
|
+
* The tags that a page's `<head>` has more than once, of those `Head.astro`
|
|
51
|
+
* writes, named as `<title>` or `<meta property="og:title">` are. A layout
|
|
52
|
+
* that includes the component and writes one of them too has two.
|
|
53
|
+
*/
|
|
54
|
+
export declare function repeatedHeadTags(html: string): string[];
|
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":"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","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"]}
|