@dogsbay/format-astro 0.2.0-beta.14 → 0.2.0-beta.140

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 (63) hide show
  1. package/dist/api-markdown.d.ts +7 -0
  2. package/dist/api-markdown.js +49 -0
  3. package/dist/base-path.d.ts +93 -8
  4. package/dist/base-path.js +122 -9
  5. package/dist/blog.d.ts +133 -0
  6. package/dist/blog.js +323 -0
  7. package/dist/browser.d.ts +13 -0
  8. package/dist/browser.js +13 -0
  9. package/dist/canonical.d.ts +33 -0
  10. package/dist/canonical.js +106 -0
  11. package/dist/capabilities.d.ts +28 -0
  12. package/dist/capabilities.js +94 -0
  13. package/dist/cli.d.ts +0 -1
  14. package/dist/cli.js +3 -1
  15. package/dist/diff-decoration.d.ts +35 -0
  16. package/dist/diff-decoration.js +101 -0
  17. package/dist/granularity.d.ts +82 -0
  18. package/dist/granularity.js +246 -0
  19. package/dist/head-metadata.d.ts +25 -0
  20. package/dist/head-metadata.js +55 -0
  21. package/dist/index.d.ts +26 -5
  22. package/dist/index.js +34 -4
  23. package/dist/lead.d.ts +1 -39
  24. package/dist/lead.js +3 -38
  25. package/dist/llms-txt.d.ts +41 -11
  26. package/dist/llms-txt.js +82 -274
  27. package/dist/plugins.d.ts +0 -1
  28. package/dist/plugins.js +4 -3
  29. package/dist/project.d.ts +377 -13
  30. package/dist/project.js +2216 -650
  31. package/dist/redirects.d.ts +115 -0
  32. package/dist/redirects.js +223 -0
  33. package/dist/serialize.d.ts +14 -1
  34. package/dist/serialize.js +857 -255
  35. package/dist/sitemap.d.ts +107 -0
  36. package/dist/sitemap.js +126 -0
  37. package/dist/taxonomy.d.ts +48 -2
  38. package/dist/taxonomy.js +46 -12
  39. package/dist/theme-css.d.ts +33 -0
  40. package/dist/theme-css.js +683 -0
  41. package/dist/tone-classes.d.ts +52 -0
  42. package/dist/tone-classes.js +84 -0
  43. package/dist/write-if-changed.d.ts +20 -0
  44. package/dist/write-if-changed.js +57 -0
  45. package/package.json +16 -7
  46. package/dist/base-path.d.ts.map +0 -1
  47. package/dist/base-path.js.map +0 -1
  48. package/dist/cli.d.ts.map +0 -1
  49. package/dist/cli.js.map +0 -1
  50. package/dist/index.d.ts.map +0 -1
  51. package/dist/index.js.map +0 -1
  52. package/dist/lead.d.ts.map +0 -1
  53. package/dist/lead.js.map +0 -1
  54. package/dist/llms-txt.d.ts.map +0 -1
  55. package/dist/llms-txt.js.map +0 -1
  56. package/dist/plugins.d.ts.map +0 -1
  57. package/dist/plugins.js.map +0 -1
  58. package/dist/project.d.ts.map +0 -1
  59. package/dist/project.js.map +0 -1
  60. package/dist/serialize.d.ts.map +0 -1
  61. package/dist/serialize.js.map +0 -1
  62. package/dist/taxonomy.d.ts.map +0 -1
  63. package/dist/taxonomy.js.map +0 -1
