@ascribed/astro 0.2.1-next.13 → 0.2.1-next.14

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.
@@ -3,7 +3,8 @@
3
3
  // one with nowhere to lead, which says why it was removed. Ascribe writes the list,
4
4
  // `_ascribe/permalinks.json` in the site output (output-layout contract §1.2);
5
5
  // the integration adds the route (`Permalink.astro`) and builds its pages
6
- // from it, in `astro build` and `astro dev`.
6
+ // from it, in `astro build` and `astro dev`. With several projects, one route
7
+ // serves every project's list.
7
8
  import { readFileSync } from "node:fs";
8
9
  import path from "node:path";
9
10
  import { normalizeBase } from "./project.js";
@@ -52,21 +53,49 @@ function destination(entry) {
52
53
  }
53
54
  /**
54
55
  * `getStaticPaths` for the route: one page for each permalink in the site
55
- * output's list, and one for each retired permalink it lists as removed.
56
+ * outputs' lists, and one for each retired permalink they list as removed,
57
+ * whose page links to its project's root. A name in more than one list gets
58
+ * the first's page; the integration refuses one that two projects declare.
56
59
  */
57
- export function forwardingPaths(siteRoot) {
58
- const list = readPermalinks(siteRoot);
59
- if (list === undefined)
60
- return [];
61
- const dimensions = list.dimensions ?? {};
62
- const forwarding = Object.entries(list.permalinks).map(([id, entry]) => ({
63
- params: { id },
64
- props: { to: destination(entry), dimensions },
65
- }));
66
- const removed = Object.entries(list.removed ?? {}).map(([id, entry]) => ({
67
- params: { id },
68
- props: { reason: entry.reason },
69
- }));
70
- return [...forwarding, ...removed];
60
+ export function forwardingPaths(sources) {
61
+ const all = typeof sources === "string" ? [{ siteRoot: sources, home: undefined }] : sources;
62
+ const pages = new Map();
63
+ for (const { siteRoot, home } of all) {
64
+ const list = readPermalinks(siteRoot);
65
+ if (list === undefined)
66
+ continue;
67
+ const dimensions = list.dimensions ?? {};
68
+ for (const [id, entry] of Object.entries(list.permalinks)) {
69
+ if (!pages.has(id))
70
+ pages.set(id, { to: destination(entry), dimensions });
71
+ }
72
+ for (const [id, entry] of Object.entries(list.removed ?? {})) {
73
+ if (!pages.has(id))
74
+ pages.set(id, home === undefined ? { reason: entry.reason } : { reason: entry.reason, home });
75
+ }
76
+ }
77
+ const forwarding = [...pages].filter(([, props]) => "to" in props);
78
+ const removed = [...pages].filter(([, props]) => !("to" in props));
79
+ return [...forwarding, ...removed].map(([id, props]) => ({ params: { id }, props }));
80
+ }
81
+ /** Whether a list names anything: a permalink, or a retired one it lists as removed. */
82
+ export function hasPermalinks(list) {
83
+ return Object.keys(list.permalinks).length + Object.keys(list.removed ?? {}).length > 0;
84
+ }
85
+ /**
86
+ * The list a site's not-found page explains addresses from, and the route of
87
+ * its project's root: the first that has a permalink, or else the first list.
88
+ */
89
+ export function servedList(sources) {
90
+ let first;
91
+ for (const { siteRoot, home } of sources) {
92
+ const list = readPermalinks(siteRoot);
93
+ if (list === undefined)
94
+ continue;
95
+ first ??= { path: list.path, home };
96
+ if (hasPermalinks(list))
97
+ return { path: list.path, home };
98
+ }
99
+ return first;
71
100
  }
72
101
  //# sourceMappingURL=permalinks.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"permalinks.js","sourceRoot":"","sources":["../src/permalinks.ts"],"names":[],"mappings":"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","sourcesContent":["// Permalinks' addresses: a page at `<path><id>` for each permalink the build\n// publishes, which sends the reader on to its heading, and for each retired\n// one with nowhere to lead, which says why it was removed. Ascribe writes the list,\n// `_ascribe/permalinks.json` in the site output (output-layout contract §1.2);\n// the integration adds the route (`Permalink.astro`) and builds its pages\n// from it, in `astro build` and `astro dev`.\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { normalizeBase } from \"./project.js\";\n\n/** The list's format version this reads. */\nconst VERSION = 1;\n\n/** `_ascribe/permalinks.json`. */\nexport interface PermalinkList {\n version: number;\n /** Where the addresses are published, from the site's root, ending in `/`. */\n path: string;\n /** The content model's dimensions, each with its values: the parameters an address takes. */\n dimensions?: Record<string, string[]>;\n /**\n * Each permalink's destination. An alias names the permalink it leads to,\n * and a retired permalink that leads somewhere its reason.\n */\n permalinks: Record<\n string,\n { route: string; fragment?: string; source: string; alias?: string; retired?: string }\n >;\n /** The retired permalinks with nowhere to lead in this build, each with why. */\n removed?: Record<string, { reason: string }>;\n}\n\n/**\n * What an address's page needs: where it sends the reader, and the\n * parameters it takes; or, for a retired permalink with nowhere to lead,\n * why it was removed.\n */\nexport type Forwarding = { to: string; dimensions: Record<string, string[]> } | { reason: string };\n\n/** The list's text in a build's site output, or `undefined` when there isn't one. */\nexport function permalinksText(siteRoot: string): string | undefined {\n try {\n return readFileSync(listFile(siteRoot), \"utf8\");\n } catch {\n return undefined;\n }\n}\n\nfunction listFile(siteRoot: string): string {\n return path.join(siteRoot, \"_ascribe\", \"permalinks.json\");\n}\n\n/** The list in a build's site output, or `undefined` when there isn't one. */\nexport function readPermalinks(siteRoot: string): PermalinkList | undefined {\n const file = listFile(siteRoot);\n const text = permalinksText(siteRoot);\n if (text === undefined) return undefined;\n const list = JSON.parse(text) as PermalinkList;\n if (list.version !== VERSION) {\n throw new Error(\n `@ascribed/astro: ${file} is version ${String(list.version)} of the permalinks list, and this version of the integration reads version ${VERSION}. Update @ascribed/astro and @ascribed/cli together.`,\n );\n }\n return list;\n}\n\n/**\n * The route pattern for the addresses, as Astro takes it: under `base`, which\n * Astro adds. Throws when the list's path isn't under `base`, since an Astro\n * site serves nothing outside it.\n */\nexport function permalinkPattern(listPath: string, base: string): string {\n const root = normalizeBase(base);\n const at = normalizeBase(listPath);\n if (!at.startsWith(root)) {\n throw new Error(\n `@ascribed/astro: permalinks are published at \"${at}\", which isn't under Astro's \\`base\\`, \"${base}\", and an Astro site serves nothing outside it. Set [consumer] permalink-path in ascribe.toml to a path under \"${root}\".`,\n );\n }\n return `/${at.slice(root.length)}[id]`;\n}\n\n/** Where a permalink leads: its page's route, and the heading's anchor. */\nfunction destination(entry: { route: string; fragment?: string }): string {\n return entry.fragment === undefined ? entry.route : `${entry.route}#${entry.fragment}`;\n}\n\n/**\n * `getStaticPaths` for the route: one page for each permalink in the site\n * output's list, and one for each retired permalink it lists as removed.\n */\nexport function forwardingPaths(siteRoot: string): { params: { id: string }; props: Forwarding }[] {\n const list = readPermalinks(siteRoot);\n if (list === undefined) return [];\n const dimensions = list.dimensions ?? {};\n const forwarding = Object.entries(list.permalinks).map(([id, entry]) => ({\n params: { id },\n props: { to: destination(entry), dimensions } as Forwarding,\n }));\n const removed = Object.entries(list.removed ?? {}).map(([id, entry]) => ({\n params: { id },\n props: { reason: entry.reason } as Forwarding,\n }));\n return [...forwarding, ...removed];\n}\n"]}
1
+ {"version":3,"file":"permalinks.js","sourceRoot":"","sources":["../src/permalinks.ts"],"names":[],"mappings":"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","sourcesContent":["// Permalinks' addresses: a page at `<path><id>` for each permalink the build\n// publishes, which sends the reader on to its heading, and for each retired\n// one with nowhere to lead, which says why it was removed. Ascribe writes the list,\n// `_ascribe/permalinks.json` in the site output (output-layout contract §1.2);\n// the integration adds the route (`Permalink.astro`) and builds its pages\n// from it, in `astro build` and `astro dev`. With several projects, one route\n// serves every project's list.\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { normalizeBase } from \"./project.js\";\n\n/** The list's format version this reads. */\nconst VERSION = 1;\n\n/** `_ascribe/permalinks.json`. */\nexport interface PermalinkList {\n version: number;\n /** Where the addresses are published, from the site's root, ending in `/`. */\n path: string;\n /** The content model's dimensions, each with its values: the parameters an address takes. */\n dimensions?: Record<string, string[]>;\n /**\n * Each permalink's destination. An alias names the permalink it leads to,\n * and a retired permalink that leads somewhere its reason.\n */\n permalinks: Record<\n string,\n { route: string; fragment?: string; source: string; alias?: string; retired?: string }\n >;\n /** The retired permalinks with nowhere to lead in this build, each with why. */\n removed?: Record<string, { reason: string }>;\n}\n\n/**\n * What an address's page needs: where it sends the reader, and the\n * parameters it takes; or, for a retired permalink with nowhere to lead,\n * why it was removed.\n */\nexport type Forwarding =\n { to: string; dimensions: Record<string, string[]> } | { reason: string; home?: string };\n\n/** A build's site output whose list the route serves, and the route of its project's root. */\nexport interface PermalinkSource {\n siteRoot: string;\n home: string;\n}\n\n/** The list's text in a build's site output, or `undefined` when there isn't one. */\nexport function permalinksText(siteRoot: string): string | undefined {\n try {\n return readFileSync(listFile(siteRoot), \"utf8\");\n } catch {\n return undefined;\n }\n}\n\nfunction listFile(siteRoot: string): string {\n return path.join(siteRoot, \"_ascribe\", \"permalinks.json\");\n}\n\n/** The list in a build's site output, or `undefined` when there isn't one. */\nexport function readPermalinks(siteRoot: string): PermalinkList | undefined {\n const file = listFile(siteRoot);\n const text = permalinksText(siteRoot);\n if (text === undefined) return undefined;\n const list = JSON.parse(text) as PermalinkList;\n if (list.version !== VERSION) {\n throw new Error(\n `@ascribed/astro: ${file} is version ${String(list.version)} of the permalinks list, and this version of the integration reads version ${VERSION}. Update @ascribed/astro and @ascribed/cli together.`,\n );\n }\n return list;\n}\n\n/**\n * The route pattern for the addresses, as Astro takes it: under `base`, which\n * Astro adds. Throws when the list's path isn't under `base`, since an Astro\n * site serves nothing outside it.\n */\nexport function permalinkPattern(listPath: string, base: string): string {\n const root = normalizeBase(base);\n const at = normalizeBase(listPath);\n if (!at.startsWith(root)) {\n throw new Error(\n `@ascribed/astro: permalinks are published at \"${at}\", which isn't under Astro's \\`base\\`, \"${base}\", and an Astro site serves nothing outside it. Set [consumer] permalink-path in ascribe.toml to a path under \"${root}\".`,\n );\n }\n return `/${at.slice(root.length)}[id]`;\n}\n\n/** Where a permalink leads: its page's route, and the heading's anchor. */\nfunction destination(entry: { route: string; fragment?: string }): string {\n return entry.fragment === undefined ? entry.route : `${entry.route}#${entry.fragment}`;\n}\n\n/**\n * `getStaticPaths` for the route: one page for each permalink in the site\n * outputs' lists, and one for each retired permalink they list as removed,\n * whose page links to its project's root. A name in more than one list gets\n * the first's page; the integration refuses one that two projects declare.\n */\nexport function forwardingPaths(\n sources: string | readonly PermalinkSource[],\n): { params: { id: string }; props: Forwarding }[] {\n const all = typeof sources === \"string\" ? [{ siteRoot: sources, home: undefined }] : sources;\n const pages = new Map<string, Forwarding>();\n for (const { siteRoot, home } of all) {\n const list = readPermalinks(siteRoot);\n if (list === undefined) continue;\n const dimensions = list.dimensions ?? {};\n for (const [id, entry] of Object.entries(list.permalinks)) {\n if (!pages.has(id)) pages.set(id, { to: destination(entry), dimensions });\n }\n for (const [id, entry] of Object.entries(list.removed ?? {})) {\n if (!pages.has(id))\n pages.set(\n id,\n home === undefined ? { reason: entry.reason } : { reason: entry.reason, home },\n );\n }\n }\n const forwarding = [...pages].filter(([, props]) => \"to\" in props);\n const removed = [...pages].filter(([, props]) => !(\"to\" in props));\n return [...forwarding, ...removed].map(([id, props]) => ({ params: { id }, props }));\n}\n\n/** Whether a list names anything: a permalink, or a retired one it lists as removed. */\nexport function hasPermalinks(list: PermalinkList): boolean {\n return Object.keys(list.permalinks).length + Object.keys(list.removed ?? {}).length > 0;\n}\n\n/**\n * The list a site's not-found page explains addresses from, and the route of\n * its project's root: the first that has a permalink, or else the first list.\n */\nexport function servedList(\n sources: readonly PermalinkSource[],\n): { path: string; home: string } | undefined {\n let first: { path: string; home: string } | undefined;\n for (const { siteRoot, home } of sources) {\n const list = readPermalinks(siteRoot);\n if (list === undefined) continue;\n first ??= { path: list.path, home };\n if (hasPermalinks(list)) return { path: list.path, home };\n }\n return first;\n}\n"]}
package/dist/project.d.ts CHANGED
@@ -26,8 +26,9 @@ export declare function readProject(dir: string): ProjectInfo;
26
26
  /** A base path with a leading and a trailing `/`, as Ascribe's router has it. */
27
27
  export declare function normalizeBase(base: string): string;
28
28
  /**
29
- * The route of the site's root, Astro's `base`, ending the way `trailingSlash`
30
- * says the site's routes do. (`import.meta.env.BASE_URL` isn't replaced in a
29
+ * The route of a base path, ending the way `trailingSlash` says the site's
30
+ * routes do: the site's root for Astro's `base`, a project's root for its
31
+ * `[consumer] base-path`. (`import.meta.env.BASE_URL` isn't replaced in a
31
32
  * package's components.)
32
33
  */
33
34
  export declare function homeRoute(base: string, trailingSlash: "always" | "never" | "ignore"): string;
package/dist/project.js CHANGED
@@ -51,8 +51,9 @@ export function normalizeBase(base) {
51
51
  return result;
52
52
  }
53
53
  /**
54
- * The route of the site's root, Astro's `base`, ending the way `trailingSlash`
55
- * says the site's routes do. (`import.meta.env.BASE_URL` isn't replaced in a
54
+ * The route of a base path, ending the way `trailingSlash` says the site's
55
+ * routes do: the site's root for Astro's `base`, a project's root for its
56
+ * `[consumer] base-path`. (`import.meta.env.BASE_URL` isn't replaced in a
56
57
  * package's components.)
57
58
  */
58
59
  export function homeRoute(base, trailingSlash) {
@@ -67,8 +68,9 @@ export function homeRoute(base, trailingSlash) {
67
68
  export function consumerMismatches(project, astro) {
68
69
  const problems = [];
69
70
  const { basePath, trailingSlash, site } = project.consumer;
70
- if (normalizeBase(astro.base) !== basePath) {
71
- problems.push(`[consumer] base-path is "${basePath}", but Astro's \`base\` is "${astro.base}". Set them to the same path.`);
71
+ // A base path under Astro's `base` leaves the rest of the site to other pages, such as a home page.
72
+ if (!basePath.startsWith(normalizeBase(astro.base))) {
73
+ problems.push(`[consumer] base-path is "${basePath}", but Astro's \`base\` is "${astro.base}". Set base-path to that path or a path under it.`);
72
74
  }
73
75
  // Astro's "ignore" serves both forms, so Ascribe's links work either way.
74
76
  if (astro.trailingSlash !== "ignore" && astro.trailingSlash !== trailingSlash) {
@@ -1 +1 @@
1
- {"version":3,"file":"project.js","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"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","sourcesContent":["// What the integration needs from `ascribe.toml`: where a build's site output\n// goes, and the consumer settings that must agree with `astro.config`.\n// The compiler validates the whole file; this reads\n// only what it compiled.\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { parse } from \"smol-toml\";\n\n/** The parts of an Ascribe project the integration reads. */\nexport interface ProjectInfo {\n /** The directory holding `ascribe.toml`. */\n dir: string;\n /** The path of `ascribe.toml`. */\n configPath: string;\n /** Source files and assets watched during development. */\n contentRoot: string;\n /** Generated output, never treated as a source change. */\n outputRoot: string;\n /** `[consumer]`, with the profile's defaults. */\n consumer: {\n site: string | undefined;\n basePath: string;\n trailingSlash: \"always\" | \"never\";\n /** Whether builds publish what agents read: `llms.txt` and each page's Markdown, in the plain output. */\n agents: boolean;\n };\n /** The site output's root for a build: `<output-dir>/<build>/site`. */\n siteRoot(build: string): string;\n /** The plain output's root for a build: `<output-dir>/<build>/plain`. */\n plainRoot(build: string): string;\n}\n\n/** Reads `ascribe.toml` in `dir`. Throws a readable error if it can't be read. */\nexport function readProject(dir: string): ProjectInfo {\n const configPath = path.join(dir, \"ascribe.toml\");\n let table: Record<string, unknown>;\n try {\n table = parse(readFileSync(configPath, \"utf8\"));\n } catch (error) {\n throw new Error(\n `@ascribed/astro can't read ${configPath}: ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n const project = section(table, \"project\");\n const consumer = section(table, \"consumer\");\n const outputDir =\n typeof project[\"output-dir\"] === \"string\" ? project[\"output-dir\"] : \".ascribe/build\";\n const contentDir = typeof project[\"content-root\"] === \"string\" ? project[\"content-root\"] : \"docs\";\n const trailingSlash = consumer[\"trailing-slash\"] === \"never\" ? \"never\" : \"always\";\n return {\n dir,\n configPath,\n contentRoot: path.resolve(dir, contentDir),\n outputRoot: path.resolve(dir, outputDir),\n consumer: {\n site: typeof consumer[\"site\"] === \"string\" ? consumer[\"site\"] : undefined,\n basePath: normalizeBase(\n typeof consumer[\"base-path\"] === \"string\" ? consumer[\"base-path\"] : \"/\",\n ),\n trailingSlash,\n agents: consumer[\"agents\"] === true,\n },\n siteRoot: (build) => path.resolve(dir, outputDir, build, \"site\"),\n plainRoot: (build) => path.resolve(dir, outputDir, build, \"plain\"),\n };\n}\n\nfunction section(table: Record<string, unknown>, name: string): Record<string, unknown> {\n const value = table[name];\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {};\n}\n\n/** A base path with a leading and a trailing `/`, as Ascribe's router has it. */\nexport function normalizeBase(base: string): string {\n let result = base.trim();\n if (!result.startsWith(\"/\")) result = `/${result}`;\n if (!result.endsWith(\"/\")) result += \"/\";\n return result;\n}\n\n/**\n * The route of the site's root, Astro's `base`, ending the way `trailingSlash`\n * says the site's routes do. (`import.meta.env.BASE_URL` isn't replaced in a\n * package's components.)\n */\nexport function homeRoute(base: string, trailingSlash: \"always\" | \"never\" | \"ignore\"): string {\n const root = normalizeBase(base);\n return trailingSlash === \"never\" && root !== \"/\" ? root.slice(0, -1) : root;\n}\n\n/**\n * The ways `ascribe.toml`'s `[consumer]` disagrees with Astro's configuration,\n * as sentences. Ascribe writes every link with its own settings, so a site\n * whose routes differ would have broken links.\n */\nexport function consumerMismatches(\n project: ProjectInfo,\n astro: { base: string; trailingSlash: string; site: string | undefined },\n): string[] {\n const problems: string[] = [];\n const { basePath, trailingSlash, site } = project.consumer;\n if (normalizeBase(astro.base) !== basePath) {\n problems.push(\n `[consumer] base-path is \"${basePath}\", but Astro's \\`base\\` is \"${astro.base}\". Set them to the same path.`,\n );\n }\n // Astro's \"ignore\" serves both forms, so Ascribe's links work either way.\n if (astro.trailingSlash !== \"ignore\" && astro.trailingSlash !== trailingSlash) {\n problems.push(\n `[consumer] trailing-slash is \"${trailingSlash}\", but Astro's \\`trailingSlash\\` is \"${astro.trailingSlash}\".`,\n );\n }\n if (site !== undefined && astro.site !== undefined && origin(site) !== origin(astro.site)) {\n problems.push(`[consumer] site is \"${site}\", but Astro's \\`site\\` is \"${astro.site}\".`);\n }\n return problems;\n}\n\nfunction origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return url;\n }\n}\n"]}
1
+ {"version":3,"file":"project.js","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"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","sourcesContent":["// What the integration needs from `ascribe.toml`: where a build's site output\n// goes, and the consumer settings that must agree with `astro.config`.\n// The compiler validates the whole file; this reads\n// only what it compiled.\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { parse } from \"smol-toml\";\n\n/** The parts of an Ascribe project the integration reads. */\nexport interface ProjectInfo {\n /** The directory holding `ascribe.toml`. */\n dir: string;\n /** The path of `ascribe.toml`. */\n configPath: string;\n /** Source files and assets watched during development. */\n contentRoot: string;\n /** Generated output, never treated as a source change. */\n outputRoot: string;\n /** `[consumer]`, with the profile's defaults. */\n consumer: {\n site: string | undefined;\n basePath: string;\n trailingSlash: \"always\" | \"never\";\n /** Whether builds publish what agents read: `llms.txt` and each page's Markdown, in the plain output. */\n agents: boolean;\n };\n /** The site output's root for a build: `<output-dir>/<build>/site`. */\n siteRoot(build: string): string;\n /** The plain output's root for a build: `<output-dir>/<build>/plain`. */\n plainRoot(build: string): string;\n}\n\n/** Reads `ascribe.toml` in `dir`. Throws a readable error if it can't be read. */\nexport function readProject(dir: string): ProjectInfo {\n const configPath = path.join(dir, \"ascribe.toml\");\n let table: Record<string, unknown>;\n try {\n table = parse(readFileSync(configPath, \"utf8\"));\n } catch (error) {\n throw new Error(\n `@ascribed/astro can't read ${configPath}: ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n const project = section(table, \"project\");\n const consumer = section(table, \"consumer\");\n const outputDir =\n typeof project[\"output-dir\"] === \"string\" ? project[\"output-dir\"] : \".ascribe/build\";\n const contentDir = typeof project[\"content-root\"] === \"string\" ? project[\"content-root\"] : \"docs\";\n const trailingSlash = consumer[\"trailing-slash\"] === \"never\" ? \"never\" : \"always\";\n return {\n dir,\n configPath,\n contentRoot: path.resolve(dir, contentDir),\n outputRoot: path.resolve(dir, outputDir),\n consumer: {\n site: typeof consumer[\"site\"] === \"string\" ? consumer[\"site\"] : undefined,\n basePath: normalizeBase(\n typeof consumer[\"base-path\"] === \"string\" ? consumer[\"base-path\"] : \"/\",\n ),\n trailingSlash,\n agents: consumer[\"agents\"] === true,\n },\n siteRoot: (build) => path.resolve(dir, outputDir, build, \"site\"),\n plainRoot: (build) => path.resolve(dir, outputDir, build, \"plain\"),\n };\n}\n\nfunction section(table: Record<string, unknown>, name: string): Record<string, unknown> {\n const value = table[name];\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {};\n}\n\n/** A base path with a leading and a trailing `/`, as Ascribe's router has it. */\nexport function normalizeBase(base: string): string {\n let result = base.trim();\n if (!result.startsWith(\"/\")) result = `/${result}`;\n if (!result.endsWith(\"/\")) result += \"/\";\n return result;\n}\n\n/**\n * The route of a base path, ending the way `trailingSlash` says the site's\n * routes do: the site's root for Astro's `base`, a project's root for its\n * `[consumer] base-path`. (`import.meta.env.BASE_URL` isn't replaced in a\n * package's components.)\n */\nexport function homeRoute(base: string, trailingSlash: \"always\" | \"never\" | \"ignore\"): string {\n const root = normalizeBase(base);\n return trailingSlash === \"never\" && root !== \"/\" ? root.slice(0, -1) : root;\n}\n\n/**\n * The ways `ascribe.toml`'s `[consumer]` disagrees with Astro's configuration,\n * as sentences. Ascribe writes every link with its own settings, so a site\n * whose routes differ would have broken links.\n */\nexport function consumerMismatches(\n project: ProjectInfo,\n astro: { base: string; trailingSlash: string; site: string | undefined },\n): string[] {\n const problems: string[] = [];\n const { basePath, trailingSlash, site } = project.consumer;\n // A base path under Astro's `base` leaves the rest of the site to other pages, such as a home page.\n if (!basePath.startsWith(normalizeBase(astro.base))) {\n problems.push(\n `[consumer] base-path is \"${basePath}\", but Astro's \\`base\\` is \"${astro.base}\". Set base-path to that path or a path under it.`,\n );\n }\n // Astro's \"ignore\" serves both forms, so Ascribe's links work either way.\n if (astro.trailingSlash !== \"ignore\" && astro.trailingSlash !== trailingSlash) {\n problems.push(\n `[consumer] trailing-slash is \"${trailingSlash}\", but Astro's \\`trailingSlash\\` is \"${astro.trailingSlash}\".`,\n );\n }\n if (site !== undefined && astro.site !== undefined && origin(site) !== origin(astro.site)) {\n problems.push(`[consumer] site is \"${site}\", but Astro's \\`site\\` is \"${astro.site}\".`);\n }\n return problems;\n}\n\nfunction origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return url;\n }\n}\n"]}
@@ -0,0 +1,44 @@
1
+ import { type PermalinkList } from "./permalinks.js";
2
+ import { type ProjectInfo } from "./project.js";
3
+ /** One entry of the integration's `projects` option. */
4
+ export interface ProjectEntry {
5
+ /** The entry's name: its collection's, `review`'s, and its schema folder's. */
6
+ name: string;
7
+ /** The build whose site output is the collection (a name in `ascribe.toml`). */
8
+ build: string;
9
+ /** The directory holding `ascribe.toml`, relative to the Astro root. Default: the root. */
10
+ project?: string;
11
+ }
12
+ /** The ways the entries' names are wrong, as sentences. */
13
+ export declare function nameProblems(entries: readonly ProjectEntry[]): string[];
14
+ /** An entry with its project read. */
15
+ export interface ResolvedEntry {
16
+ name: string;
17
+ build: string;
18
+ project: ProjectInfo;
19
+ }
20
+ /** Two projects publishing at one base path, as sentences. */
21
+ export declare function basePathClashes(entries: readonly ResolvedEntry[]): string[];
22
+ /**
23
+ * A page's entry id, as Astro's `glob` loader computes it from the page's
24
+ * path in the site output: each segment slugged, without the extension, and
25
+ * without a final `/index`.
26
+ */
27
+ export declare function entryId(pagePath: string): string;
28
+ /** The pages of a build's site output, by their paths in it, from its manifest. */
29
+ export declare function sitePages(project: ProjectInfo, build: string): string[];
30
+ /**
31
+ * A page of one project whose route falls under another project's base path,
32
+ * as sentences: one project at `/` with another at `/docs/` is allowed, and a
33
+ * page of the first at `/docs/…` is not. `pages` gives an entry's pages.
34
+ */
35
+ export declare function pagesUnderOtherPaths(entries: readonly ResolvedEntry[], pages?: (entry: ResolvedEntry) => readonly string[]): string[];
36
+ /**
37
+ * The problems with serving every entry's permalinks from one route, as
38
+ * sentences: the lists that name anything must be published at one path (an
39
+ * empty one, which every build writes, can be anywhere), and two projects may
40
+ * not declare one name, since neither build can see the other. Entries of one
41
+ * project may: the first entry's wins. `lists` holds each entry's list, or
42
+ * `undefined` where its build wrote none.
43
+ */
44
+ export declare function permalinkProblems(entries: readonly ResolvedEntry[], lists: readonly (PermalinkList | undefined)[]): string[];
@@ -0,0 +1,140 @@
1
+ // Several Ascribe projects in one Astro site: the entries of `projects`, and
2
+ // the rules for what they share. Each entry has a name, which is what a
3
+ // collection, `review`, a schema's folder, and an error message say. Two
4
+ // entries may share a project and differ by build; entries of one project are
5
+ // one project to these rules, since they share its `ascribe.toml`.
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { slug } from "github-slugger";
9
+ import { hasPermalinks } from "./permalinks.js";
10
+ import { normalizeBase } from "./project.js";
11
+ /** What a name may be: letters, digits, `-`, and `_`, starting with a letter. */
12
+ const NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
13
+ /** The ways the entries' names are wrong, as sentences. */
14
+ export function nameProblems(entries) {
15
+ const problems = [];
16
+ if (entries.length === 0)
17
+ problems.push("`projects` is empty. Give it an entry for each project.");
18
+ const seen = new Set();
19
+ for (const entry of entries) {
20
+ if (typeof entry.name !== "string" || !NAME.test(entry.name)) {
21
+ problems.push(`an entry's name is ${JSON.stringify(entry.name)}. A name has letters, digits, "-", and "_", and starts with a letter.`);
22
+ }
23
+ else if (seen.has(entry.name)) {
24
+ problems.push(`two entries are named "${entry.name}". Each entry's name must be its own.`);
25
+ }
26
+ seen.add(entry.name);
27
+ }
28
+ return problems;
29
+ }
30
+ /** The entries of distinct projects, each project once with its first entry's name. */
31
+ function distinctProjects(entries) {
32
+ const byDir = new Map();
33
+ for (const entry of entries)
34
+ if (!byDir.has(entry.project.dir))
35
+ byDir.set(entry.project.dir, entry);
36
+ return [...byDir.values()];
37
+ }
38
+ /** Two projects publishing at one base path, as sentences. */
39
+ export function basePathClashes(entries) {
40
+ const problems = [];
41
+ const projects = distinctProjects(entries);
42
+ for (const [i, a] of projects.entries()) {
43
+ for (const b of projects.slice(i + 1)) {
44
+ if (a.project.consumer.basePath === b.project.consumer.basePath) {
45
+ problems.push(`"${a.name}" and "${b.name}" both have [consumer] base-path "${a.project.consumer.basePath}". Give each project its own.`);
46
+ }
47
+ }
48
+ }
49
+ return problems;
50
+ }
51
+ /**
52
+ * A page's entry id, as Astro's `glob` loader computes it from the page's
53
+ * path in the site output: each segment slugged, without the extension, and
54
+ * without a final `/index`.
55
+ */
56
+ export function entryId(pagePath) {
57
+ return pagePath
58
+ .replace(/\.md$/, "")
59
+ .split("/")
60
+ .map((segment) => slug(segment))
61
+ .join("/")
62
+ .replace(/\/index$/, "");
63
+ }
64
+ /** The pages of a build's site output, by their paths in it, from its manifest. */
65
+ export function sitePages(project, build) {
66
+ const manifest = path.join(path.dirname(project.siteRoot(build)), "site.manifest.json");
67
+ let files;
68
+ try {
69
+ files = JSON.parse(readFileSync(manifest, "utf8")).files;
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ if (!Array.isArray(files))
75
+ return [];
76
+ return files
77
+ .filter((file) => file.kind === "page" && typeof file.path === "string")
78
+ .map((file) => file.path);
79
+ }
80
+ /**
81
+ * A page of one project whose route falls under another project's base path,
82
+ * as sentences: one project at `/` with another at `/docs/` is allowed, and a
83
+ * page of the first at `/docs/…` is not. `pages` gives an entry's pages.
84
+ */
85
+ export function pagesUnderOtherPaths(entries, pages = (entry) => sitePages(entry.project, entry.build)) {
86
+ const problems = [];
87
+ const projects = distinctProjects(entries);
88
+ for (const a of entries) {
89
+ const base = a.project.consumer.basePath;
90
+ const inner = projects.filter((b) => b.project.dir !== a.project.dir &&
91
+ b.project.consumer.basePath !== base &&
92
+ b.project.consumer.basePath.startsWith(base));
93
+ if (inner.length === 0)
94
+ continue;
95
+ for (const page of pages(a)) {
96
+ const id = entryId(page);
97
+ const route = id === "index" ? base : `${base}${id}/`;
98
+ const under = inner.find((b) => route.startsWith(b.project.consumer.basePath));
99
+ if (under !== undefined) {
100
+ problems.push(`the page ${page} of "${a.name}" is at ${route}, under the base path of "${under.name}", ${under.project.consumer.basePath}. Move the page, or give "${under.name}" another base path.`);
101
+ }
102
+ }
103
+ }
104
+ return problems;
105
+ }
106
+ /**
107
+ * The problems with serving every entry's permalinks from one route, as
108
+ * sentences: the lists that name anything must be published at one path (an
109
+ * empty one, which every build writes, can be anywhere), and two projects may
110
+ * not declare one name, since neither build can see the other. Entries of one
111
+ * project may: the first entry's wins. `lists` holds each entry's list, or
112
+ * `undefined` where its build wrote none.
113
+ */
114
+ export function permalinkProblems(entries, lists) {
115
+ const problems = [];
116
+ let first;
117
+ const owners = new Map();
118
+ for (const [i, entry] of entries.entries()) {
119
+ const list = lists[i];
120
+ if (list === undefined || !hasPermalinks(list))
121
+ continue;
122
+ const at = normalizeBase(list.path);
123
+ if (first === undefined)
124
+ first = { entry, path: at };
125
+ else if (at !== first.path) {
126
+ problems.push(`"${first.entry.name}" publishes its permalinks at ${first.path}, and "${entry.name}" at ${at}. One route serves them all: set [consumer] permalink-path to one path in both.`);
127
+ continue;
128
+ }
129
+ for (const name of [...Object.keys(list.permalinks), ...Object.keys(list.removed ?? {})]) {
130
+ const owner = owners.get(name);
131
+ if (owner === undefined)
132
+ owners.set(name, entry);
133
+ else if (owner.project.dir !== entry.project.dir) {
134
+ problems.push(`"${owner.name}" and "${entry.name}" both declare the permalink "${name}". A name leads to one place: rename or retire it in one of them.`);
135
+ }
136
+ }
137
+ }
138
+ return problems;
139
+ }
140
+ //# sourceMappingURL=projects.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projects.js","sourceRoot":"","sources":["../src/projects.ts"],"names":[],"mappings":"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","sourcesContent":["// Several Ascribe projects in one Astro site: the entries of `projects`, and\n// the rules for what they share. Each entry has a name, which is what a\n// collection, `review`, a schema's folder, and an error message say. Two\n// entries may share a project and differ by build; entries of one project are\n// one project to these rules, since they share its `ascribe.toml`.\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { slug } from \"github-slugger\";\nimport { hasPermalinks, type PermalinkList } from \"./permalinks.js\";\nimport { normalizeBase, type ProjectInfo } from \"./project.js\";\n\n/** One entry of the integration's `projects` option. */\nexport interface ProjectEntry {\n /** The entry's name: its collection's, `review`'s, and its schema folder's. */\n name: string;\n /** The build whose site output is the collection (a name in `ascribe.toml`). */\n build: string;\n /** The directory holding `ascribe.toml`, relative to the Astro root. Default: the root. */\n project?: string;\n}\n\n/** What a name may be: letters, digits, `-`, and `_`, starting with a letter. */\nconst NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;\n\n/** The ways the entries' names are wrong, as sentences. */\nexport function nameProblems(entries: readonly ProjectEntry[]): string[] {\n const problems: string[] = [];\n if (entries.length === 0)\n problems.push(\"`projects` is empty. Give it an entry for each project.\");\n const seen = new Set<string>();\n for (const entry of entries) {\n if (typeof entry.name !== \"string\" || !NAME.test(entry.name)) {\n problems.push(\n `an entry's name is ${JSON.stringify(entry.name)}. A name has letters, digits, \"-\", and \"_\", and starts with a letter.`,\n );\n } else if (seen.has(entry.name)) {\n problems.push(`two entries are named \"${entry.name}\". Each entry's name must be its own.`);\n }\n seen.add(entry.name);\n }\n return problems;\n}\n\n/** An entry with its project read. */\nexport interface ResolvedEntry {\n name: string;\n build: string;\n project: ProjectInfo;\n}\n\n/** The entries of distinct projects, each project once with its first entry's name. */\nfunction distinctProjects(entries: readonly ResolvedEntry[]): ResolvedEntry[] {\n const byDir = new Map<string, ResolvedEntry>();\n for (const entry of entries)\n if (!byDir.has(entry.project.dir)) byDir.set(entry.project.dir, entry);\n return [...byDir.values()];\n}\n\n/** Two projects publishing at one base path, as sentences. */\nexport function basePathClashes(entries: readonly ResolvedEntry[]): string[] {\n const problems: string[] = [];\n const projects = distinctProjects(entries);\n for (const [i, a] of projects.entries()) {\n for (const b of projects.slice(i + 1)) {\n if (a.project.consumer.basePath === b.project.consumer.basePath) {\n problems.push(\n `\"${a.name}\" and \"${b.name}\" both have [consumer] base-path \"${a.project.consumer.basePath}\". Give each project its own.`,\n );\n }\n }\n }\n return problems;\n}\n\n/**\n * A page's entry id, as Astro's `glob` loader computes it from the page's\n * path in the site output: each segment slugged, without the extension, and\n * without a final `/index`.\n */\nexport function entryId(pagePath: string): string {\n return pagePath\n .replace(/\\.md$/, \"\")\n .split(\"/\")\n .map((segment) => slug(segment))\n .join(\"/\")\n .replace(/\\/index$/, \"\");\n}\n\n/** The pages of a build's site output, by their paths in it, from its manifest. */\nexport function sitePages(project: ProjectInfo, build: string): string[] {\n const manifest = path.join(path.dirname(project.siteRoot(build)), \"site.manifest.json\");\n let files: unknown;\n try {\n files = (JSON.parse(readFileSync(manifest, \"utf8\")) as { files?: unknown }).files;\n } catch {\n return [];\n }\n if (!Array.isArray(files)) return [];\n return (files as { kind?: unknown; path?: unknown }[])\n .filter((file) => file.kind === \"page\" && typeof file.path === \"string\")\n .map((file) => file.path as string);\n}\n\n/**\n * A page of one project whose route falls under another project's base path,\n * as sentences: one project at `/` with another at `/docs/` is allowed, and a\n * page of the first at `/docs/…` is not. `pages` gives an entry's pages.\n */\nexport function pagesUnderOtherPaths(\n entries: readonly ResolvedEntry[],\n pages: (entry: ResolvedEntry) => readonly string[] = (entry) =>\n sitePages(entry.project, entry.build),\n): string[] {\n const problems: string[] = [];\n const projects = distinctProjects(entries);\n for (const a of entries) {\n const base = a.project.consumer.basePath;\n const inner = projects.filter(\n (b) =>\n b.project.dir !== a.project.dir &&\n b.project.consumer.basePath !== base &&\n b.project.consumer.basePath.startsWith(base),\n );\n if (inner.length === 0) continue;\n for (const page of pages(a)) {\n const id = entryId(page);\n const route = id === \"index\" ? base : `${base}${id}/`;\n const under = inner.find((b) => route.startsWith(b.project.consumer.basePath));\n if (under !== undefined) {\n problems.push(\n `the page ${page} of \"${a.name}\" is at ${route}, under the base path of \"${under.name}\", ${under.project.consumer.basePath}. Move the page, or give \"${under.name}\" another base path.`,\n );\n }\n }\n }\n return problems;\n}\n\n/**\n * The problems with serving every entry's permalinks from one route, as\n * sentences: the lists that name anything must be published at one path (an\n * empty one, which every build writes, can be anywhere), and two projects may\n * not declare one name, since neither build can see the other. Entries of one\n * project may: the first entry's wins. `lists` holds each entry's list, or\n * `undefined` where its build wrote none.\n */\nexport function permalinkProblems(\n entries: readonly ResolvedEntry[],\n lists: readonly (PermalinkList | undefined)[],\n): string[] {\n const problems: string[] = [];\n let first: { entry: ResolvedEntry; path: string } | undefined;\n const owners = new Map<string, ResolvedEntry>();\n for (const [i, entry] of entries.entries()) {\n const list = lists[i];\n if (list === undefined || !hasPermalinks(list)) continue;\n const at = normalizeBase(list.path);\n if (first === undefined) first = { entry, path: at };\n else if (at !== first.path) {\n problems.push(\n `\"${first.entry.name}\" publishes its permalinks at ${first.path}, and \"${entry.name}\" at ${at}. One route serves them all: set [consumer] permalink-path to one path in both.`,\n );\n continue;\n }\n for (const name of [...Object.keys(list.permalinks), ...Object.keys(list.removed ?? {})]) {\n const owner = owners.get(name);\n if (owner === undefined) owners.set(name, entry);\n else if (owner.project.dir !== entry.project.dir) {\n problems.push(\n `\"${owner.name}\" and \"${entry.name}\" both declare the permalink \"${name}\". A name leads to one place: rename or retire it in one of them.`,\n );\n }\n }\n }\n return problems;\n}\n"]}
@@ -15,7 +15,12 @@ export declare function siteUrl(address: AddressInfo, options: {
15
15
  https: boolean;
16
16
  base: string;
17
17
  }): string;
18
- /** Writes `dev.json`. A failure is the caller's to log: it only costs the editor its link. */
18
+ /**
19
+ * Writes `dev.json`. A failure is the caller's to log: it only costs the editor its link.
20
+ * `.ascribe` or `dev.json` that's a link (a symbolic link, or a junction on Windows, which
21
+ * Node's `lstat` reports as one) is refused, not written through: a committed link could
22
+ * lead anywhere.
23
+ */
19
24
  export declare function writeDevFile(projectDir: string, file: DevFile): void;
20
25
  /** Removes `dev.json` if this process wrote it. */
21
26
  export declare function removeDevFile(projectDir: string, pid: number): void;
@@ -1,7 +1,7 @@
1
1
  // `.ascribe/dev.json`: where the dev server is, for the editor's Open Site
2
2
  // Preview. Written when the server starts, removed when it stops. A server
3
3
  // that crashed leaves it behind, so a reader checks the URL answers first.
4
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  /** `<project>/.ascribe/dev.json`. */
7
7
  export function devFilePath(projectDir) {
@@ -17,9 +17,19 @@ export function siteUrl(address, options) {
17
17
  const base = options.base.endsWith("/") ? options.base : `${options.base}/`;
18
18
  return `${options.https ? "https" : "http"}://${host}:${address.port}${base.startsWith("/") ? base : `/${base}`}`;
19
19
  }
20
- /** Writes `dev.json`. A failure is the caller's to log: it only costs the editor its link. */
20
+ /**
21
+ * Writes `dev.json`. A failure is the caller's to log: it only costs the editor its link.
22
+ * `.ascribe` or `dev.json` that's a link (a symbolic link, or a junction on Windows, which
23
+ * Node's `lstat` reports as one) is refused, not written through: a committed link could
24
+ * lead anywhere.
25
+ */
21
26
  export function writeDevFile(projectDir, file) {
22
27
  const target = devFilePath(projectDir);
28
+ for (const at of [path.dirname(target), target]) {
29
+ if (lstatSync(at, { throwIfNoEntry: false })?.isSymbolicLink()) {
30
+ throw new Error(`${at} is a link; replace it with a folder or a file, or remove it`);
31
+ }
32
+ }
23
33
  mkdirSync(path.dirname(target), { recursive: true });
24
34
  writeFileSync(target, `${JSON.stringify(file, null, 2)}\n`);
25
35
  }
@@ -1 +1 @@
1
- {"version":3,"file":"devfile.js","sourceRoot":"","sources":["../../src/review/devfile.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,2EAA2E;AAC3E,2EAA2E;AAC3E,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAEzE,OAAO,IAAI,MAAM,WAAW,CAAC;AAY7B,qCAAqC;AACrC,MAAM,UAAU,WAAW,CAAC,UAAkB;IAC5C,OAAO,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,UAAU,CAAC,CAAC;AACvD,CAAC;AAED,wFAAwF;AACxF,MAAM,UAAU,OAAO,CAAC,OAAoB,EAAE,OAAyC;IACrF,IAAI,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC;IAC3B,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,IAAI,GAAG,WAAW,CAAC;SAC5E,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,IAAI,GAAG,IAAI,IAAI,GAAG,CAAC;IAChD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,GAAG,CAAC;IAC5E,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,MAAM,IAAI,IAAI,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;AACpH,CAAC;AAED,8FAA8F;AAC9F,MAAM,UAAU,YAAY,CAAC,UAAkB,EAAE,IAAa;IAC5D,MAAM,MAAM,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IACvC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACrD,aAAa,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;AAC9D,CAAC;AAED,mDAAmD;AACnD,MAAM,UAAU,aAAa,CAAC,UAAkB,EAAE,GAAW;IAC3D,MAAM,MAAM,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IACvC,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAAqB,CAAC;QAC7E,IAAI,OAAO,CAAC,GAAG,KAAK,GAAG;YAAE,OAAO;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO;IACT,CAAC;IACD,MAAM,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;AAClC,CAAC","sourcesContent":["// `.ascribe/dev.json`: where the dev server is, for the editor's Open Site\n// Preview. Written when the server starts, removed when it stops. A server\n// that crashed leaves it behind, so a reader checks the URL answers first.\nimport { mkdirSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport type { AddressInfo } from \"node:net\";\nimport path from \"node:path\";\n\n/** What `dev.json` holds. */\nexport interface DevFile {\n /** The site's address: origin and base path, `http://localhost:4321/docs/`. */\n url: string;\n /** The build the dev server shows. */\n build: string;\n /** The dev server's process, so another server's file isn't removed. */\n pid: number;\n}\n\n/** `<project>/.ascribe/dev.json`. */\nexport function devFilePath(projectDir: string): string {\n return path.join(projectDir, \".ascribe\", \"dev.json\");\n}\n\n/** The site's address from the server's: a wildcard or loopback host is `localhost`. */\nexport function siteUrl(address: AddressInfo, options: { https: boolean; base: string }): string {\n let host = address.address;\n if ([\"::\", \"0.0.0.0\", \"::1\", \"\", \"localhost\"].includes(host)) host = \"localhost\";\n else if (host.includes(\":\")) host = `[${host}]`;\n const base = options.base.endsWith(\"/\") ? options.base : `${options.base}/`;\n return `${options.https ? \"https\" : \"http\"}://${host}:${address.port}${base.startsWith(\"/\") ? base : `/${base}`}`;\n}\n\n/** Writes `dev.json`. A failure is the caller's to log: it only costs the editor its link. */\nexport function writeDevFile(projectDir: string, file: DevFile): void {\n const target = devFilePath(projectDir);\n mkdirSync(path.dirname(target), { recursive: true });\n writeFileSync(target, `${JSON.stringify(file, null, 2)}\\n`);\n}\n\n/** Removes `dev.json` if this process wrote it. */\nexport function removeDevFile(projectDir: string, pid: number): void {\n const target = devFilePath(projectDir);\n try {\n const current = JSON.parse(readFileSync(target, \"utf8\")) as Partial<DevFile>;\n if (current.pid !== pid) return;\n } catch {\n return;\n }\n rmSync(target, { force: true });\n}\n"]}
1
+ {"version":3,"file":"devfile.js","sourceRoot":"","sources":["../../src/review/devfile.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,2EAA2E;AAC3E,2EAA2E;AAC3E,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAEpF,OAAO,IAAI,MAAM,WAAW,CAAC;AAY7B,qCAAqC;AACrC,MAAM,UAAU,WAAW,CAAC,UAAkB;IAC5C,OAAO,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,UAAU,CAAC,CAAC;AACvD,CAAC;AAED,wFAAwF;AACxF,MAAM,UAAU,OAAO,CAAC,OAAoB,EAAE,OAAyC;IACrF,IAAI,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC;IAC3B,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,IAAI,GAAG,WAAW,CAAC;SAC5E,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,IAAI,GAAG,IAAI,IAAI,GAAG,CAAC;IAChD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,GAAG,CAAC;IAC5E,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,MAAM,IAAI,IAAI,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;AACpH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,UAAkB,EAAE,IAAa;IAC5D,MAAM,MAAM,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IACvC,KAAK,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,EAAE,CAAC;QAChD,IAAI,SAAS,CAAC,EAAE,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,cAAc,EAAE,EAAE,CAAC;YAC/D,MAAM,IAAI,KAAK,CAAC,GAAG,EAAE,8DAA8D,CAAC,CAAC;QACvF,CAAC;IACH,CAAC;IACD,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACrD,aAAa,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;AAC9D,CAAC;AAED,mDAAmD;AACnD,MAAM,UAAU,aAAa,CAAC,UAAkB,EAAE,GAAW;IAC3D,MAAM,MAAM,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IACvC,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAAqB,CAAC;QAC7E,IAAI,OAAO,CAAC,GAAG,KAAK,GAAG;YAAE,OAAO;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO;IACT,CAAC;IACD,MAAM,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;AAClC,CAAC","sourcesContent":["// `.ascribe/dev.json`: where the dev server is, for the editor's Open Site\n// Preview. Written when the server starts, removed when it stops. A server\n// that crashed leaves it behind, so a reader checks the URL answers first.\nimport { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport type { AddressInfo } from \"node:net\";\nimport path from \"node:path\";\n\n/** What `dev.json` holds. */\nexport interface DevFile {\n /** The site's address: origin and base path, `http://localhost:4321/docs/`. */\n url: string;\n /** The build the dev server shows. */\n build: string;\n /** The dev server's process, so another server's file isn't removed. */\n pid: number;\n}\n\n/** `<project>/.ascribe/dev.json`. */\nexport function devFilePath(projectDir: string): string {\n return path.join(projectDir, \".ascribe\", \"dev.json\");\n}\n\n/** The site's address from the server's: a wildcard or loopback host is `localhost`. */\nexport function siteUrl(address: AddressInfo, options: { https: boolean; base: string }): string {\n let host = address.address;\n if ([\"::\", \"0.0.0.0\", \"::1\", \"\", \"localhost\"].includes(host)) host = \"localhost\";\n else if (host.includes(\":\")) host = `[${host}]`;\n const base = options.base.endsWith(\"/\") ? options.base : `${options.base}/`;\n return `${options.https ? \"https\" : \"http\"}://${host}:${address.port}${base.startsWith(\"/\") ? base : `/${base}`}`;\n}\n\n/**\n * Writes `dev.json`. A failure is the caller's to log: it only costs the editor its link.\n * `.ascribe` or `dev.json` that's a link (a symbolic link, or a junction on Windows, which\n * Node's `lstat` reports as one) is refused, not written through: a committed link could\n * lead anywhere.\n */\nexport function writeDevFile(projectDir: string, file: DevFile): void {\n const target = devFilePath(projectDir);\n for (const at of [path.dirname(target), target]) {\n if (lstatSync(at, { throwIfNoEntry: false })?.isSymbolicLink()) {\n throw new Error(`${at} is a link; replace it with a folder or a file, or remove it`);\n }\n }\n mkdirSync(path.dirname(target), { recursive: true });\n writeFileSync(target, `${JSON.stringify(file, null, 2)}\\n`);\n}\n\n/** Removes `dev.json` if this process wrote it. */\nexport function removeDevFile(projectDir: string, pid: number): void {\n const target = devFilePath(projectDir);\n try {\n const current = JSON.parse(readFileSync(target, \"utf8\")) as Partial<DevFile>;\n if (current.pid !== pid) return;\n } catch {\n return;\n }\n rmSync(target, { force: true });\n}\n"]}
package/dist/run.d.ts CHANGED
@@ -18,8 +18,8 @@ export declare function hasWarnings(diagnostics: string): boolean;
18
18
  * them in `astro dev`.
19
19
  */
20
20
  export declare function anchorsFor(anchors: boolean | "dev" | undefined, command: string, review?: boolean): boolean;
21
- /** Whether an Astro command has review in the site preview, given the `review` option. */
22
- export declare function reviewFor(review: boolean | undefined, command: string): boolean;
21
+ /** Whether an Astro command has review in the site preview, given the `review` option (an entry's name is on). */
22
+ export declare function reviewFor(review: boolean | string | undefined, command: string): boolean;
23
23
  /**
24
24
  * Builds one build's outputs, the site output by default. Rejects, with the compiler's report as the
25
25
  * message, if the build has errors: the Astro build fails with them.
package/dist/run.js CHANGED
@@ -26,7 +26,7 @@ export function hasWarnings(diagnostics) {
26
26
  export function anchorsFor(anchors, command, review = false) {
27
27
  return anchors === true || (command === "dev" && (anchors === "dev" || review));
28
28
  }
29
- /** Whether an Astro command has review in the site preview, given the `review` option. */
29
+ /** Whether an Astro command has review in the site preview, given the `review` option (an entry's name is on). */
30
30
  export function reviewFor(review, command) {
31
31
  return command === "dev" && review !== false;
32
32
  }
package/dist/run.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"run.js","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,sCAAsC;AACtC,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAU9C;;;GAGG;AACH,MAAM,OAAO,GAAG,oEAAoE,CAAC;AAErF;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,WAAmB;IAC7C,IAAI,WAAW,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IACrC,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAC7E,OAAO,OAAO,KAAK,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC;AACtE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CACxB,OAAoC,EACpC,OAAe,EACf,MAAM,GAAG,KAAK;IAEd,OAAO,OAAO,KAAK,IAAI,IAAI,CAAC,OAAO,KAAK,KAAK,IAAI,CAAC,OAAO,KAAK,KAAK,IAAI,MAAM,CAAC,CAAC,CAAC;AAClF,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,SAAS,CAAC,MAA2B,EAAE,OAAe;IACpE,OAAO,OAAO,KAAK,KAAK,IAAI,MAAM,KAAK,KAAK,CAAC;AAC/C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAC,OASxB;IACC,MAAM,IAAI,GAAG;QACX,OAAO;QACP,QAAQ;QACR,CAAC,OAAO,CAAC,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;QACvC,SAAS;QACT,OAAO,CAAC,KAAK;QACb,UAAU;QACV,OAAO,CAAC,UAAU;QAClB,SAAS;QACT,OAAO;KACR,CAAC;IACF,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI;QAAE,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IACrD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,QAAQ,CACN,OAAO,CAAC,MAAM,EACd,IAAI,EACJ,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,EAAE,GAAG,IAAI,GAAG,IAAI,EAAE,EACjD,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;YACxB,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;gBACnB,MAAM,MAAM,GAAG,GAAG,MAAM,GAAG,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC;gBAC3C,MAAM,CACJ,IAAI,KAAK,CACP,2BAA2B,OAAO,CAAC,KAAK,YAAY,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,MAAM,MAAM,EAAE,EAAE,EAC3G,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CACF,CAAC;gBACF,OAAO;YACT,CAAC;YACD,OAAO,CAAC,EAAE,WAAW,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAClE,CAAC,CACF,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["// Running `ascribe build` for the configured build: its site output, and\n// its JSON output while review is on.\nimport { execFile } from \"node:child_process\";\n\n/** What a successful build printed. */\nexport interface BuildResult {\n /** The checks' report, which is more than a summary line when there is anything to report. */\n diagnostics: string;\n /** The `built <build>/site: …` lines. */\n summary: string;\n}\n\n/**\n * A check's summary line, the report's last: `checked 14 files: 0 errors, 0 warnings`,\n * with `, 3 advice` after when there is advice.\n */\nconst SUMMARY = /^checked [^:\\n]*: (\\d+) errors?, (\\d+) warnings?(?:, \\d+ advice)?$/;\n\n/**\n * Whether a build's report is worth a warning: anything but a report whose\n * summary line counts no errors and no warnings, which is logged at info\n * level, advice and all.\n */\nexport function hasWarnings(diagnostics: string): boolean {\n if (diagnostics === \"\") return false;\n const summary = SUMMARY.exec(diagnostics.trimEnd().split(\"\\n\").at(-1) ?? \"\");\n return summary === null || summary[1] !== \"0\" || summary[2] !== \"0\";\n}\n\n/**\n * Whether an Astro command builds with source anchors, given the integration's\n * `anchors` option: `\"dev\"` means in `astro dev` only, so `astro build` has\n * them only when it is `true`. Review in the site preview (`review`) needs\n * them in `astro dev`.\n */\nexport function anchorsFor(\n anchors: boolean | \"dev\" | undefined,\n command: string,\n review = false,\n): boolean {\n return anchors === true || (command === \"dev\" && (anchors === \"dev\" || review));\n}\n\n/** Whether an Astro command has review in the site preview, given the `review` option. */\nexport function reviewFor(review: boolean | undefined, command: string): boolean {\n return command === \"dev\" && review !== false;\n}\n\n/**\n * Builds one build's outputs, the site output by default. Rejects, with the compiler's report as the\n * message, if the build has errors: the Astro build fails with them.\n */\nexport function runBuild(options: {\n binary: string;\n configPath: string;\n build: string;\n cwd: string;\n /** Write source anchors (`--anchors`). */\n anchors?: boolean;\n /** The outputs to write. Default: `[\"site\"]`. */\n outputs?: readonly (\"site\" | \"plain\" | \"json\")[];\n}): Promise<BuildResult> {\n const args = [\n \"build\",\n \"--emit\",\n (options.outputs ?? [\"site\"]).join(\",\"),\n \"--build\",\n options.build,\n \"--config\",\n options.configPath,\n \"--color\",\n \"never\",\n ];\n if (options.anchors === true) args.push(\"--anchors\");\n return new Promise((resolve, reject) => {\n execFile(\n options.binary,\n args,\n { cwd: options.cwd, maxBuffer: 64 * 1024 * 1024 },\n (error, stdout, stderr) => {\n if (error !== null) {\n const report = `${stdout}${stderr}`.trim();\n reject(\n new Error(\n `\\`ascribe build --build ${options.build}\\` failed${report === \"\" ? `: ${error.message}` : `:\\n${report}`}`,\n { cause: error },\n ),\n );\n return;\n }\n resolve({ diagnostics: stdout.trim(), summary: stderr.trim() });\n },\n );\n });\n}\n"]}
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,sCAAsC;AACtC,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAU9C;;;GAGG;AACH,MAAM,OAAO,GAAG,oEAAoE,CAAC;AAErF;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,WAAmB;IAC7C,IAAI,WAAW,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IACrC,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAC7E,OAAO,OAAO,KAAK,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC;AACtE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CACxB,OAAoC,EACpC,OAAe,EACf,MAAM,GAAG,KAAK;IAEd,OAAO,OAAO,KAAK,IAAI,IAAI,CAAC,OAAO,KAAK,KAAK,IAAI,CAAC,OAAO,KAAK,KAAK,IAAI,MAAM,CAAC,CAAC,CAAC;AAClF,CAAC;AAED,kHAAkH;AAClH,MAAM,UAAU,SAAS,CAAC,MAAoC,EAAE,OAAe;IAC7E,OAAO,OAAO,KAAK,KAAK,IAAI,MAAM,KAAK,KAAK,CAAC;AAC/C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAC,OASxB;IACC,MAAM,IAAI,GAAG;QACX,OAAO;QACP,QAAQ;QACR,CAAC,OAAO,CAAC,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;QACvC,SAAS;QACT,OAAO,CAAC,KAAK;QACb,UAAU;QACV,OAAO,CAAC,UAAU;QAClB,SAAS;QACT,OAAO;KACR,CAAC;IACF,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI;QAAE,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IACrD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,QAAQ,CACN,OAAO,CAAC,MAAM,EACd,IAAI,EACJ,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,EAAE,GAAG,IAAI,GAAG,IAAI,EAAE,EACjD,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;YACxB,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;gBACnB,MAAM,MAAM,GAAG,GAAG,MAAM,GAAG,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC;gBAC3C,MAAM,CACJ,IAAI,KAAK,CACP,2BAA2B,OAAO,CAAC,KAAK,YAAY,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,MAAM,MAAM,EAAE,EAAE,EAC3G,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CACF,CAAC;gBACF,OAAO;YACT,CAAC;YACD,OAAO,CAAC,EAAE,WAAW,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAClE,CAAC,CACF,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["// Running `ascribe build` for the configured build: its site output, and\n// its JSON output while review is on.\nimport { execFile } from \"node:child_process\";\n\n/** What a successful build printed. */\nexport interface BuildResult {\n /** The checks' report, which is more than a summary line when there is anything to report. */\n diagnostics: string;\n /** The `built <build>/site: …` lines. */\n summary: string;\n}\n\n/**\n * A check's summary line, the report's last: `checked 14 files: 0 errors, 0 warnings`,\n * with `, 3 advice` after when there is advice.\n */\nconst SUMMARY = /^checked [^:\\n]*: (\\d+) errors?, (\\d+) warnings?(?:, \\d+ advice)?$/;\n\n/**\n * Whether a build's report is worth a warning: anything but a report whose\n * summary line counts no errors and no warnings, which is logged at info\n * level, advice and all.\n */\nexport function hasWarnings(diagnostics: string): boolean {\n if (diagnostics === \"\") return false;\n const summary = SUMMARY.exec(diagnostics.trimEnd().split(\"\\n\").at(-1) ?? \"\");\n return summary === null || summary[1] !== \"0\" || summary[2] !== \"0\";\n}\n\n/**\n * Whether an Astro command builds with source anchors, given the integration's\n * `anchors` option: `\"dev\"` means in `astro dev` only, so `astro build` has\n * them only when it is `true`. Review in the site preview (`review`) needs\n * them in `astro dev`.\n */\nexport function anchorsFor(\n anchors: boolean | \"dev\" | undefined,\n command: string,\n review = false,\n): boolean {\n return anchors === true || (command === \"dev\" && (anchors === \"dev\" || review));\n}\n\n/** Whether an Astro command has review in the site preview, given the `review` option (an entry's name is on). */\nexport function reviewFor(review: boolean | string | undefined, command: string): boolean {\n return command === \"dev\" && review !== false;\n}\n\n/**\n * Builds one build's outputs, the site output by default. Rejects, with the compiler's report as the\n * message, if the build has errors: the Astro build fails with them.\n */\nexport function runBuild(options: {\n binary: string;\n configPath: string;\n build: string;\n cwd: string;\n /** Write source anchors (`--anchors`). */\n anchors?: boolean;\n /** The outputs to write. Default: `[\"site\"]`. */\n outputs?: readonly (\"site\" | \"plain\" | \"json\")[];\n}): Promise<BuildResult> {\n const args = [\n \"build\",\n \"--emit\",\n (options.outputs ?? [\"site\"]).join(\",\"),\n \"--build\",\n options.build,\n \"--config\",\n options.configPath,\n \"--color\",\n \"never\",\n ];\n if (options.anchors === true) args.push(\"--anchors\");\n return new Promise((resolve, reject) => {\n execFile(\n options.binary,\n args,\n { cwd: options.cwd, maxBuffer: 64 * 1024 * 1024 },\n (error, stdout, stderr) => {\n if (error !== null) {\n const report = `${stdout}${stderr}`.trim();\n reject(\n new Error(\n `\\`ascribe build --build ${options.build}\\` failed${report === \"\" ? `: ${error.message}` : `:\\n${report}`}`,\n { cause: error },\n ),\n );\n return;\n }\n resolve({ diagnostics: stdout.trim(), summary: stderr.trim() });\n },\n );\n });\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascribed/astro",
3
- "version": "0.2.1-next.13",
3
+ "version": "0.2.1-next.14",
4
4
  "description": "The Astro integration for Ascribe.",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -48,9 +48,10 @@
48
48
  "node": ">=24"
49
49
  },
50
50
  "dependencies": {
51
- "@ascribed/cli": "0.2.1-next.13",
52
- "@ascribed/elements": "0.2.1-next.13",
53
- "@ascribed/review": "0.2.1-next.13",
51
+ "@ascribed/cli": "0.2.1-next.14",
52
+ "@ascribed/elements": "0.2.1-next.14",
53
+ "@ascribed/review": "0.2.1-next.14",
54
+ "github-slugger": "^2.0.0",
54
55
  "property-information": "^7.2.0",
55
56
  "smol-toml": "^1.9.0"
56
57
  },
@@ -85,7 +86,7 @@
85
86
  "publishConfig": {
86
87
  "access": "public"
87
88
  },
88
- "gitHead": "42f00096ceb0f91a736b6a3d485eb38a91fbcc48",
89
+ "gitHead": "7f59c3e433d6830ddb59e07f050bd1ac21f71d11",
89
90
  "scripts": {
90
91
  "build": "tsc -p tsconfig.build.json",
91
92
  "typecheck": "tsc --noEmit",
@@ -8,17 +8,20 @@
8
8
  // import MissingPermalink from "@ascribed/astro/MissingPermalink.astro";
9
9
  // <MissingPermalink />
10
10
  //
11
- // `home` is where it sends the reader, the site's root by default. Children
12
- // replace its last paragraph, which offers the home page and searching.
13
- import { home as root, siteRoot } from "virtual:ascribe/site";
14
- import { readPermalinks } from "@ascribed/astro/permalinks";
11
+ // `home` is where it sends the reader: by default the root of the project whose
12
+ // permalinks it explains, which with several projects is the first that has
13
+ // any. Children replace its last paragraph, which offers the home page and
14
+ // searching.
15
+ import { home as root, permalinks } from "virtual:ascribe/site";
16
+ import { servedList } from "@ascribed/astro/permalinks";
15
17
 
16
18
  interface Props {
17
19
  home?: string;
18
20
  }
19
21
 
20
- const { home = root } = Astro.props;
21
- const path = readPermalinks(siteRoot)?.path;
22
+ const served = servedList(permalinks);
23
+ const { home = served?.home ?? root } = Astro.props;
24
+ const path = served?.path;
22
25
  ---
23
26
 
24
27
  {