@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.
Files changed (81) hide show
  1. package/README.md +41 -9
  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 +30 -0
  6. package/dist/content.js +101 -2
  7. package/dist/content.js.map +1 -1
  8. package/dist/dev.d.ts +2 -1
  9. package/dist/dev.js +13 -3
  10. package/dist/dev.js.map +1 -1
  11. package/dist/files.d.ts +21 -4
  12. package/dist/files.js +46 -8
  13. package/dist/files.js.map +1 -1
  14. package/dist/head-check.d.ts +6 -0
  15. package/dist/head-check.js +28 -0
  16. package/dist/head-check.js.map +1 -0
  17. package/dist/head.d.ts +54 -0
  18. package/dist/head.js +114 -0
  19. package/dist/head.js.map +1 -0
  20. package/dist/headers.d.ts +50 -0
  21. package/dist/headers.js +155 -0
  22. package/dist/headers.js.map +1 -0
  23. package/dist/index.d.ts +40 -9
  24. package/dist/index.js +330 -95
  25. package/dist/index.js.map +1 -1
  26. package/dist/init.d.ts +41 -0
  27. package/dist/init.js +130 -0
  28. package/dist/init.js.map +1 -0
  29. package/dist/names.d.ts +5 -0
  30. package/dist/names.js +5 -0
  31. package/dist/names.js.map +1 -1
  32. package/dist/nav.d.ts +51 -0
  33. package/dist/nav.js +97 -0
  34. package/dist/nav.js.map +1 -0
  35. package/dist/permalinks.d.ts +78 -0
  36. package/dist/permalinks.js +114 -0
  37. package/dist/permalinks.js.map +1 -0
  38. package/dist/project.d.ts +37 -0
  39. package/dist/project.js +47 -2
  40. package/dist/project.js.map +1 -1
  41. package/dist/projects.d.ts +38 -0
  42. package/dist/projects.js +127 -0
  43. package/dist/projects.js.map +1 -0
  44. package/dist/records.d.ts +136 -0
  45. package/dist/records.js +182 -0
  46. package/dist/records.js.map +1 -0
  47. package/dist/review/devfile.d.ts +6 -1
  48. package/dist/review/devfile.js +12 -2
  49. package/dist/review/devfile.js.map +1 -1
  50. package/dist/review/diff.d.ts +13 -0
  51. package/dist/review/diff.js +34 -1
  52. package/dist/review/diff.js.map +1 -1
  53. package/dist/review/protocol.d.ts +6 -1
  54. package/dist/review/protocol.js.map +1 -1
  55. package/dist/review/routes.d.ts +3 -0
  56. package/dist/review/routes.js +49 -35
  57. package/dist/review/routes.js.map +1 -1
  58. package/dist/review/server.d.ts +15 -0
  59. package/dist/review/server.js +58 -7
  60. package/dist/review/server.js.map +1 -1
  61. package/dist/root-files.d.ts +68 -0
  62. package/dist/root-files.js +103 -0
  63. package/dist/root-files.js.map +1 -0
  64. package/dist/run.d.ts +7 -6
  65. package/dist/run.js +14 -6
  66. package/dist/run.js.map +1 -1
  67. package/dist/shapes.d.ts +8 -1
  68. package/dist/shapes.js.map +1 -1
  69. package/dist/toolbar/app.js +98 -0
  70. package/dist/toolbar/app.js.map +1 -1
  71. package/dist/toolbar/style.d.ts +1 -1
  72. package/dist/toolbar/style.js +31 -23
  73. package/dist/toolbar/style.js.map +1 -1
  74. package/package.json +32 -9
  75. package/src/Head.astro +58 -0
  76. package/src/MissingPermalink.astro +59 -0
  77. package/src/Permalink.astro +78 -0
  78. package/starter/src/content.config.ts +8 -0
  79. package/starter/src/layouts/Docs.astro +87 -0
  80. package/starter/src/nav.ts +13 -0
  81. package/starter/src/pages/[...slug].astro +20 -0