package/dist/lead.d.ts CHANGED
@@ -1,39 +1 @@
1
- /**
2
- * Detect whether a page's body tree already provides a level-1
3
- * heading and / or a leading paragraph.
4
- *
5
- * `format-astro` uses this to decide whether to auto-inject the
6
- * frontmatter `title` and `description` at the top of `<main>` —
7
- * we never overwrite writer-authored content, only fill in what
8
- * the body doesn't already cover.
9
- *
10
- * See plans/auto-lede.md.
11
- */
12
- import type { TreeNode } from "@dogsbay/types";
13
- export interface LeadingNodeInfo {
14
- /**
15
- * `true` when `tree[0]` is a level-1 heading. Heading depth
16
- * lives at either `node.depth` (some importers) or
17
- * `node.props.level` (others); both shapes are recognized.
18
- */
19
- hasH1: boolean;
20
- /**
21
- * `true` when the body's first non-heading node is a paragraph.
22
- * Used to decide whether the description should be auto-rendered
23
- * as a lede `<p>` — if the writer already wrote a lede
24
- * paragraph, we leave it alone.
25
- *
26
- * "First non-heading node" means: `tree[1]` when `tree[0]` is a
27
- * heading; otherwise `tree[0]`. So `# Title\nLede paragraph`
28
- * yields `{ hasH1: true, hasLede: true }`.
29
- */
30
- hasLede: boolean;
31
- }
32
- /**
33
- * Inspect the leading nodes of a page tree.
34
- *
35
- * Defensive against empty trees and missing properties — returns
36
- * `false` for both flags when the tree is empty.
37
- */
38
- export declare function detectLeadingNodes(tree: TreeNode[] | undefined): LeadingNodeInfo;
39
- //# sourceMappingURL=lead.d.ts.map
1
+ export { detectLeadingNodes, deriveDescription, type LeadingNodeInfo } from "@dogsbay/serialize-core";
package/dist/lead.js CHANGED
@@ -1,38 +1,3 @@
1
- /**
2
- * Inspect the leading nodes of a page tree.
3
- *
4
- * Defensive against empty trees and missing properties — returns
5
- * `false` for both flags when the tree is empty.
6
- */
7
- export function detectLeadingNodes(tree) {
8
- if (!tree || tree.length === 0) {
9
- return { hasH1: false, hasLede: false };
10
- }
11
- const first = tree[0];
12
- const firstIsH1 = isH1(first);
13
- if (firstIsH1) {
14
- const second = tree[1];
15
- return { hasH1: true, hasLede: isParagraph(second) };
16
- }
17
- return { hasH1: false, hasLede: isParagraph(first) };
18
- }
19
- function isH1(node) {
20
- if (!node || node.type !== "heading")
21
- return false;
22
- // Some importers (dogsbay-md, mkdocs) attach depth as a top-level
23
- // field; others (Starlight via mdx-jsx) use props.level. Both
24
- // conventions are valid — check both.
25
- const depth = node.depth;
26
- if (typeof depth === "number")
27
- return depth === 1;
28
- const level = node.props?.level;
29
- if (typeof level === "number")
30
- return level === 1;
31
- return false;
32
- }
33
- function isParagraph(node) {
34
- if (!node)
35
- return false;
36
- return node.type === "paragraph";
37
- }
38
- //# sourceMappingURL=lead.js.map
1
+ // Moved to @dogsbay/serialize-core (src/lead.ts), so @dogsbay/site-check can use
2
+ // these without depending on format-astro, which brings @dogsbay/ui and astro.
3
+ export { detectLeadingNodes, deriveDescription } from "@dogsbay/serialize-core";
@@ -14,7 +14,9 @@
14
14
  * dependency-free (only @dogsbay/types). Callers wire the actual
15
15
  * `treeToDogsbayMd` serializer when they want full-body bundles.
16
16
  */
