@ascribed/astro 0.4.2 → 0.4.4-next.32

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.
@@ -0,0 +1,165 @@
1
+ // The redirects a build writes for the host: with `[consumer] host =
2
+ // "netlify"`, the compiler writes Netlify's `_redirects` in each build's site
3
+ // output, at `_ascribe/host/_redirects` (output-layout contract §1.9), from
4
+ // the moves `ascribe.addresses` records. `astro build` merges every
5
+ // project's into the site's `_redirects`, at the root of what's deployed.
6
+ //
7
+ // A site's own `public/_redirects` is kept, after Ascribe's rules: Netlify
8
+ // uses the first rule that matches, so a page's recorded move wins over a
9
+ // pattern of the site's.
10
+ import { readFileSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { normalizeBase } from "./project.js";
13
+ import { routeKey } from "./records.js";
14
+ /**
15
+ * The redirects in the host's `_redirects` the compiler wrote in a build's
16
+ * site output, in its order: none when it has none, as without `[consumer]
17
+ * host`.
18
+ */
19
+ export function readHostRedirects(siteRoot) {
20
+ let text;
21
+ try {
22
+ text = readFileSync(path.join(siteRoot, "_ascribe", "host", "_redirects"), "utf8");
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ const out = [];
28
+ for (const line of text.split(/\r?\n/)) {
29
+ const trimmed = line.trim();
30
+ if (trimmed === "" || trimmed.startsWith("#"))
31
+ continue;
32
+ const [from, to, status] = trimmed.split(/\s+/);
33
+ if (from === undefined || to === undefined)
34
+ continue;
35
+ out.push({ from, to, status: status === undefined ? 301 : Number.parseInt(status, 10) });
36
+ }
37
+ return out;
38
+ }
39
+ /**
40
+ * Every project's redirects as one list, in the order given: an address two
41
+ * projects both redirect keeps the first's, as the first entry's files win.
42
+ */
43
+ export function mergeRedirects(projects) {
44
+ const seen = new Set();
45
+ const out = [];
46
+ for (const list of projects) {
47
+ for (const redirect of list) {
48
+ if (seen.has(redirect.from))
49
+ continue;
50
+ seen.add(redirect.from);
51
+ out.push(redirect);
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+ /** The redirects in Netlify's `_redirects` format: each on a line, `from`, `to`, and the status. */
57
+ export function netlifyRedirects(redirects) {
58
+ return redirects.map((r) => `${r.from} ${r.to} ${String(r.status)}\n`).join("");
59
+ }
60
+ /** What the build does about `_redirects`, given every project's redirects and the site. */
61
+ export function redirectsOutcome(sources) {
62
+ if (sources.redirects.length === 0)
63
+ return { kind: "none" };
64
+ const text = netlifyRedirects(sources.redirects);
65
+ const base = normalizeBase(sources.base);
66
+ if (base !== "/") {
67
+ return {
68
+ kind: "note",
69
+ text: `no _redirects: Astro's \`base\` is "${base}", and Netlify reads _redirects only at the root of what's deployed. Put these lines in the host's own:\n${text}`,
70
+ };
71
+ }
72
+ const taken = sources.redirects.filter((r) => sources.published(routeKey(r.from)));
73
+ if (taken.length > 0) {
74
+ return {
75
+ kind: "fail",
76
+ text: `a page the site publishes is at an address ascribe.addresses forwards somewhere else, so its redirect would hide the page or never be used. Remove the entry, or move the page:\n${netlifyRedirects(taken)}`,
77
+ };
78
+ }
79
+ if (sources.own === undefined)
80
+ return { kind: "write", text };
81
+ const own = sources.own.endsWith("\n") || sources.own === "" ? sources.own : `${sources.own}\n`;
82
+ return {
83
+ kind: "write",
84
+ text: `${text}\n# The site's own, from public/_redirects: after Ascribe's, since the first rule that matches is used.\n${own}`,
85
+ };
86
+ }
87
+ /** The folder a retired address's page is in, under its build's base path. */
88
+ const GONE_FOLDER = "/_ascribe/gone/";
89
+ /** The format version of `_ascribe/redirects.json` this reads. */
90
+ const REDIRECTS_VERSION = 1;
91
+ /** The retired addresses a build's site output lists: none when it has no `_ascribe/redirects.json`. */
92
+ function readGone(siteRoot) {
93
+ const file = path.join(siteRoot, "_ascribe", "redirects.json");
94
+ let text;
95
+ try {
96
+ text = readFileSync(file, "utf8");
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ const list = JSON.parse(text);
102
+ if (list.version !== REDIRECTS_VERSION) {
103
+ throw new Error(`@ascribed/astro: ${file} is version ${String(list.version)} of the redirects list, and this version of the integration reads version ${String(REDIRECTS_VERSION)}. Update @ascribed/astro and @ascribed/cli together.`);
104
+ }
105
+ return list.gone ?? [];
106
+ }
107
+ /**
108
+ * Each retired address's page the site outputs list, by its route from the
109
+ * host's root, with its folder (its build's base path and `_ascribe/gone/`),
110
+ * its address from there, and what it says. A page two projects both list
111
+ * keeps the first's.
112
+ */
113
+ function gonePages(sources) {
114
+ const pages = new Map();
115
+ for (const { siteRoot, home } of sources) {
116
+ for (const gone of readGone(siteRoot)) {
117
+ const at = gone.page.indexOf(GONE_FOLDER);
118
+ if (at < 0)
119
+ continue;
120
+ const folder = gone.page.slice(0, at + GONE_FOLDER.length);
121
+ const address = gone.page.slice(folder.length).replace(/\/+$/, "");
122
+ if (address === "")
123
+ continue;
124
+ const route = `${folder}${address}`;
125
+ if (!pages.has(route))
126
+ pages.set(route, { folder, address, props: { reason: gone.reason, home } });
127
+ }
128
+ }
129
+ return pages;
130
+ }
131
+ /**
132
+ * The route pattern for the pages in `folder`, as Astro takes it: under
133
+ * `base`, which Astro adds. Throws when the folder isn't under `base`, since
134
+ * an Astro site serves nothing outside it.
135
+ */
136
+ function gonePattern(folder, base) {
137
+ const root = normalizeBase(base);
138
+ if (!folder.startsWith(root)) {
139
+ throw new Error(`@ascribed/astro: retired addresses' pages are published at "${folder}", which isn't under Astro's \`base\`, "${base}", and an Astro site serves nothing outside it. Set [consumer] base-path in ascribe.toml to a path under "${root}".`);
140
+ }
141
+ return `/${folder.slice(root.length)}[...address]`;
142
+ }
143
+ /**
144
+ * The route patterns the retired addresses' pages need, one for each
145
+ * folder that has any, under `base`.
146
+ */
147
+ export function gonePatterns(siteRoots, base) {
148
+ const pages = gonePages(siteRoots.map((siteRoot) => ({ siteRoot, home: "/" })));
149
+ return [...new Set([...pages.values()].map(({ folder }) => gonePattern(folder, base)))];
150
+ }
151
+ /**
152
+ * `getStaticPaths` for the route at `routePattern`, one of `gonePatterns`:
153
+ * one page for each retired address's page in its folder, with the reason
154
+ * and the route of its project's root.
155
+ */
156
+ export function gonePaths(sources, base, routePattern) {
157
+ return [...gonePages(sources).values()]
158
+ .filter(({ folder }) => gonePattern(folder, base) === routePattern)
159
+ .map(({ address, props }) => ({ params: { address }, props }));
160
+ }
161
+ /** The routes of the retired addresses' pages the site outputs at `siteRoots` list, from the host's root. */
162
+ export function goneRoutes(siteRoots) {
163
+ return [...gonePages(siteRoots.map((siteRoot) => ({ siteRoot, home: "/" }))).keys()];
164
+ }
165
+ //# sourceMappingURL=redirects.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirects.js","sourceRoot":"","sources":["../src/redirects.ts"],"names":[],"mappings":"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","sourcesContent":["// The redirects a build writes for the host: with `[consumer] host =\n// \"netlify\"`, the compiler writes Netlify's `_redirects` in each build's site\n// output, at `_ascribe/host/_redirects` (output-layout contract §1.9), from\n// the moves `ascribe.addresses` records. `astro build` merges every\n// project's into the site's `_redirects`, at the root of what's deployed.\n//\n// A site's own `public/_redirects` is kept, after Ascribe's rules: Netlify\n// uses the first rule that matches, so a page's recorded move wins over a\n// pattern of the site's.\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { normalizeBase } from \"./project.js\";\nimport { routeKey } from \"./records.js\";\n\n/** One redirect: an old address, from the host's root, to where it leads now. */\nexport interface Redirect {\n from: string;\n to: string;\n status: number;\n}\n\n/**\n * The redirects in the host's `_redirects` the compiler wrote in a build's\n * site output, in its order: none when it has none, as without `[consumer]\n * host`.\n */\nexport function readHostRedirects(siteRoot: string): Redirect[] {\n let text: string;\n try {\n text = readFileSync(path.join(siteRoot, \"_ascribe\", \"host\", \"_redirects\"), \"utf8\");\n } catch {\n return [];\n }\n const out: Redirect[] = [];\n for (const line of text.split(/\\r?\\n/)) {\n const trimmed = line.trim();\n if (trimmed === \"\" || trimmed.startsWith(\"#\")) continue;\n const [from, to, status] = trimmed.split(/\\s+/);\n if (from === undefined || to === undefined) continue;\n out.push({ from, to, status: status === undefined ? 301 : Number.parseInt(status, 10) });\n }\n return out;\n}\n\n/**\n * Every project's redirects as one list, in the order given: an address two\n * projects both redirect keeps the first's, as the first entry's files win.\n */\nexport function mergeRedirects(projects: readonly (readonly Redirect[])[]): Redirect[] {\n const seen = new Set<string>();\n const out: Redirect[] = [];\n for (const list of projects) {\n for (const redirect of list) {\n if (seen.has(redirect.from)) continue;\n seen.add(redirect.from);\n out.push(redirect);\n }\n }\n return out;\n}\n\n/** The redirects in Netlify's `_redirects` format: each on a line, `from`, `to`, and the status. */\nexport function netlifyRedirects(redirects: readonly Redirect[]): string {\n return redirects.map((r) => `${r.from} ${r.to} ${String(r.status)}\\n`).join(\"\");\n}\n\n/** What the build does about the host's redirects. */\nexport type RedirectsOutcome =\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 `_redirects`, given every project's redirects and the site. */\nexport function redirectsOutcome(sources: {\n /** The redirects of every project that sets `[consumer] host = \"netlify\"`, merged. */\n redirects: readonly Redirect[];\n /** Astro's `base`. */\n base: string;\n /** Whether a route is a page the site publishes, which a redirect mustn't hide. */\n published: (route: string) => boolean;\n /** The site's own `public/_redirects`, when it has one. */\n own: string | undefined;\n}): RedirectsOutcome {\n if (sources.redirects.length === 0) return { kind: \"none\" };\n const text = netlifyRedirects(sources.redirects);\n const base = normalizeBase(sources.base);\n if (base !== \"/\") {\n return {\n kind: \"note\",\n text: `no _redirects: Astro's \\`base\\` is \"${base}\", and Netlify reads _redirects only at the root of what's deployed. Put these lines in the host's own:\\n${text}`,\n };\n }\n const taken = sources.redirects.filter((r) => sources.published(routeKey(r.from)));\n if (taken.length > 0) {\n return {\n kind: \"fail\",\n text: `a page the site publishes is at an address ascribe.addresses forwards somewhere else, so its redirect would hide the page or never be used. Remove the entry, or move the page:\\n${netlifyRedirects(taken)}`,\n };\n }\n if (sources.own === undefined) return { kind: \"write\", text };\n const own = sources.own.endsWith(\"\\n\") || sources.own === \"\" ? sources.own : `${sources.own}\\n`;\n return {\n kind: \"write\",\n text: `${text}\\n# The site's own, from public/_redirects: after Ascribe's, since the first rule that matches is used.\\n${own}`,\n };\n}\n\n/** The folder a retired address's page is in, under its build's base path. */\nconst GONE_FOLDER = \"/_ascribe/gone/\";\n\n/** The format version of `_ascribe/redirects.json` this reads. */\nconst REDIRECTS_VERSION = 1;\n\n/** A retired address, as a build's `_ascribe/redirects.json` lists it (output-layout contract §1.8). */\ninterface Gone {\n /** The address, from the host's root. */\n path: string;\n /** Why it was retired, as its authors wrote it. */\n reason: string;\n /** The `ascribe.addresses` key it comes from. */\n entry: string;\n /** Where its page is, from the host's root: the base path, `_ascribe/gone/`, and the address from there. */\n page: string;\n}\n\n/** What a retired address's page says: why, and where the docs' home is. */\nexport interface GonePage {\n reason: string;\n home: string;\n}\n\n/** The retired addresses a build's site output lists: none when it has no `_ascribe/redirects.json`. */\nfunction readGone(siteRoot: string): Gone[] {\n const file = path.join(siteRoot, \"_ascribe\", \"redirects.json\");\n let text: string;\n try {\n text = readFileSync(file, \"utf8\");\n } catch {\n return [];\n }\n const list = JSON.parse(text) as { version: number; gone?: Gone[] };\n if (list.version !== REDIRECTS_VERSION) {\n throw new Error(\n `@ascribed/astro: ${file} is version ${String(list.version)} of the redirects list, and this version of the integration reads version ${String(REDIRECTS_VERSION)}. Update @ascribed/astro and @ascribed/cli together.`,\n );\n }\n return list.gone ?? [];\n}\n\n/**\n * Each retired address's page the site outputs list, by its route from the\n * host's root, with its folder (its build's base path and `_ascribe/gone/`),\n * its address from there, and what it says. A page two projects both list\n * keeps the first's.\n */\nfunction gonePages(\n sources: readonly { siteRoot: string; home: string }[],\n): Map<string, { folder: string; address: string; props: GonePage }> {\n const pages = new Map<string, { folder: string; address: string; props: GonePage }>();\n for (const { siteRoot, home } of sources) {\n for (const gone of readGone(siteRoot)) {\n const at = gone.page.indexOf(GONE_FOLDER);\n if (at < 0) continue;\n const folder = gone.page.slice(0, at + GONE_FOLDER.length);\n const address = gone.page.slice(folder.length).replace(/\\/+$/, \"\");\n if (address === \"\") continue;\n const route = `${folder}${address}`;\n if (!pages.has(route))\n pages.set(route, { folder, address, props: { reason: gone.reason, home } });\n }\n }\n return pages;\n}\n\n/**\n * The route pattern for the pages in `folder`, as Astro takes it: under\n * `base`, which Astro adds. Throws when the folder isn't under `base`, since\n * an Astro site serves nothing outside it.\n */\nfunction gonePattern(folder: string, base: string): string {\n const root = normalizeBase(base);\n if (!folder.startsWith(root)) {\n throw new Error(\n `@ascribed/astro: retired addresses' pages are published at \"${folder}\", which isn't under Astro's \\`base\\`, \"${base}\", and an Astro site serves nothing outside it. Set [consumer] base-path in ascribe.toml to a path under \"${root}\".`,\n );\n }\n return `/${folder.slice(root.length)}[...address]`;\n}\n\n/**\n * The route patterns the retired addresses' pages need, one for each\n * folder that has any, under `base`.\n */\nexport function gonePatterns(siteRoots: readonly string[], base: string): string[] {\n const pages = gonePages(siteRoots.map((siteRoot) => ({ siteRoot, home: \"/\" })));\n return [...new Set([...pages.values()].map(({ folder }) => gonePattern(folder, base)))];\n}\n\n/**\n * `getStaticPaths` for the route at `routePattern`, one of `gonePatterns`:\n * one page for each retired address's page in its folder, with the reason\n * and the route of its project's root.\n */\nexport function gonePaths(\n sources: readonly { siteRoot: string; home: string }[],\n base: string,\n routePattern: string,\n): { params: { address: string }; props: GonePage }[] {\n return [...gonePages(sources).values()]\n .filter(({ folder }) => gonePattern(folder, base) === routePattern)\n .map(({ address, props }) => ({ params: { address }, props }));\n}\n\n/** The routes of the retired addresses' pages the site outputs at `siteRoots` list, from the host's root. */\nexport function goneRoutes(siteRoots: readonly string[]): string[] {\n return [...gonePages(siteRoots.map((siteRoot) => ({ siteRoot, home: \"/\" }))).keys()];\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascribed/astro",
3
- "version": "0.4.2",
3
+ "version": "0.4.4-next.32",
4
4
  "description": "A toolchain for documentation written as code. This package is its Astro integration, which publishes a project's pages as a site.",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -30,6 +30,7 @@
30
30
  "src/Elements.astro",
31
31
  "src/Availability.astro",
32
32
  "src/Permalink.astro",
33
+ "src/Gone.astro",
33
34
  "src/MissingPermalink.astro",
34
35
  "src/Head.astro",
35
36
  "src/Versions.astro",
@@ -61,15 +62,19 @@
61
62
  "./permalinks": {
62
63
  "types": "./dist/permalinks.d.ts",
63
64
  "default": "./dist/permalinks.js"
65
+ },
66
+ "./redirects": {
67
+ "types": "./dist/redirects.d.ts",
68
+ "default": "./dist/redirects.js"
64
69
  }
65
70
  },
66
71
  "engines": {
67
72
  "node": ">=24"
68
73
  },
69
74
  "dependencies": {
70
- "@ascribed/cli": "0.4.2",
71
- "@ascribed/elements": "0.4.2",
72
- "@ascribed/review": "0.4.2",
75
+ "@ascribed/cli": "0.4.4-next.32",
76
+ "@ascribed/elements": "0.4.4-next.32",
77
+ "@ascribed/review": "0.4.4-next.32",
73
78
  "github-slugger": "^2.0.0",
74
79
  "property-information": "^7.2.0",
75
80
  "smol-toml": "^1.9.1"
@@ -105,6 +110,7 @@
105
110
  "publishConfig": {
106
111
  "access": "public"
107
112
  },
113
+ "gitHead": "0c61ee6284117bcbac4e40c7feca78d9a92e177e",
108
114
  "scripts": {
109
115
  "build": "tsc -p tsconfig.build.json",
110
116
  "typecheck": "tsc --noEmit",
package/src/Gone.astro ADDED
@@ -0,0 +1,37 @@
1
+ ---
2
+ // A retired address's page: it says what was here was removed, gives the
3
+ // reason as its authors wrote it, as text, and links to the docs' home: the
4
+ // root of the project whose `_ascribe/redirects.json` lists it. The
5
+ // integration adds it as a route at `<base path>_ascribe/gone/[...address]`
6
+ // for each build's base path, one page for each retired address there, and
7
+ // the host's `_redirects` serves it at the address with a 404. Which folder
8
+ // this is comes from the route's pattern. It isn't indexed.
9
+ import type { GetStaticPaths } from "astro";
10
+ import { base, permalinks } from "virtual:ascribe/site";
11
+ import { gonePaths, type GonePage } from "@ascribed/astro/redirects";
12
+
13
+ export const getStaticPaths = (({ routePattern }) =>
14
+ gonePaths(permalinks, base, routePattern)) satisfies GetStaticPaths;
15
+
16
+ const { reason, home } = Astro.props as GonePage;
17
+ ---
18
+
19
+ <!doctype html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="utf-8" />
23
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
24
+ <title>Removed from these docs</title>
25
+ <meta name="robots" content="noindex" />
26
+ </head>
27
+ <body>
28
+ <main>
29
+ <h1>Removed from these docs</h1>
30
+ <p>The page that was here was removed from these docs.</p>
31
+ <p data-reason>{reason}</p>
32
+ <p>
33
+ <a href={home}>Go to the docs' home page</a>
34
+ </p>
35
+ </main>
36
+ </body>
37
+ </html>