package/dist/index.d.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  import type { AstroIntegration } from "astro";
2
+ import { type ProjectEntry } from "./projects.js";
2
3
  export { default as rehypeAscribeAttributes } from "./rehype.js";
3
4
  export { satteriAscribeAttributes } from "./satteri.js";
4
- /** Options of the integration. */
5
- export interface AscribeOptions {
6
- /** The build whose site output is the collection (a name in `ascribe.toml`). */
7
- build: string;
8
- /** The directory holding `ascribe.toml`, relative to the Astro root. Default: the root. */
9
- project?: string;
5
+ export type { ProjectEntry } from "./projects.js";
6
+ /** Options every form of the integration takes. */
7
+ interface SharedOptions {
10
8
  /**
11
9
  * The `ascribe` binary, relative to the Astro root. Default: `ASCRIBE_BIN`, then
12
10
  * the platform binary installed with `@ascribed/cli`.
@@ -22,15 +20,48 @@ export interface AscribeOptions {
22
20
  * Review in the site preview: an **Ascribe review** app in `astro dev`'s
23
21
  * toolbar, which marks the page's changes and shows the pull request's
24
22
  * comments, with source anchors in `astro dev`. Default `true`; `false`
25
- * removes the app. `astro build` never has it.
23
+ * removes the app. `astro build` never has it. With several projects, it
24
+ * reviews one: the entry a name names, or the first for `true`.
26
25
  */
27
- review?: boolean;
26
+ review?: boolean | string;
28
27
  /**
29
28
  * Show a code block's title (`title="…"` in its info string, as `@snippet`
30
29
  * writes it) above it, with a Shiki transformer that puts the block in a
31
30
  * `<figure class="code-title">` with a `<figcaption>`. Default `true`.
32
31
  */
33
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
+ };
34
42
  }
35
- /** Runs `ascribe build`, checks the site's routing, adds the markdown plugin, and serves published files. */
43
+ /** Options of the integration with one project. */
44
+ export interface OneProjectOptions extends SharedOptions {
45
+ /** The build whose site output is the collection (a name in `ascribe.toml`). */
46
+ build: string;
47
+ /** The directory holding `ascribe.toml`, relative to the Astro root. Default: the root. */
48
+ project?: string;
49
+ projects?: never;
50
+ }
51
+ /** Options of the integration with several projects, each entry named. */
52
+ export interface ProjectsOptions extends SharedOptions {
53
+ /** The projects, each a named build of a project; two entries may share a project. */
54
+ projects: ProjectEntry[];
55
+ build?: never;
56
+ project?: never;
57
+ }
58
+ /** Options of the integration. */
59
+ export type AscribeOptions = OneProjectOptions | ProjectsOptions;
60
+ /**
61
+ * Runs `ascribe build` for each project, checks the site's routing, adds the markdown plugin, adds a
62
+ * page for each permalink's address, and serves published files, and, with `[consumer] agents = true`,
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.
66
+ */
36
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,56 +20,161 @@
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"));
28
+ //
29
+ // // src/pages/404.astro
30
+ // import MissingPermalink from "@ascribed/astro/MissingPermalink.astro"; // a permalink that isn't here
31
+ //
32
+ // Several projects in one site, each entry named, and each collection naming its entry:
33
+ //
34
+ // ascribe({
35
+ // projects: [
36
+ // { name: "docs", project: "../docs", build: "site" },
37
+ // { name: "home", project: "home", build: "site" },
38
+ // ],
39
+ // review: "docs",
40
+ // })
41
+ // ascribeCollection({ schema, name: "docs" })
42
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
43
+ import { writeFile } from "node:fs/promises";
22
44
  import path from "node:path";
23
45
  import { fileURLToPath } from "node:url";
24
46
  import { findBinary } from "./binary.js";
25
47
  import { codeTitles } from "./code-titles.js";
26
48
  import { SeenFiles, watchDev } from "./dev.js";
