@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.
Files changed (51) hide show
  1. package/README.md +21 -7
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +62 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/content.d.ts +27 -0
  6. package/dist/content.js +71 -1
  7. package/dist/content.js.map +1 -1
  8. package/dist/files.d.ts +5 -2
  9. package/dist/files.js +13 -5
  10. package/dist/files.js.map +1 -1
  11. package/dist/head-check.d.ts +6 -0
  12. package/dist/head-check.js +28 -0
  13. package/dist/head-check.js.map +1 -0
  14. package/dist/head.d.ts +54 -0
  15. package/dist/head.js +114 -0
  16. package/dist/head.js.map +1 -0
  17. package/dist/headers.d.ts +50 -0
  18. package/dist/headers.js +155 -0
  19. package/dist/headers.js.map +1 -0
  20. package/dist/index.d.ts +12 -1
  21. package/dist/index.js +124 -14
  22. package/dist/index.js.map +1 -1
  23. package/dist/init.d.ts +41 -0
  24. package/dist/init.js +130 -0
  25. package/dist/init.js.map +1 -0
  26. package/dist/nav.d.ts +51 -0
  27. package/dist/nav.js +97 -0
  28. package/dist/nav.js.map +1 -0
  29. package/dist/permalinks.d.ts +6 -0
  30. package/dist/permalinks.js +13 -0
  31. package/dist/permalinks.js.map +1 -1
  32. package/dist/project.d.ts +26 -0
  33. package/dist/project.js +32 -0
  34. package/dist/project.js.map +1 -1
  35. package/dist/projects.d.ts +0 -6
  36. package/dist/projects.js +1 -14
  37. package/dist/projects.js.map +1 -1
  38. package/dist/records.d.ts +136 -0
  39. package/dist/records.js +182 -0
  40. package/dist/records.js.map +1 -0
  41. package/dist/root-files.d.ts +68 -0
  42. package/dist/root-files.js +103 -0
  43. package/dist/root-files.js.map +1 -0
  44. package/dist/run.js +3 -2
  45. package/dist/run.js.map +1 -1
  46. package/package.json +12 -6
  47. package/src/Head.astro +58 -0
  48. package/starter/src/content.config.ts +8 -0
  49. package/starter/src/layouts/Docs.astro +87 -0
  50. package/starter/src/nav.ts +13 -0
  51. 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
@@ -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;
@@ -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":"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","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: "/docs", trailingSlash: "never", // as in ascribe.toml's [consumer]
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 { hasPermalinks, permalinkPattern, readPermalinks } from "./permalinks.js";
44
- import { consumerMismatches, homeRoute, readProject } from "./project.js";
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, config.base),
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, config.trailingSlash);
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 home = ${JSON.stringify(homeRoute(config.base, config.trailingSlash))};\n` +
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
- server.middlewares.use(agentsMiddleware(entry.agentsRoot, base));
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);