17
- import type { ExportPage, NavItem, SiteConfig, TreeNode } from "@dogsbay/types";
17
+ import type { ExportPage, NavItem, SiteConfig } from "@dogsbay/types";
18
+ import { extractFirstParagraph } from "@dogsbay/serialize-core";
19
+ export { extractFirstParagraph };
18
20
  export interface BuildLlmsTxtOptions {
19
21
  /**
20
22
  * Used to derive page slugs from nav hrefs. Defaults to
@@ -43,6 +45,41 @@ export interface BuildLlmsTxtOptions {
43
45
  * orphan emission entirely (matches pre-decoupling behavior).
44
46
  */
45
47
  otherPagesLabel?: string | false;
48
+ /**
49
+ * Other sites on this host to link at the end of the index — the
50
+ * `llms.txt` half of `site.aggregates`.
51
+ *
52
+ * Emitted as links, NOT inlined. Inlining would give an agent
53
+ * everything in one fetch but go stale on the aggregated site's next
54
+ * build, and this file is generated by a DIFFERENT build that has no
55
+ * way to know that happened.
56
+ */
57
+ aggregates?: AggregateEntry[];
58
+ /** Heading for the aggregates section. Default "Other sites". */
59
+ aggregatesLabel?: string;
60
+ /**
61
+ * Free-form guidance placed directly under the site description:
62
+ * what this site is good for and when an agent should reach for it.
63
+ *
64
+ * The llms.txt spec gives you a title, a blockquote and a link
65
+ * index — all of which describe what EXISTS. None of them say when
66
+ * the site is the right answer, which is the question an agent is
67
+ * actually holding. An audit put it as "generic marketing copy does
68
+ * not read as guidance", and that is fair: this is worth writing
69
+ * only if it names concrete jobs.
70
+ *
71
+ * Emitted verbatim as markdown so an author can use a list, and
72
+ * omitted entirely when unset rather than filled with a default —
73
+ * an invented "when to use" is worse than none.
74
+ */
75
+ whenToUse?: string;
76
+ }
77
+ /** One entry in the aggregates section. See `site.aggregates`. */
78
+ export interface AggregateEntry {
79
+ name: string;
80
+ description?: string;
81
+ url?: string;
82
+ llmsTxt?: string;
46
83
  }