27
- import { copyPublishedFiles, filesMiddleware } from "./files.js";
28
- import { consumerMismatches, readProject } from "./project.js";
49
+ import { agentsMiddleware, copyAgentFiles, copyPublishedFiles, filesMiddleware, folderUnder, } from "./files.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";
54
+ import { basePathClashes, nameProblems, pagesUnderOtherPaths, permalinkProblems, } from "./projects.js";
55
+ import { pageList, readRecords, routeAs, routeKey, sitemapRoute, } from "./records.js";
29
56
  import rehypeAscribeAttributes from "./rehype.js";
30
57
  import { removeDevFile, siteUrl, writeDevFile } from "./review/devfile.js";
31
- import { runDiff } from "./review/diff.js";
58
+ import { runDiff, runPrompt } from "./review/diff.js";
32
59
  import { APP_ID } from "./review/protocol.js";
33
60
  import { readRoutes } from "./review/routes.js";
34
61
  import { channelProblem, ReviewServer } from "./review/server.js";
62
+ import { devRouted, devSitePages, rootFiles, rootFilesMiddleware, } from "./root-files.js";
35
63
  import { anchorsFor, hasWarnings, reviewFor, runBuild } from "./run.js";
36
64
  import { satteriAscribeAttributes } from "./satteri.js";
37
65
  import { copySchema } from "./schema.js";
38
66
  export { default as rehypeAscribeAttributes } from "./rehype.js";
39
67
  export { satteriAscribeAttributes } from "./satteri.js";
40
- /** The virtual module `content.ts` reads the resolved site output root from. */
68
+ /** The virtual module `content.ts` reads the resolved site output roots from. */
41
69
  const SITE_MODULE = "virtual:ascribe/site";
42
70
  /** The toolbar app's icon: a speech bubble over a page. */
43
71
  const ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v6"/><path d="M14 3v4h4"/><path d="M6 3v15"/><path d="M11 14h10v6h-6l-3 2v-2h-1z"/></svg>';
44
- /** Runs `ascribe build`, checks the site's routing, adds the markdown plugin, and serves published files. */
72
+ function fail(problems, what) {
73
+ if (problems.length > 0)
74
+ throw new Error(`@ascribed/astro: ${what}:\n- ${problems.join("\n- ")}`);
75
+ }
76
+ /**
77
+ * Runs `ascribe build` for each project, checks the site's routing, adds the markdown plugin, adds a
78
+ * page for each permalink's address, and serves published files, and, with `[consumer] agents = true`,
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.
82
+ */
45
83
  export default function ascribe(options) {
46
- let siteRoot = "";
47
- let devProject;
48
- let devConfig;
49
- let rebuild;
84
+ /** With `projects`; otherwise the one entry has no name a collection gives. */
85
+ const named = options.projects !== undefined;
86
+ let entries = [];
87
+ let groups = [];
88
+ let routing;
50
89
  /** The dev server's sources as its first build read them. */
51
90
  const seen = new SeenFiles();
52
91
  let binary;
53
- let reviewing = false;
92
+ let reviewed;
93
+ let devCommand = false;
54
94
  let devHttps = false;
55
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
+ };
56
131
  return {
57
132
  name: "@ascribed/astro",
58
133
  hooks: {
59
- "astro:config:setup": async ({ config, command, logger, updateConfig, addDevToolbarApp, createCodegenDir, }) => {
134
+ "astro:config:setup": async ({ config, command, logger, updateConfig, addDevToolbarApp, createCodegenDir, injectRoute, addMiddleware, }) => {
60
135
  const root = fileURLToPath(config.root);
61
- const project = readProject(path.resolve(root, options.project ?? "."));
62
- // An unknown build is `ascribe build`'s to report: it knows the implicit `site` build.
63
- siteRoot = project.siteRoot(options.build);
64
- // `ascribe.toml`'s routing must be Astro's, or every link Ascribe writes is wrong.
65
- const problems = consumerMismatches(project, {
66
- base: config.base,
67
- trailingSlash: config.trailingSlash,
68
- site: config.site,
136
+ publicDir = fileURLToPath(config.publicDir);
137
+ const list = options.projects ?? [
138
+ {
139
+ name: "",
140
+ build: options.build,
141
+ ...(options.project === undefined ? {} : { project: options.project }),
142
+ },
143
+ ];
144
+ if (named)
145
+ fail(nameProblems(list), "the `projects` option is wrong");
146
+ const codegen = command === "preview" ? "" : fileURLToPath(createCodegenDir());
147
+ entries = list.map((entry) => {
148
+ const project = readProject(path.resolve(root, entry.project ?? "."));
149
+ return {
150
+ name: entry.name,
151
+ build: entry.build,
152
+ project,
153
+ // An unknown build is `ascribe build`'s to report: it knows the implicit `site` build.
154
+ siteRoot: project.siteRoot(entry.build),
155
+ agentsRoot: project.consumer.agents ? project.plainRoot(entry.build) : undefined,
156
+ schemaDir: named ? path.join(codegen, entry.name) : codegen,
157
+ };
69
158
  });
70
- if (problems.length > 0) {
71
- throw new Error(`@ascribed/astro: ascribe.toml and astro.config disagree:\n- ${problems.join("\n- ")}`);
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);
163
+ // `ascribe.toml`'s routing must be Astro's, or every link Ascribe writes is wrong.
164
+ for (const entry of entries) {
165
+ fail(consumerMismatches(entry.project, astro), named
166
+ ? `"${entry.name}"'s ascribe.toml and astro.config disagree`
167
+ : "ascribe.toml and astro.config disagree");
168
+ }
169
+ fail(basePathClashes(entries), "two projects share a base path");
170
+ if (typeof options.review === "string" && !entries.some((e) => e.name === options.review)) {
171
+ throw new Error(`@ascribed/astro: \`review\` names "${options.review}", and no entry of \`projects\` has that name.`);
172
+ }
173
+ if (reviewFor(options.review, command)) {
174
+ reviewed =
175
+ typeof options.review === "string"
176
+ ? entries.find((e) => e.name === options.review)
177
+ : entries[0];
72
178
  }
73
179
  // Where a markdown plugin goes depends on the processor Astro 7.3 runs.
74
180
  const processor = config.markdown.processor;
@@ -83,13 +189,8 @@ export default function ascribe(options) {
83
189
  throw new Error(`@ascribed/astro: the markdown processor "${processor.name}" is not one it can add its plugin to. ` +
84
190
  "Use Astro's default (satteri) or unified(), or add `rehypeAscribeAttributes` to your own.");
85
191
  }
86
- devConfig = {
87
- base: config.base,
88
- trailingSlash: config.trailingSlash,
89
- site: config.site,
90
- };
91
- reviewing = reviewFor(options.review, command);
92
- if (reviewing) {
192
+ routing = astro;
193
+ if (reviewed !== undefined) {
93
194
  addDevToolbarApp({
94
195
  id: APP_ID,
95
196
  name: "Ascribe review",
@@ -100,18 +201,21 @@ export default function ascribe(options) {
100
201
  if (command !== "preview") {
101
202
  const found = findBinary({ binary: options.binary, root });
102
203
  binary = found;
103
- // The schema's copy inside the Astro root, for content.config.ts to import.
104
- const codegen = fileURLToPath(createCodegenDir());
105
- rebuild = async () => {
106
- logger.info(`running ${path.basename(found)} build for "${options.build}"`);
204
+ const buildEntry = async (entry) => {
205
+ logger.info(`running ${path.basename(found)} build for "${entry.build}"${named ? ` (${entry.name})` : ""}`);
107
206
  const result = await runBuild({
108
207
  binary: found,
109
- configPath: project.configPath,
110
- build: options.build,
111
- cwd: project.dir,
112
- anchors: anchorsFor(options.anchors, command, reviewing),
113
- // While review is on, the JSON output says which page is at each route.
114
- outputs: review?.active ? ["site", "json"] : ["site"],
208
+ configPath: entry.project.configPath,
209
+ build: entry.build,
210
+ cwd: entry.project.dir,
211
+ anchors: anchorsFor(options.anchors, command, entry === reviewed),
212
+ outputs: [
213
+ "site",
214
+ // `llms.txt` and each page's Markdown, which agents read.
215
+ ...(entry.agentsRoot === undefined ? [] : ["plain"]),
216
+ // While review is on, the JSON output says which page is at each route.
217
+ ...(entry === reviewed && review?.active ? ["json"] : []),
218
+ ],
115
219
  });
116
220
  if (hasWarnings(result.diagnostics))
117
221
  logger.warn(result.diagnostics);
@@ -119,17 +223,69 @@ export default function ascribe(options) {
119
223
  logger.info(result.diagnostics);
120
224
  if (result.summary !== "")
121
225
  logger.info(result.summary);
122
- copySchema(siteRoot, codegen);
226
+ // The schema's copy inside the Astro root, for content.config.ts to import.
227
+ mkdirSync(entry.schemaDir, { recursive: true });
228
+ copySchema(entry.siteRoot, entry.schemaDir);
229
+ };
230
+ // What the projects share, which no one project's build can see.
231
+ const checkShared = () => {
232
+ if (entries.length < 2)
233
+ return;
234
+ const current = entries.map((entry) => ({
235
+ ...entry,
236
+ project: readProject(entry.project.dir),
237
+ }));
238
+ fail(basePathClashes(current), "two projects share a base path");
239
+ fail(pagesUnderOtherPaths(current), "a page is under another project's base path");
240
+ fail(permalinkProblems(current, current.map((entry) => readPermalinks(entry.siteRoot))), "the projects' permalinks can't share one route");
123
241
  };
242
+ // One group for each project: a change rebuilds the project it's in.
243
+ const byDir = new Map();
244
+ for (const entry of entries) {
245
+ byDir.set(entry.project.dir, [...(byDir.get(entry.project.dir) ?? []), entry]);
246
+ }
247
+ groups = [...byDir.values()].map((group) => ({
248
+ project: group[0].project,
249
+ entries: group,
250
+ rebuild: async () => {
251
+ for (const entry of group)
252
+ await buildEntry(entry);
253
+ checkShared();
254
+ },
255
+ }));
124
256
  // The sources as the first build reads them: the watcher may report their edits late.
125
- if (command === "dev")
126
- seen.prime([project.contentRoot, project.configPath]);
127
- await rebuild();
128
- if (command === "dev")
129
- devProject = project;
257
+ if (command === "dev") {
258
+ for (const group of groups)
259
+ seen.prime([group.project.contentRoot, group.project.configPath]);
260
+ }
261
+ for (const entry of entries)
262
+ await buildEntry(entry);
263
+ checkShared();
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
+ }
273
+ // Each permalink's address, a page that forwards to its heading. One route serves every
274
+ // list, at the path of the first that names anything.
275
+ const lists = entries.flatMap((entry) => readPermalinks(entry.siteRoot) ?? []);
276
+ const served = lists.find(hasPermalinks) ?? lists[0];
277
+ if (served !== undefined) {
278
+ injectRoute({
279
+ pattern: permalinkPattern(served.path, astro.base),
280
+ entrypoint: new URL("../src/Permalink.astro", import.meta.url),
281
+ prerender: true,
282
+ });
283
+ }
130
284
  }
131
- // Dev serving of `_ascribe/files/`; the build copies them in `astro:build:done`.
285
+ const projectHome = (entry) => homeRoute(entry.project.consumer.basePath, astro.trailingSlash);
286
+ // Dev serving of `_ascribe/files/`, and of the plain output for agents; the build copies them in `astro:build:done`.
132
287
  updateConfig({
288
+ ...supplied,
133
289
  ...(options.codeTitles === false
134
290
  ? {}
135
291
  : { markdown: { shikiConfig: { transformers: [codeTitles] } } }),
@@ -141,83 +297,162 @@ export default function ascribe(options) {
141
297
  name: "@ascribed/astro:site",
142
298
  resolveId: (id) => (id === SITE_MODULE ? `\0${SITE_MODULE}` : undefined),
143
299
  load: (id) => id === `\0${SITE_MODULE}`
144
- ? `export const siteRoot = ${JSON.stringify(siteRoot)};`
300
+ ? `export const siteRoot = ${JSON.stringify(entries[0]?.siteRoot ?? "")};\n` +
301
+ `export const astroRoot = ${JSON.stringify(root)};\n` +
302
+ `export const home = ${JSON.stringify(homeRoute(astro.base, astro.trailingSlash))};\n` +
303
+ `export const collections = ${JSON.stringify(named ? Object.fromEntries(entries.map((e) => [e.name, e.siteRoot])) : {})};\n` +
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
+ })))};`
145
310
  : undefined,
146
311
  },
147
312
  {
148
313
  name: "@ascribed/astro:files",
149
314
  configureServer(server) {
150
- server.middlewares.use(filesMiddleware(siteRoot, config.base.endsWith("/") ? config.base : `${config.base}/`));
315
+ for (const entry of entries) {
316
+ const base = entry.project.consumer.basePath;
317
+ server.middlewares.use(filesMiddleware(entry.siteRoot, base));
318
+ if (entry.agentsRoot !== undefined) {
319
+ const siteRoot = entry.siteRoot;
320
+ server.middlewares.use(agentsMiddleware(entry.agentsRoot, base, (pathname) => headersFor(siteRoot, pathname)));
321
+ }
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)))));
151
325
  },
152
326
  },
153
327
  ],
154
328
  },
155
329
  });
156
330
  },
331
+ "astro:routes:resolved": ({ routes }) => {
332
+ astroRoutes = routes;
333
+ },
157
334
  "astro:server:setup": ({ server, refreshContent, logger, toolbar }) => {
158
- if (devProject && rebuild && devConfig) {
159
- if (!refreshContent)
160
- throw new Error("@ascribed/astro: this Astro version does not support refreshContent.");
161
- const project = devProject;
162
- devHttps = Boolean(server.config.server.https);
163
- const builds = watchDev({
335
+ if (!devCommand || !routing)
336
+ return;
337
+ if (!refreshContent)
338
+ throw new Error("@ascribed/astro: this Astro version does not support refreshContent.");
339
+ devHttps = Boolean(server.config.server.https);
340
+ for (const group of groups) {
341
+ group.builds = watchDev({
164
342
  server,
165
343
  seen,
166
344
  refreshContent: () => refreshContent({}),
167
345
  logger,
168
- project,
169
- astro: devConfig,
170
- build: options.build,
171
- rebuild,
172
- onBuilt: () => review?.rebuilt(),
346
+ project: group.project,
347
+ astro: routing,
348
+ builds: group.entries.map((entry) => entry.build),
349
+ rebuild: group.rebuild,
350
+ onBuilt: () => {
351
+ if (reviewed !== undefined && group.entries.includes(reviewed))
352
+ review?.rebuilt();
353
+ },
354
+ });
355
+ }
356
+ const group = groups.find((g) => reviewed !== undefined && g.entries.includes(reviewed));
357
+ if (reviewed !== undefined && group?.builds !== undefined && binary !== undefined) {
358
+ const entry = reviewed;
359
+ const builds = group.builds;
360
+ const run = { binary, configPath: entry.project.configPath, cwd: entry.project.dir };
361
+ const jsonRoot = path.join(path.dirname(entry.siteRoot), "json");
362
+ review = new ReviewServer({
363
+ channel: toolbar,
364
+ logger,
365
+ build: entry.build,
366
+ contentRoot: entry.project.contentRoot,
367
+ channelProblem: channelProblem(server.config),
368
+ diff: (base) => runDiff({ ...run, build: entry.build, base }),
369
+ prompt: (page, base) => runPrompt({ ...run, build: entry.build, base, path: page }),
370
+ projectDir: entry.project.dir,
371
+ connect: async () => (await import("./review/github.js")).connect(entry.project.dir),
372
+ writeRoutes: () => builds.inTurn(async () => {
373
+ await runBuild({ ...run, build: entry.build, outputs: ["json"] });
374
+ }),
375
+ readRoutes: () => readRoutes(jsonRoot),
173
376
  });
174
- if (reviewing && binary !== undefined) {
175
- const found = binary;
176
- const run = { binary: found, configPath: project.configPath, cwd: project.dir };
177
- const jsonRoot = path.join(path.dirname(siteRoot), "json");
178
- review = new ReviewServer({
179
- channel: toolbar,
180
- logger,
181
- build: options.build,
182
- contentRoot: project.contentRoot,
183
- channelProblem: channelProblem(server.config),
184
- diff: (base) => runDiff({ ...run, build: options.build, base }),
185
- connect: async () => (await import("./review/github.js")).connect(project.dir),
186
- writeRoutes: () => builds.inTurn(async () => {
187
- await runBuild({ ...run, build: options.build, outputs: ["json"] });
188
- }),
189
- readRoutes: () => readRoutes(jsonRoot),
190
- });
191
- }
192
377
  }
193
378
  },
194
379
  "astro:server:start": ({ address, logger }) => {
195
380
  review?.listening(address.address);
196
- if (!devProject || !devConfig)
381
+ if (!devCommand)
197
382
  return;
198
- const dir = devProject.dir;
199
- try {
200
- writeDevFile(dir, {
201
- url: siteUrl(address, { https: devHttps, base: devConfig.base }),
202
- build: options.build,
203
- pid: process.pid,
204
- });
205
- // Vite exits on a signal before `astro:server:done` runs.
206
- process.once("exit", () => removeDevFile(dir, process.pid));
207
- }
208
- catch (error) {
209
- logger.warn(`couldn't write .ascribe/dev.json, which the editor's Open Site Preview reads: ${error instanceof Error ? error.message : String(error)}`);
383
+ // Each project's folder gets its own file, with its own address: its base path.
384
+ for (const group of groups) {
385
+ const entry = group.entries[0];
386
+ const dir = group.project.dir;
387
+ try {
388
+ writeDevFile(dir, {
389
+ url: siteUrl(address, { https: devHttps, base: entry.project.consumer.basePath }),
390
+ build: entry.build,
391
+ pid: process.pid,
392
+ });
393
+ // Vite exits on a signal before `astro:server:done` runs.
394
+ process.once("exit", () => removeDevFile(dir, process.pid));
395
+ }
396
+ catch (error) {
397
+ logger.warn(`couldn't write ${groups.length > 1 ? path.join(dir, ".ascribe", "dev.json") : ".ascribe/dev.json"}, which the editor's Open Site Preview reads: ${error instanceof Error ? error.message : String(error)}`);
398
+ }
210
399
  }
211
400
  },
212
401
  "astro:server:done": () => {
213
402
  review?.dispose();
214
403
  review = undefined;
215
- if (devProject)
216
- removeDevFile(devProject.dir, process.pid);
404
+ if (devCommand)
405
+ for (const group of groups)
406
+ removeDevFile(group.project.dir, process.pid);
217
407
  },
218
- "astro:build:done": async ({ dir, logger }) => {
219
- if (await copyPublishedFiles(siteRoot, dir))
220
- logger.info("copied _ascribe/files/");
408
+ "astro:build:done": async ({ dir, pages, logger }) => {
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
+ }
446
+ // Entries of one project publish at one base path: the first entry's copies go last, so they win.
447
+ for (const entry of [...entries].reverse()) {
448
+ const folder = folderUnder(entry.project.consumer.basePath, base);
449
+ const where = folder === "" ? "" : ` to ${folder}`;
450
+ if (await copyPublishedFiles(entry.siteRoot, dir, folder))
451
+ logger.info(`copied _ascribe/files/${where}`);
452
+ if (entry.agentsRoot !== undefined &&
453
+ (await copyAgentFiles(entry.agentsRoot, dir, folder)))
454
+ logger.info(`copied llms.txt and each page's Markdown${where}`);
455
+ }
221
456
  },
222
457
  },
223
458
  };