47
84
  export interface BuildLlmsFullTxtOptions extends BuildLlmsTxtOptions {
48
85
  /**
@@ -68,7 +105,7 @@ export interface BuildLlmsFullTxtOptions extends BuildLlmsTxtOptions {
68
105
  * - Pages reachable in `pages` but absent from `nav` appended under
69
106
  * a final `## {otherPagesLabel}` section. Set `otherPagesLabel`
70
107
  * to `false` to suppress (e.g. when nav is the publish list).
71
- * - External hrefs are skipped.
108
+ * - External hrefs, and hrefs outside `hrefPrefix`, are skipped.
72
109
  */
73
110
  export declare function buildLlmsTxt(siteConfig: Pick<SiteConfig, "siteName" | "description" | "siteUrl">, nav: NavItem[], pages: ExportPage[], options?: BuildLlmsTxtOptions): string;
74
111
  /**
@@ -78,15 +115,8 @@ export declare function buildLlmsTxt(siteConfig: Pick<SiteConfig, "siteName" | "
78
115
  */
79
116
  export declare function buildSectionLlmsTxt(siteConfig: Pick<SiteConfig, "siteName" | "siteUrl">, group: NavItem, pages: ExportPage[], options?: BuildLlmsTxtOptions): string;
80
117
  /**
81
- * Bundle every page reachable from `nav` into a single document, in
82
- * nav order. Each page is delimited by a `# Title` header and `URL:`
118
+ * Bundle every page into a single document: nav order, then pages outside
119
+ * the nav by slug. Each page is delimited by a `# Title` header and `URL:`
83
120
  * line, then either the full body (default) or just a description.
84
121
  */
85
122
  export declare function buildLlmsFullTxt(siteConfig: Pick<SiteConfig, "siteName" | "description" | "siteUrl">, nav: NavItem[], pages: ExportPage[], options?: BuildLlmsFullTxtOptions): string;
86
- /**
87
- * Extract plain text of the first paragraph in a page tree. Cross-shape
88
- * tolerant: handles `node.inline` (dogsbay-md parser) and
89
- * `node.children[{type: "prose", inline}]` (Starlight importer).
90
- */
91
- export declare function extractFirstParagraph(tree: TreeNode[]): string;
92
- //# sourceMappingURL=llms-txt.d.ts.map
package/dist/llms-txt.js CHANGED
@@ -1,9 +1,16 @@
1
+ import { extractFirstParagraph } from "@dogsbay/serialize-core";
1
2
  import { DEFAULT_BASE_PATH } from "./base-path.js";
3
+ import { DESCRIPTION_LIMIT, buildLlmsFullTxt as buildLlmsFullTxtFile, buildLlmsTxt as buildLlmsTxtFile, buildSectionLlmsTxt as buildSectionLlmsTxtFile, llmsFullOrder, truncate, } from "@dogsbay/site-files";
4
+ // Moved to serialize-core so @dogsbay/site-check need not depend on format-astro
5
+ export { extractFirstParagraph };
2
6
  const EXTERNAL_HREF_RE = /^(?:https?:\/\/|mailto:|tel:|#)/i;
3
- const DEFAULT_DESCRIPTION_LIMIT = 160;
4
- const DEFAULT_TOP_LEVEL_LABEL = "Documentation";
5
- const DEFAULT_OTHER_PAGES_LABEL = "Other pages";
7
+ const DEFAULT_DESCRIPTION_LIMIT = DESCRIPTION_LIMIT;
6
8
  // ── Public builders ─────────────────────────────────────────────
9
+ //
10
+ // The files themselves are written by @dogsbay/site-files, shared with
11
+ // dogsbay-ssg. This module maps ExportPage and NavItem onto its records:
12
+ // keys are slugs ("index" for the home page), URLs are `<prefix>/<slug>.md`,
13
+ // descriptions come from front matter or the first paragraph.
7
14
  /**
8
15
  * Build the top-level llms.txt index for a site.
9
16
  *
@@ -14,73 +21,18 @@ const DEFAULT_OTHER_PAGES_LABEL = "Other pages";
14
21
  * - Pages reachable in `pages` but absent from `nav` appended under
15
22
  * a final `## {otherPagesLabel}` section. Set `otherPagesLabel`
16
23
  * to `false` to suppress (e.g. when nav is the publish list).
17
- * - External hrefs are skipped.
24
+ * - External hrefs, and hrefs outside `hrefPrefix`, are skipped.
18
25
  */
19
26
  export function buildLlmsTxt(siteConfig, nav, pages, options = {}) {
20
- const hrefPrefix = options.hrefPrefix ?? DEFAULT_BASE_PATH;
21
- const siteUrl = options.siteUrl ?? siteConfig.siteUrl;
22
- const descLimit = options.descriptionLimit ?? DEFAULT_DESCRIPTION_LIMIT;
23
- const topLevelLabel = options.topLevelLabel ?? DEFAULT_TOP_LEVEL_LABEL;
24
- const otherPagesLabel = options.otherPagesLabel === undefined
25
- ? DEFAULT_OTHER_PAGES_LABEL
26
- : options.otherPagesLabel;
27
- const slugIndex = indexPagesBySlug(pages);
28
- const emittedSlugs = new Set();
29
- const out = [];
30
- out.push(`# ${siteConfig.siteName}`);
31
- out.push("");
32
- if (siteConfig.description) {
33
- out.push(`> ${siteConfig.description}`);
34
- out.push("");
35
- }
36
- const topLevelLeaves = [];
37
- const groupSections = [];
38
- for (const item of nav) {
39
- if (item.children && item.children.length > 0) {
40
- const lines = [];
41
- lines.push(`## ${item.label}`);
42
- lines.push("");
43
- // Group's own landing page first, if it has one
44
- if (item.href && !EXTERNAL_HREF_RE.test(item.href)) {
45
- const entry = renderEntry(item, slugIndex, hrefPrefix, siteUrl, descLimit, emittedSlugs);
46
- if (entry)
47
- lines.push(entry);
48
- }
49
- collectEntries(item.children, slugIndex, hrefPrefix, siteUrl, descLimit, lines, emittedSlugs);
50
- lines.push("");
51
- groupSections.push(lines);
52
- }
53
- else if (item.href && !EXTERNAL_HREF_RE.test(item.href)) {
54
- topLevelLeaves.push(item);
55
- }
56
- }
57
- if (topLevelLeaves.length > 0) {
58
- out.push(`## ${topLevelLabel}`);
59
- out.push("");
60
- for (const leaf of topLevelLeaves) {
61
- const entry = renderEntry(leaf, slugIndex, hrefPrefix, siteUrl, descLimit, emittedSlugs);
62
- if (entry)
63
- out.push(entry);
64
- }
65
- out.push("");
66
- }
67
- for (const section of groupSections) {
68
- out.push(...section);
69
- }
70
- // Orphan pass — pages that exist in `pages` but were never hit by
71
- // the nav walk. Emit so agents see the full surface even when nav
72
- // curates a subset (the common case: a sidebar trims internal /
73
- // sub-utility pages, but agents still want to find them).
74
- if (otherPagesLabel !== false) {
75
- const orphans = collectOrphanEntries(pages, emittedSlugs, hrefPrefix, siteUrl, descLimit);
76
- if (orphans.length > 0) {
77
- out.push(`## ${otherPagesLabel}`);
78
- out.push("");
79
- out.push(...orphans);
80
- out.push("");
81
- }
82
- }
83
- return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
27
+ const m = mapInputs(siteConfig, nav, pages, options);
28
+ return buildLlmsTxtFile({ name: siteConfig.siteName, description: siteConfig.description, whenToUse: options.whenToUse, aggregates: options.aggregates }, m.nav, m.pages, {
29
+ url: m.url,
30
+ // A nav entry whose page is missing is still linked, by its label.
31
+ missing: "link",
32
+ topLevelLabel: options.topLevelLabel,
33
+ otherPagesLabel: options.otherPagesLabel,
34
+ aggregatesLabel: options.aggregatesLabel,
35
+ });
84
36
  }
85
37
  /**
86
38
  * Per-section llms.txt — same shape as the root index, scoped to one
@@ -88,149 +40,44 @@ export function buildLlmsTxt(siteConfig, nav, pages, options = {}) {
88
40
  * can pull only the section they care about.
89
41
  */
90
42
  export function buildSectionLlmsTxt(siteConfig, group, pages, options = {}) {
91
- const hrefPrefix = options.hrefPrefix ?? DEFAULT_BASE_PATH;
92
- const siteUrl = options.siteUrl ?? siteConfig.siteUrl;
93
- const descLimit = options.descriptionLimit ?? DEFAULT_DESCRIPTION_LIMIT;
94
- const slugIndex = indexPagesBySlug(pages);
95
- const out = [];
96
- out.push(`# ${siteConfig.siteName} — ${group.label}`);
97
- out.push("");
98
- out.push(`## ${group.label}`);
99
- out.push("");
100
- if (group.href && !EXTERNAL_HREF_RE.test(group.href)) {
101
- const entry = renderEntry(group, slugIndex, hrefPrefix, siteUrl, descLimit);
102
- if (entry)
103
- out.push(entry);
104
- }
105
- if (group.children) {
106
- collectEntries(group.children, slugIndex, hrefPrefix, siteUrl, descLimit, out);
107
- }
108
- return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
43
+ const m = mapInputs(siteConfig, [group], pages, options);
44
+ return buildSectionLlmsTxtFile({ name: siteConfig.siteName }, m.nav[0], m.pages, { url: m.url, missing: "link" });
109
45
  }
110
46
  /**
111
- * Bundle every page reachable from `nav` into a single document, in
112
- * nav order. Each page is delimited by a `# Title` header and `URL:`
47
+ * Bundle every page into a single document: nav order, then pages outside
48
+ * the nav by slug. Each page is delimited by a `# Title` header and `URL:`
113
49
  * line, then either the full body (default) or just a description.
114
50
  */
115
51
  export function buildLlmsFullTxt(siteConfig, nav, pages, options = {}) {
116
- const hrefPrefix = options.hrefPrefix ?? DEFAULT_BASE_PATH;
117
- const siteUrl = options.siteUrl ?? siteConfig.siteUrl;
118
52
  const summary = options.summary ?? (options.serializePage ? "body" : "description");
119
53
  if (summary === "body" && !options.serializePage) {
120
54
  throw new Error(`buildLlmsFullTxt: summary "body" requires options.serializePage`);
121
55
  }
122
- const slugIndex = indexPagesBySlug(pages);
123
- const navOrder = flattenNavToSlugs(nav, hrefPrefix);
124
- // Append page slugs not reachable from nav (sorted for stable
125
- // output). Same rationale as buildLlmsTxt's orphan pass: the bundle
126
- // is for agents, and curated nav usually trims internal pages that
127
- // agents still want to read.
128
- const seen = new Set(navOrder);
129
- const orphanSlugs = pages
130
- .map((p) => p.slug || "index")
131
- .filter((s) => !seen.has(s))
132
- .sort((a, b) => a.localeCompare(b));
133
- const allSlugs = [...navOrder, ...orphanSlugs];
134
- const out = [];
135
- out.push(`# ${siteConfig.siteName}`);
136
- out.push("");
137
- if (siteConfig.description) {
138
- out.push(`> ${siteConfig.description}`);
139
- out.push("");
140
- }
141
- let firstPage = true;
142
- for (const slug of allSlugs) {
143
- const page = slugIndex.get(slug);
144
- if (!page)
145
- continue;
146
- if (!firstPage) {
147
- out.push("---");
148
- out.push("");
149
- }
150
- firstPage = false;
151
- out.push(`# ${page.title}`);
152
- out.push("");
153
- out.push(`URL: ${absoluteUrl(slugToHref(slug, hrefPrefix), siteUrl)}`);
154
- out.push("");
155
- if (summary === "body") {
156
- out.push(options.serializePage(page).trimEnd());
157
- }
158
- else {
159
- const desc = getPageDescription(page, options.descriptionLimit ?? DEFAULT_DESCRIPTION_LIMIT);
160
- out.push(desc || page.title);
161
- }
162
- out.push("");
163
- }
164
- return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
165
- }
166
- // ── Internals: nav walking + entry rendering ────────────────────
167
- function collectEntries(items, slugIndex, hrefPrefix, siteUrl, descLimit, out, emitted) {
168
- for (const item of items) {
169
- if (item.href && !EXTERNAL_HREF_RE.test(item.href)) {
170
- const entry = renderEntry(item, slugIndex, hrefPrefix, siteUrl, descLimit, emitted);
171
- if (entry)
172
- out.push(entry);
173
- }
174
- if (item.children) {
175
- collectEntries(item.children, slugIndex, hrefPrefix, siteUrl, descLimit, out, emitted);
176
- }
177
- }
178
- }
179
- function renderEntry(item, slugIndex, hrefPrefix, siteUrl, descLimit, emitted) {
180
- if (!item.href)
181
- return null;
182
- const slug = hrefToSlug(item.href, hrefPrefix);
183
- if (slug === null)
184
- return null;
185
- const page = slugIndex.get(slug);
186
- const label = item.label || page?.title || slug;
187
- const description = page ? getPageDescription(page, descLimit) : "";
188
- const url = absoluteUrl(`${item.href.replace(/\/$/, "")}.md`, siteUrl);
189
- if (emitted && page)
190
- emitted.add(slug);
191
- return description
192
- ? `- [${label}](${url}): ${description}`
193
- : `- [${label}](${url})`;
194
- }
195
- /**
196
- * Render entries for pages that exist but weren't reached during the
197
- * nav walk. Sorted alphabetically by slug for stable output (nav
198
- * order isn't meaningful for these — they're not in nav).
199
- */
200
- function collectOrphanEntries(pages, emitted, hrefPrefix, siteUrl, descLimit) {
201
- const orphans = [];
202
- const sorted = pages
203
- .filter((p) => !emitted.has(p.slug || "index"))
204
- .sort((a, b) => (a.slug || "index").localeCompare(b.slug || "index"));
205
- for (const page of sorted) {
206
- const slug = page.slug || "index";
207
- const label = page.title || slug;
208
- const description = getPageDescription(page, descLimit);
209
- const url = absoluteUrl(slugToHref(slug, hrefPrefix), siteUrl);
210
- orphans.push(description
211
- ? `- [${label}](${url}): ${description}`
212
- : `- [${label}](${url})`);
213
- }
214
- return orphans;
215
- }
216
- function flattenNavToSlugs(nav, hrefPrefix) {
217
- const slugs = [];
218
- const seen = new Set();
219
- const walk = (items) => {
220
- for (const item of items) {
221
- if (item.href && !EXTERNAL_HREF_RE.test(item.href)) {
222
- const slug = hrefToSlug(item.href, hrefPrefix);
223
- if (slug !== null && !seen.has(slug)) {
224
- seen.add(slug);
225
- slugs.push(slug);
226
- }
227
- }
228
- if (item.children)
229
- walk(item.children);
230
- }
56
+ const m = mapInputs(siteConfig, nav, pages, options);
57
+ const load = (key) => {
58
+ const page = m.byKey.get(key);
59
+ if (summary === "body")
60
+ return options.serializePage(page);
61
+ return m.pages.get(key).description || page.title;
231
62
  };
232
- walk(nav);
233
- return slugs;
63
+ return buildLlmsFullTxtFile({ name: siteConfig.siteName, description: siteConfig.description, whenToUse: options.whenToUse }, llmsFullOrder(m.nav, m.pages), m.pages, m.url, load);
64
+ }
65
+ /** ExportPage and NavItem as @dogsbay/site-files records. */
66
+ function mapInputs(siteConfig, nav, pages, options) {
67
+ const hrefPrefix = options.hrefPrefix ?? DEFAULT_BASE_PATH;
68
+ const siteUrl = options.siteUrl ?? siteConfig.siteUrl;
69
+ const limit = options.descriptionLimit ?? DEFAULT_DESCRIPTION_LIMIT;
70
+ const byKey = indexPagesBySlug(pages);
71
+ const records = new Map();
72
+ for (const [key, page] of byKey) {
73
+ const description = getPageDescription(page, limit);
74
+ records.set(key, { title: page.title, ...(description ? { description } : {}) });
75
+ }
76
+ const toNav = (items) => items.map((item) => {
77
+ const key = item.href ? hrefToSlug(item.href, hrefPrefix) : null;
78
+ return { label: item.label, ...(key !== null ? { key } : {}), children: toNav(item.children ?? []) };
79
+ });
80
+ return { pages: records, byKey, nav: toNav(nav), url: (key) => absoluteUrl(slugToHref(key, hrefPrefix), siteUrl) };
234
81
  }
235
82
  // ── Internals: page lookup + URL math ───────────────────────────
236
83
  function indexPagesBySlug(pages) {
@@ -252,14 +99,45 @@ function hrefToSlug(href, hrefPrefix) {
252
99
  function slugToHref(slug, hrefPrefix) {
253
100
  const prefix = hrefPrefix.endsWith("/") ? hrefPrefix.slice(0, -1) : hrefPrefix;
254
101
  if (slug === "" || slug === "index")
255
- return `${prefix}.md`;
102
+ return indexMirrorHref(prefix);
256
103
  return `${prefix}/${slug}.md`;
257
104
  }
105
+ /**
106
+ * The site index's markdown mirror.
107
+ *
108
+ * At a mount this is `<prefix>.md` — verified against the deployed
109
+ * blog, where `/blog.md` serves and `/blog/index.md` 404s.
110
+ *
111
+ * At the HOST ROOT the prefix is empty, so that form degenerates to
112
+ * `.md`: a relative path that resolves against whatever directory the
113
+ * reader happens to be in, and absolutises to `https://host/.md`. The
114
+ * route Astro actually emits there is `dist/index.md`.
115
+ *
116
+ * The bug was invisible on every site we had, because a mounted site
117
+ * takes the other branch and the one host-root site had no index entry
118
+ * in its llms.txt.
119
+ */
120
+ function indexMirrorHref(prefix) {
121
+ return prefix ? `${prefix}.md` : "/index.md";
122
+ }
258
123
  function absoluteUrl(path, siteUrl) {
259
124
  if (!siteUrl)
260
125
  return path;
261
- const base = siteUrl.replace(/\/$/, "");
262
- return path.startsWith("/") ? `${base}${path}` : `${base}/${path}`;
126
+ // Strip the path component from siteUrl. With the two-prefix
127
+ // model (urlBase from site.url's path drives Astro's `base`,
128
+ // basePath stays as filesystem prefix), `path` already carries
129
+ // the combined prefix. Concatenating siteUrl-with-path would
130
+ // double-count. Use only the origin.
131
+ // See plans/astro-base-from-site-url.md.
132
+ let origin;
133
+ try {
134
+ const u = new URL(siteUrl);
135
+ origin = `${u.protocol}//${u.host}`;
136
+ }
137
+ catch {
138
+ origin = siteUrl.replace(/\/$/, "");
139
+ }
140
+ return path.startsWith("/") ? `${origin}${path}` : `${origin}/${path}`;
263
141
  }
264
142
  // ── Internals: description + plain-text extraction ──────────────
265
143
  function getPageDescription(page, limit) {
@@ -270,73 +148,3 @@ function getPageDescription(page, limit) {
270
148
  const firstPara = extractFirstParagraph(page.tree);
271
149
  return firstPara ? truncate(firstPara, limit) : "";
272
150
  }
273
- /**
274
- * Extract plain text of the first paragraph in a page tree. Cross-shape
275
- * tolerant: handles `node.inline` (dogsbay-md parser) and
276
- * `node.children[{type: "prose", inline}]` (Starlight importer).
277
- */
278
- export function extractFirstParagraph(tree) {
279
- for (const node of tree) {
280
- if (node.type !== "paragraph")
281
- continue;
282
- const inline = node.inline ?? findProseInline(node.children);
283
- if (inline)
284
- return inlineToPlainText(inline).trim();
285
- }
286
- return "";
287
- }
288
- function findProseInline(children) {
289
- if (!children)
290
- return undefined;
291
- for (const child of children) {
292
- if (child.type === "prose" && child.inline)
293
- return child.inline;
294
- }
295
- return undefined;
296
- }
297
- function inlineToPlainText(inline) {
298
- let out = "";
299
- for (const node of inline) {
300
- switch (node.type) {
301
- case "text":
302
- out += node.text;
303
- break;
304
- case "code":
305
- out += node.text;
306
- break;
307
- case "link":
308
- case "highlight":
309
- out += inlineToPlainText(node.children);
310
- break;
311
- case "kbd":
312
- out += node.keys.join("+");
313
- break;
314
- case "math":
315
- out += node.latex;
316
- break;
317
- case "image":
318
- if (node.alt)
319
- out += node.alt;
320
- break;
321
- case "break":
322
- out += " ";
323
- break;
324
- case "html-inline":
325
- case "footnote-ref":
326
- case "icon":
327
- // No useful plain text; skip.
328
- break;
329
- }
330
- }
331
- return out;
332
- }
333
- function truncate(text, limit) {
334
- const collapsed = text.replace(/\s+/g, " ").trim();
335
- if (collapsed.length <= limit)
336
- return collapsed;
337
- const slice = collapsed.slice(0, limit);
338
- const lastSpace = slice.lastIndexOf(" ");
339
- const cut = lastSpace > limit * 0.5 ? slice.slice(0, lastSpace) : slice;
340
- return `${cut.trimEnd()}…`;
341
- }
342
- //# sourceMappingURL=llms-txt.js.map
package/dist/plugins.d.ts CHANGED
@@ -37,4 +37,3 @@ export interface EmitPluginRuntimeOptions {
37
37
  export declare function emitPluginRuntime(options: EmitPluginRuntimeOptions): void;
38
38
  /** Test-only: clear emitted plugin runtime files. */
39
39
  export declare function _clearPluginRuntime(outputDir: string): void;
40
- //# sourceMappingURL=plugins.d.ts.map
package/dist/plugins.js CHANGED
@@ -37,7 +37,9 @@
37
37
  *
38
38
  * See plans/plugin-api.md.
39
39
  */
40
- import { writeFileSync, readFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
40
+ import { readFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
41
+ // Unchanged files are not rewritten, so `site dev` reloads only what changed
42
+ import { writeFileSync, copyFileSync } from "./write-if-changed.js";
41
43
  import { dirname, join, basename, extname } from "node:path";
42
44
  /**
43
45
  * Slots the runtime emits stack files for. Each entry is always
@@ -46,7 +48,7 @@ import { dirname, join, basename, extname } from "node:path";
46
48
  * requires both an entry here and a usage site in format-astro's
47
49
  * page emitter.
48
50
  */
49
- const KNOWN_SLOTS = ["MarkdownContent"];
51
+ const KNOWN_SLOTS = ["MarkdownContent", "RightRail"];
50
52
  const PLUGIN_RUNTIME_REL = "src/lib/plugin-runtime.ts";
51
53
  const PLUGIN_CONFIG_DIR_REL = "src/lib/plugin-config";
52
54
  const PLUGIN_STYLES_DIR_REL = "src/styles/plugins";
@@ -336,4 +338,3 @@ export function _clearPluginRuntime(outputDir) {
336
338
  rmSync(join(outputDir, PLUGIN_CONFIG_DIR_REL), { recursive: true, force: true });
337
339
  rmSync(join(outputDir, PLUGIN_STYLES_DIR_REL), { recursive: true, force: true });
338
340
  }
339
- //# sourceMappingURL=plugins.js.map