@dogsbay/format-astro 0.2.0-beta.15 → 0.2.0-beta.151

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 (67) 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 +325 -0
  7. package/dist/browser.d.ts +13 -0
  8. package/dist/browser.js +13 -0
  9. package/dist/canonical.d.ts +35 -0
  10. package/dist/canonical.js +110 -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 -6
  22. package/dist/index.js +34 -5
  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 +461 -13
  30. package/dist/project.js +2343 -649
  31. package/dist/redirects.d.ts +117 -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/site-chrome.d.ts +62 -0
  36. package/dist/site-chrome.js +56 -0
  37. package/dist/sitemap.d.ts +62 -4
  38. package/dist/sitemap.js +63 -56
  39. package/dist/taxonomy.d.ts +48 -2
  40. package/dist/taxonomy.js +50 -12
  41. package/dist/theme-css.d.ts +33 -0
  42. package/dist/theme-css.js +683 -0
  43. package/dist/tone-classes.d.ts +52 -0
  44. package/dist/tone-classes.js +84 -0
  45. package/dist/write-if-changed.d.ts +20 -0
  46. package/dist/write-if-changed.js +57 -0
  47. package/package.json +17 -7
  48. package/dist/base-path.d.ts.map +0 -1
  49. package/dist/base-path.js.map +0 -1
  50. package/dist/cli.d.ts.map +0 -1
  51. package/dist/cli.js.map +0 -1
  52. package/dist/index.d.ts.map +0 -1
  53. package/dist/index.js.map +0 -1
  54. package/dist/lead.d.ts.map +0 -1
  55. package/dist/lead.js.map +0 -1
  56. package/dist/llms-txt.d.ts.map +0 -1
  57. package/dist/llms-txt.js.map +0 -1
  58. package/dist/plugins.d.ts.map +0 -1
  59. package/dist/plugins.js.map +0 -1
  60. package/dist/project.d.ts.map +0 -1
  61. package/dist/project.js.map +0 -1
  62. package/dist/serialize.d.ts.map +0 -1
  63. package/dist/serialize.js.map +0 -1
  64. package/dist/sitemap.d.ts.map +0 -1
  65. package/dist/sitemap.js.map +0 -1
  66. package/dist/taxonomy.d.ts.map +0 -1
  67. package/dist/taxonomy.js.map +0 -1
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Diff decorations — DiffTree marks rendered through the NORMAL
3
+ * serializer (plans/tree-diff-rendering.md §2).
4
+ *
5
+ * `@dogsbay/tree-diff` annotates nodes with structural fields
6
+ * (`diff`, `diffInline`, `diffPeer`, `diffMovedFrom`); this module
7
+ * reads them STRUCTURALLY (no dependency on tree-diff) and decorates
8
+ * the serializer's output:
9
+ *
10
+ * - block marks → a `data-diff` attribute injected into the
11
+ * rendered snippet's first opening tag (attribute injection works
12
+ * uniformly for components, table rows, and plain tags — no
13
+ * invalid wrapper elements inside tables/tabs), styled by the
14
+ * generated theme CSS;
15
+ * - changed prose → the word-level segments render as
16
+ * `<ins>/<del>` runs (the merged reading view);
17
+ * - changed code → a before/after pair: the old side renders
18
+ * through the same code renderer, labeled, above the new side.
19
+ *
20
+ * A tree with no marks renders byte-identically to before — the
21
+ * decoration layer is invisible until a DiffTree arrives.
22
+ */
23
+ import type { TreeNode } from "@dogsbay/types";
24
+ export { DIFF_CSS, buildMergedCodeDiff, diffMarksOf, hasDiffMarks, inlineSegmentsToHtml, type DiffMarks, type MergedCodeDiff, } from "@dogsbay/render-html/diff";
25
+ /**
26
+ * Inject attributes into the FIRST opening tag of a rendered
27
+ * snippet. Handles components (`<TableRow`), plain tags (`<p`), and
28
+ * skips leading whitespace/JSX expressions.
29
+ */
30
+ export declare function injectAttrs(rendered: string, attrs: string): string;
31
+ /**
32
+ * Decorate one rendered node. `renderPeer` renders the other side of
33
+ * a changed pair through the same serializer (code before/after).
34
+ */
35
+ export declare function decorateDiff(node: TreeNode, rendered: string, renderPeer: (peer: TreeNode) => string): string;
@@ -0,0 +1,101 @@
1
+ import { diffMarksOf, inlineSegmentsToHtml, mergedCodeNode } from "@dogsbay/render-html/diff";
2
+ // Shared with render-html, which marks the same DiffTree in its HTML
3
+ export { DIFF_CSS, buildMergedCodeDiff, diffMarksOf, hasDiffMarks, inlineSegmentsToHtml, } from "@dogsbay/render-html/diff";
4
+ /**
5
+ * Inject attributes into the FIRST opening tag of a rendered
6
+ * snippet. Handles components (`<TableRow`), plain tags (`<p`), and
7
+ * skips leading whitespace/JSX expressions.
8
+ */
9
+ export function injectAttrs(rendered, attrs) {
10
+ const m = rendered.match(/<([A-Za-z][\w.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/);
11
+ if (!m || m.index === undefined)
12
+ return rendered;
13
+ const insertAt = m.index + 1 + m[1].length;
14
+ return `${rendered.slice(0, insertAt)} ${attrs}${rendered.slice(insertAt)}`;
15
+ }
16
+ /**
17
+ * Decorate one rendered node. `renderPeer` renders the other side of
18
+ * a changed pair through the same serializer (code before/after).
19
+ */
20
+ export function decorateDiff(node, rendered, renderPeer) {
21
+ const marks = diffMarksOf(node);
22
+ if (!marks.diff && !marks.diffInline)
23
+ return rendered;
24
+ // changed prose with word-level segments: replace the inline body
25
+ if (marks.diffInline && node.type !== "heading") {
26
+ return `<p data-diff="changed" class="db-diff-prose" set:html={${JSON.stringify(inlineSegmentsToHtml(marks.diffInline))}} />`;
27
+ }
28
+ // changed code: ONE unified-diff block (removed lines −, added
29
+ // lines +, via CodeRich's notation-diff pipeline) — the same
30
+ // merged reading view prose uses; the copy button copies the
31
+ // post-change code only. Falls back to a labeled before/after
32
+ // pair when merging is unsafe (size cap, marker collision).
33
+ if (marks.diff === "changed" && marks.diffPeer && node.type === "code") {
34
+ const merged = mergedCodeNode(node);
35
+ if (merged !== null) {
36
+ const mergedNode = merged.node;
37
+ // Whitespace-only edits stay FULLY marked (indentation is
38
+ // semantic in YAML/Python/markdown — never silently dropped),
39
+ // but they get NAMED: without the label the reader hunts a
40
+ // 3-space shift across two near-identical lines.
41
+ const wsOnly = merged.whitespaceOnly;
42
+ const cls = `db-diff-code${wsOnly ? " db-diff-code-ws" : ""}`;
43
+ // The clean twin is the already-rendered new side: shiki line
44
+ // spans keep their newlines OUTSIDE the span, so hiding removed
45
+ // lines under Hide highlights would leave blank lines — instead
46
+ // the whole merged block swaps for the clean render (CSS only).
47
+ return [
48
+ `<div data-diff="changed" class="${cls}">`,
49
+ `<div class="db-diff-code-merged">`,
50
+ renderPeer(mergedNode),
51
+ `</div>`,
52
+ `<div class="db-diff-code-clean">`,
53
+ rendered,
54
+ `</div>`,
55
+ `</div>`,
56
+ ].join("\n");
57
+ }
58
+ const before = injectAttrs(renderPeer(marks.diffPeer), 'data-diff="removed"');
59
+ const after = injectAttrs(rendered, 'data-diff="added"');
60
+ return [
61
+ `<div class="db-diff-pair" data-diff="changed">`,
62
+ `<div class="db-diff-pair-label">before</div>`,
63
+ before,
64
+ `<div class="db-diff-pair-label">after</div>`,
65
+ after,
66
+ `</div>`,
67
+ ].join("\n");
68
+ }
69
+ const attrs = marks.diff === "moved" && marks.diffMovedFrom
70
+ ? `data-diff="moved" data-diff-from="${marks.diffMovedFrom.join(" › ").replace(/"/g, "&quot;")}"`
71
+ : `data-diff="${marks.diff}"`;
72
+ // Raw HTML renders as <Fragment set:html>, which emits NO wrapper
73
+ // element — an injected attribute vanishes at build time (found via
74
+ // better-auth adapters/mysql: an invisible table change). Marked
75
+ // html blocks get a real wrapper div instead.
76
+ if (node.type === "html") {
77
+ // A CHANGED html block is opaque: we render the new markup
78
+ // faithfully (fidelity is the product), but a bare "changed" border
79
+ // told the reader NOTHING about what went — the mysql table lost a
80
+ // whole column and the page showed no trace of it (external review:
81
+ // "fidelity alone is insufficient if a meaningful deletion becomes
82
+ // invisible"). So the previous version rides along in a disclosure:
83
+ // the deletion is one click away, and the reader is never misled
84
+ // into thinking nothing was removed.
85
+ if (marks.diff === "changed" && marks.diffPeer) {
86
+ return [
87
+ `<div ${attrs} class="db-diff-html">`,
88
+ rendered,
89
+ `<details class="db-diff-html-before">`,
90
+ `<summary>Show previous version</summary>`,
91
+ `<div data-diff="removed">`,
92
+ renderPeer(marks.diffPeer),
93
+ `</div>`,
94
+ `</details>`,
95
+ `</div>`,
96
+ ].join("\n");
97
+ }
98
+ return `<div ${attrs}>\n${rendered}\n</div>`;
99
+ }
100
+ return injectAttrs(rendered, attrs);
101
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Granularity views — assemble a whole-book page from topic pages + nav.
3
+ *
4
+ * The additive-MVP core (see plans/granularity-views.md). Given the topic-grained
5
+ * `ExportPage[]` + the `NavItem[]` tree a site already produces, this reassembles
6
+ * an entire nav subtree into ONE "book" page:
7
+ * - each nav node becomes a heading (level = nav depth; book title is the
8
+ * implicit H1, so a top-level node is H2, its children H3, … clamped at H6);
9
+ * - a topic's own body headings are shifted down by `level − 1` so they nest
10
+ * under their topic's title;
11
+ * - every node gets a stable, page-unique anchor, so a topic resolves to
12
+ * `<book>/_book#<anchor>` (the "Read as single page" button target).
13
+ *
14
+ * Pure + source-agnostic: it never touches the network or the original source,
15
+ * only the already-converted `TreeNode` trees ("convert once, project many").
16
+ * The original topic trees are NOT mutated — each is deep-cloned before its
17
+ * headings are shifted.
18
+ *
19
+ * This is a platform generalization of `format-fluidtopics-rest/src/single-page.ts`
20
+ * (which is khub-coupled). The FT `--single-page` branch will fold into this.
21
+ */
22
+ import type { ExportPage, NavItem, TreeNode } from "@dogsbay/types";
23
+ /**
24
+ * Reserved route segment for the book rollup. NOT `_`-prefixed: Astro treats
25
+ * `src/pages/` files/dirs starting with `_` as private (non-routable), so a
26
+ * `_book.astro` would 404. `~book` is collision-proof instead — `slugify`
27
+ * strips `~`, so no content slug can ever produce this segment (no slugifier
28
+ * reservation needed).
29
+ */
30
+ export declare const BOOK_SEGMENT = "~book";
31
+ export interface AssembleBookOptions {
32
+ /** Route prefix the nav hrefs carry (e.g. "/docs" or ""); stripped to get slugs. */
33
+ hrefPrefix?: string;
34
+ /** Book page title; defaults to the first nav item's label. */
35
+ title?: string;
36
+ /** Reserved suffix segment for the rollup route. Default `_book`. */
37
+ bookSegment?: string;
38
+ }
39
+ export interface BookView {
40
+ /** The assembled book page (noindex; excluded from nav/sitemap/search/llms). */
41
+ page: ExportPage;
42
+ /**
43
+ * topic slug → its anchor on the book page. Powers the per-topic
44
+ * "Read as single page" button (`<bookRoute>#<anchor>`).
45
+ */
46
+ topicAnchors: Map<string, string>;
47
+ }
48
+ /**
49
+ * Rough estimate of a TreeNode[]'s textual content size in bytes — the sum of
50
+ * its prose text, pre-rendered html, and string prop values (code, etc.). Used
51
+ * as the granularity size cap's input: it tracks source size closely enough to
52
+ * catch the 10 MB+ rollups (rest_api, installing) that blow up build + DOM,
53
+ * without a full serialize. See docs-dev/granularity-book-perf.md.
54
+ */
55
+ export declare function estimateTreeBytes(tree: TreeNode[]): number;
56
+ /**
57
+ * Assemble one book page from a nav subtree + the topic pages it references.
58
+ * Returns null when the subtree references no actual pages.
59
+ */
60
+ export declare function assembleBook(nav: NavItem[], pages: ExportPage[], opts?: AssembleBookOptions): BookView | null;
61
+ /** The "Open as page ↗" affordance — a link from a book section back to its topic page. */
62
+ export declare function openAsPageNode(slug: string, hrefPrefix: string): TreeNode;
63
+ /** A heading node for a nav entry's title, carrying the explicit anchor id. */
64
+ export declare function bookHeading(level: number, title: string, anchor: string): TreeNode;
65
+ /** Shift every heading in a (topic body) tree down by `offset` ranks (max H6). */
66
+ export declare function shiftHeadings(tree: TreeNode[], offset: number): void;
67
+ /**
68
+ * Rewrite **intra-book** xrefs to in-page anchors so the reader stays in the
69
+ * book. A link is rewritten iff its target slug is in *this* book's
70
+ * `topicAnchors` (the "is it in this book?" test). Cross-book links (target not
71
+ * in the book) and external links are left untouched — they keep pointing at the
72
+ * canonical topic page, which is correct. Mutates `tree` (already a clone).
73
+ */
74
+ export declare function rewriteIntraBookLinks(tree: TreeNode[], topicAnchors: Map<string, string>, hrefPrefix: string): void;
75
+ /**
76
+ * Longest common path prefix (slash-segment-wise) of the member slugs — the
77
+ * book's base route (e.g. the FT book base `…/16.0`). Falls back to `fallback`.
78
+ */
79
+ export declare function bookSlugFrom(slugs: string[], fallback: string): string;
80
+ /** Strip the route prefix + leading slash + anchor/query from a nav href → slug. */
81
+ export declare function slugFromHref(href: string | undefined, hrefPrefix: string): string;
82
+ export declare function slugify(text: string): string;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Reserved route segment for the book rollup. NOT `_`-prefixed: Astro treats
3
+ * `src/pages/` files/dirs starting with `_` as private (non-routable), so a
4
+ * `_book.astro` would 404. `~book` is collision-proof instead — `slugify`
5
+ * strips `~`, so no content slug can ever produce this segment (no slugifier
6
+ * reservation needed).
7
+ */
8
+ export const BOOK_SEGMENT = "~book";
9
+ /**
10
+ * Rough estimate of a TreeNode[]'s textual content size in bytes — the sum of
11
+ * its prose text, pre-rendered html, and string prop values (code, etc.). Used
12
+ * as the granularity size cap's input: it tracks source size closely enough to
13
+ * catch the 10 MB+ rollups (rest_api, installing) that blow up build + DOM,
14
+ * without a full serialize. See docs-dev/granularity-book-perf.md.
15
+ */
16
+ export function estimateTreeBytes(tree) {
17
+ let bytes = 0;
18
+ const walk = (nodes) => {
19
+ for (const node of nodes) {
20
+ const n = node;
21
+ if (typeof n.html === "string")
22
+ bytes += n.html.length;
23
+ if (n.props) {
24
+ for (const v of Object.values(n.props))
25
+ if (typeof v === "string")
26
+ bytes += v.length;
27
+ }
28
+ if (Array.isArray(n.inline)) {
29
+ for (const i of n.inline) {
30
+ if (typeof i.text === "string")
31
+ bytes += i.text.length;
32
+ else if (typeof i.value === "string")
33
+ bytes += i.value.length;
34
+ }
35
+ }
36
+ if (n.children?.length)
37
+ walk(n.children);
38
+ }
39
+ };
40
+ walk(tree);
41
+ return bytes;
42
+ }
43
+ /**
44
+ * Assemble one book page from a nav subtree + the topic pages it references.
45
+ * Returns null when the subtree references no actual pages.
46
+ */
47
+ export function assembleBook(nav, pages, opts = {}) {
48
+ const hrefPrefix = opts.hrefPrefix ?? "";
49
+ const bookSegment = opts.bookSegment ?? BOOK_SEGMENT;
50
+ const bySlug = new Map(pages.map((p) => [p.slug, p]));
51
+ const topicAnchors = new Map();
52
+ const memberSlugs = [];
53
+ const seenAnchor = new Map();
54
+ const uniqueAnchor = (base) => {
55
+ const b = base || "section";
56
+ const n = seenAnchor.get(b) ?? 0;
57
+ seenAnchor.set(b, n + 1);
58
+ return n === 0 ? b : `${b}-${n}`;
59
+ };
60
+ const ordered = [];
61
+ const assign = (items, depth) => {
62
+ for (const item of items) {
63
+ const level = Math.min(depth + 2, 6);
64
+ const slug = slugFromHref(item.href, hrefPrefix);
65
+ const anchor = uniqueAnchor(slugify(slug ? lastSegment(slug) : item.label));
66
+ ordered.push({ label: item.label, level, slug, anchor });
67
+ if (slug && bySlug.has(slug)) {
68
+ topicAnchors.set(slug, anchor);
69
+ memberSlugs.push(slug);
70
+ }
71
+ if (item.children?.length)
72
+ assign(item.children, depth + 1);
73
+ }
74
+ };
75
+ assign(nav, 0);
76
+ if (memberSlugs.length === 0)
77
+ return null;
78
+ // Pass 2 — assemble: a heading per node, then the topic body (cloned, headings
79
+ // shifted to nest, and intra-book xrefs rewritten to in-page anchors).
80
+ const tree = [];
81
+ for (const { label, level, slug, anchor } of ordered) {
82
+ tree.push(bookHeading(level, label, anchor));
83
+ const page = slug ? bySlug.get(slug) : undefined;
84
+ if (page) {
85
+ // "Open as page ↗" — the way back from the book to the focused topic.
86
+ // Pushed OUTSIDE the body so rewriteIntraBookLinks (which runs on the
87
+ // clone only) doesn't collapse it to an in-page anchor.
88
+ tree.push(openAsPageNode(slug, hrefPrefix));
89
+ // Clone so we never mutate the topic page that is also emitted standalone.
90
+ const body = structuredClone(page.tree);
91
+ shiftHeadings(body, level - 1);
92
+ rewriteIntraBookLinks(body, topicAnchors, hrefPrefix);
93
+ tree.push(...body);
94
+ }
95
+ }
96
+ const title = opts.title ?? nav[0]?.label ?? "Book";
97
+ const base = bookSlugFrom(memberSlugs, slugify(title));
98
+ const slug = `${base}/${bookSegment}`;
99
+ const page = {
100
+ slug,
101
+ title,
102
+ tree,
103
+ headings: extractBookHeadings(tree),
104
+ // Quarantine marker — the emitters keep the book out of search / sitemap /
105
+ // llms and tag it noindex. Kept in frontmatter so it round-trips + is visible.
106
+ frontmatter: { noindex: true, granularity: "book" },
107
+ };
108
+ return { page, topicAnchors };
109
+ }
110
+ // ── pure helpers (ported from format-fluidtopics-rest/single-page.ts) ──────
111
+ /** The "Open as page ↗" affordance — a link from a book section back to its topic page. */
112
+ export function openAsPageNode(slug, hrefPrefix) {
113
+ const route = hrefPrefix ? `${hrefPrefix}/${slug}` : `/${slug}`;
114
+ return {
115
+ type: "paragraph",
116
+ props: { class: "dsb-open-as-page" },
117
+ children: [{ type: "prose", inline: [{ type: "link", href: route, children: [{ type: "text", text: "Open as page ↗" }] }] }],
118
+ };
119
+ }
120
+ /** A heading node for a nav entry's title, carrying the explicit anchor id. */
121
+ export function bookHeading(level, title, anchor) {
122
+ return {
123
+ type: "heading",
124
+ props: { level, slug: anchor, text: title },
125
+ children: [{ type: "prose", inline: [{ type: "text", text: title }] }],
126
+ };
127
+ }
128
+ /** Shift every heading in a (topic body) tree down by `offset` ranks (max H6). */
129
+ export function shiftHeadings(tree, offset) {
130
+ if (offset <= 0)
131
+ return;
132
+ for (const n of tree) {
133
+ if (n.type === "heading") {
134
+ const lvl = n.props?.level ?? 1;
135
+ if (!n.props)
136
+ n.props = {};
137
+ n.props.level = Math.min(lvl + offset, 6);
138
+ }
139
+ if (n.children)
140
+ shiftHeadings(n.children, offset);
141
+ }
142
+ }
143
+ /**
144
+ * Rewrite **intra-book** xrefs to in-page anchors so the reader stays in the
145
+ * book. A link is rewritten iff its target slug is in *this* book's
146
+ * `topicAnchors` (the "is it in this book?" test). Cross-book links (target not
147
+ * in the book) and external links are left untouched — they keep pointing at the
148
+ * canonical topic page, which is correct. Mutates `tree` (already a clone).
149
+ */
150
+ export function rewriteIntraBookLinks(tree, topicAnchors, hrefPrefix) {
151
+ const rewriteInline = (nodes) => {
152
+ for (const n of nodes) {
153
+ if (n.type === "link" && typeof n.href === "string") {
154
+ const slug = slugFromHref(n.href, hrefPrefix);
155
+ const anchor = slug ? topicAnchors.get(slug) : undefined;
156
+ if (anchor)
157
+ n.href = `#${anchor}`;
158
+ }
159
+ if (Array.isArray(n.children))
160
+ rewriteInline(n.children);
161
+ }
162
+ };
163
+ const walk = (ns) => {
164
+ for (const n of ns) {
165
+ if (n.inline)
166
+ rewriteInline(n.inline);
167
+ if (n.children)
168
+ walk(n.children);
169
+ }
170
+ };
171
+ walk(tree);
172
+ }
173
+ /**
174
+ * Longest common path prefix (slash-segment-wise) of the member slugs — the
175
+ * book's base route (e.g. the FT book base `…/16.0`). Falls back to `fallback`.
176
+ */
177
+ export function bookSlugFrom(slugs, fallback) {
178
+ const parts = slugs.filter(Boolean).map((s) => s.split("/"));
179
+ if (!parts.length)
180
+ return fallback;
181
+ const first = parts[0];
182
+ let i = 0;
183
+ for (; i < first.length; i++) {
184
+ const seg = first[i];
185
+ if (!parts.every((p) => p[i] === seg))
186
+ break;
187
+ }
188
+ return first.slice(0, i).join("/") || fallback;
189
+ }
190
+ /** Strip the route prefix + leading slash + anchor/query from a nav href → slug. */
191
+ export function slugFromHref(href, hrefPrefix) {
192
+ if (!href)
193
+ return "";
194
+ let s = href.split("#")[0].split("?")[0].trim();
195
+ if (hrefPrefix && s.startsWith(hrefPrefix))
196
+ s = s.slice(hrefPrefix.length);
197
+ return s.replace(/^\/+/, "").replace(/\/+$/, "");
198
+ }
199
+ function lastSegment(slug) {
200
+ return slug.split("/").pop() ?? slug;
201
+ }
202
+ /** Collect Heading[] from an assembled tree (nodes already carry props.slug/level/text). */
203
+ function extractBookHeadings(tree) {
204
+ const out = [];
205
+ const walk = (ns) => {
206
+ for (const n of ns) {
207
+ if (n.type === "heading") {
208
+ out.push({
209
+ depth: n.props?.level ?? 1,
210
+ slug: n.props?.slug ?? "",
211
+ text: n.props?.text ?? inlineText(n),
212
+ });
213
+ }
214
+ if (n.children)
215
+ walk(n.children);
216
+ }
217
+ };
218
+ walk(tree);
219
+ return out;
220
+ }
221
+ function inlineText(node) {
222
+ const parts = [];
223
+ const fromInline = (ns = []) => {
224
+ for (const n of ns) {
225
+ if (typeof n.text === "string")
226
+ parts.push(n.text);
227
+ else if (Array.isArray(n.children))
228
+ fromInline(n.children);
229
+ }
230
+ };
231
+ if (node.inline)
232
+ fromInline(node.inline);
233
+ if (node.children)
234
+ for (const c of node.children)
235
+ parts.push(inlineText(c));
236
+ return parts.join("").trim();
237
+ }
238
+ export function slugify(text) {
239
+ return (text
240
+ .toLowerCase()
241
+ .replace(/[^\w\s-]/g, "")
242
+ .replace(/\s+/g, "-")
243
+ .replace(/-+/g, "-")
244
+ .replace(/^-+|-+$/g, "")
245
+ .trim() || "section");
246
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Head metadata per page (dogsbay-ssg plans/head-metadata.md, Blume item 2):
3
+ * whether it is a home page (JSON-LD `WebSite`, og:type website), its
4
+ * `hreflang` alternates across languages, and its nav trail for JSON-LD
5
+ * `BreadcrumbList`. The builders are @dogsbay/site-files', shared with
6
+ * dogsbay-ssg; this file finds the inputs in the imported pages and the nav.
7
+ */
8
+ import type { ExportPage, NavItem } from "@dogsbay/types";
9
+ import { type HreflangLink } from "@dogsbay/site-files";
10
+ export interface HeadMetadata {
11
+ home: boolean;
12
+ alternates: HreflangLink[];
13
+ breadcrumbJsonLd?: Record<string, unknown>;
14
+ }
15
+ export interface HeadMetadataOptions {
16
+ siteUrl?: string;
17
+ /** urlBase + basePath, as the sitemap takes it. */
18
+ basePath?: string;
19
+ defaultLocale?: string;
20
+ /** Pages the build writes (not fragments or excluded routes); others are never an alternate. Drafts and redirects are left out as well. */
21
+ isEmitted?: (p: ExportPage) => boolean;
22
+ /** Pages that list alternates: their own canonical and indexable (hreflang between non-canonical URLs is ignored by search engines). */
23
+ indexable?: (p: ExportPage) => boolean;
24
+ }
25
+ export declare function headMetadata(pages: ExportPage[], nav: NavItem[], o: HeadMetadataOptions): Map<string, HeadMetadata>;
@@ -0,0 +1,55 @@
1
+ import { buildBreadcrumbJsonLd, buildHreflangLinks } from "@dogsbay/site-files";
2
+ import { normalizePrefix, originOf, pageHref } from "./sitemap.js";
3
+ /** Path inside the page's source (`guide/install`), `index` for a home page. */
4
+ function inside(p) {
5
+ const orig = (p.multiSource?.originalSlug ?? p.slug).replace(/^\/+|\/+$/g, "");
6
+ return orig === "" || orig === "index" ? "index" : orig.replace(/\/index$/, "");
7
+ }
8
+ export function headMetadata(pages, nav, o) {
9
+ const plan = new Map();
10
+ const live = (p) => !p.redirect && p.frontmatter?.draft !== true && p.frontmatter?.status !== "draft" && (o.isEmitted?.(p) ?? true);
11
+ const prefix = normalizePrefix(o.basePath);
12
+ const origin = o.siteUrl && /^https?:\/\//.test(o.siteUrl) ? originOf(o.siteUrl).replace(/\/+$/, "") : undefined;
13
+ const url = (slug) => (origin ? `${origin}${pageHref(slug, prefix)}` : undefined);
14
+ // Copies of one page across languages: same namespace, version and path inside the source.
15
+ const copies = new Map();
16
+ const locales = new Set(pages.map((p) => p.multiSource?.locale).filter(Boolean));
17
+ if (origin && locales.size >= 2) {
18
+ for (const p of pages) {
19
+ const m = p.multiSource;
20
+ if (!m?.locale || !live(p))
21
+ continue;
22
+ const key = `${m.namespace ?? ""}\0${m.version ?? ""}\0${inside(p)}`;
23
+ const list = copies.get(key) ?? [];
24
+ list.push({ lang: m.locale, url: url(p.slug) });
25
+ copies.set(key, list);
26
+ }
27
+ }
28
+ // The nav trail to each page: labels and hrefs from the top level down.
29
+ const trails = new Map();
30
+ const clean = (href) => href.replace(/[?#].*$/, "").replace(/^\/+|\/+$/g, "").replace(/(^|\/)index$/, "");
31
+ const mount = prefix.replace(/^\/+/, "");
32
+ const key = (href) => {
33
+ const h = clean(href);
34
+ return mount && (h === mount || h.startsWith(`${mount}/`)) ? h.slice(mount.length).replace(/^\/+/, "") : h;
35
+ };
36
+ const walk = (items, chain) => {
37
+ for (const it of items) {
38
+ const here = [...chain, { name: it.label, href: it.href }];
39
+ if (it.href && !/^[a-z][a-z0-9+.-]*:/i.test(it.href) && !it.href.includes("#") && !trails.has(key(it.href)))
40
+ trails.set(key(it.href), here);
41
+ if (it.children?.length)
42
+ walk(it.children, here);
43
+ }
44
+ };
45
+ walk(nav, []);
46
+ for (const p of pages) {
47
+ const home = inside(p) === "index";
48
+ const m = p.multiSource;
49
+ const alternates = m?.locale && live(p) && (o.indexable?.(p) ?? true) ? buildHreflangLinks(copies.get(`${m.namespace ?? ""}\0${m.version ?? ""}\0${inside(p)}`) ?? [], o.defaultLocale) : [];
50
+ const trail = origin && !home ? trails.get(clean(p.slug)) : undefined;
51
+ const breadcrumbJsonLd = trail ? buildBreadcrumbJsonLd(trail.map((t) => ({ name: t.name, url: t.href ? url(key(t.href)) : undefined }))) : undefined;
52
+ plan.set(p.slug, { home, alternates, ...(breadcrumbJsonLd ? { breadcrumbJsonLd } : {}) });
53
+ }
54
+ return plan;
55
+ }
package/dist/index.d.ts CHANGED
@@ -1,16 +1,36 @@
1
1
  export { treeToAstro, escapeExpr, escapeAttr, escapeTemplate } from "./serialize.js";
2
- export { detectLeadingNodes } from "./lead.js";
2
+ export { writeFileSync, copyFileSync } from "./write-if-changed.js";
3
+ export { ASTRO_CAPABILITIES } from "./capabilities.js";
4
+ export { detectLeadingNodes, deriveDescription } from "./lead.js";
3
5
  export type { LeadingNodeInfo } from "./lead.js";
4
6
  export type { AstroGenResult, AstroGenOptions, AstroRenderMode } from "./serialize.js";
5
- export { DEFAULT_BASE_PATH, normalizeBasePath, basePathSegments, joinBaseUrl, buildCurrentPath, } from "./base-path.js";
7
+ export { DEFAULT_BASE_PATH, normalizeBasePath, basePathSegments, joinBaseUrl, buildCurrentPath, parseSiteUrl, combinePrefix, resolvePrefixes, } from "./base-path.js";
6
8
  export { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt, extractFirstParagraph, } from "./llms-txt.js";
7
9
  export type { BuildLlmsTxtOptions, BuildLlmsFullTxtOptions, } from "./llms-txt.js";
8
- export { buildSitemap, buildSitemapIndex } from "./sitemap.js";
10
+ export { assertLeafSitemap, buildSitemap, buildSitemapFiles, buildSitemapIndex } from "./sitemap.js";
11
+ export { buildBlogData, blogAdjacency, emitBlogFiles, indexPageCount, readingMinutes, countWords, BLOG_DEFAULTS, } from "./blog.js";
12
+ export type { BlogEmitConfig, BlogData, BlogPostRef, BlogAdjacency, } from "./blog.js";
9
13
  export type { BuildSitemapOptions } from "./sitemap.js";
10
- export { exportAstroProject, emitSiteScaffold, emitSiteConfig, emitAstroPages, emitConfigDerivedFiles, emitAgentReadinessFiles, emitSwitcherMap, emitMissingTranslationStubs, } from "./project.js";
11
- export type { AstroProjectOptions, SwitcherMap } from "./project.js";
14
+ export { exportAstroProject, emitSiteScaffold, emitSiteConfig, emitAstroPages, emitConfigDerivedFiles, syncSiteDependencies, copyComponents, emitAgentReadinessFiles, emitSwitcherMap, emitMissingTranslationStubs, emitPassthroughAstroPages, emitDeployArtifacts, workersSubpathOutDir, makeSlugExcluder, dropUnsafeNavHrefs, } from "./project.js";
15
+ export type { AstroProjectOptions, SwitcherMap, PassthroughCopy, } from "./project.js";
12
16
  export { emitPluginRuntime } from "./plugins.js";
13
17
  export type { EmitPluginRuntimeOptions, PluginClientModulesGroup, PluginStylesGroup, PluginClientConfig, } from "./plugins.js";
14
18
  export { buildTaxonomyData, emitTaxonomyForName, emitTaxonomyRoutes, getPageTerms, } from "./taxonomy.js";
15
19
  export type { TaxonomyEmitConfig, TaxonomyTerm, TaxonomyPageRef, TaxonomyData, } from "./taxonomy.js";
16
- //# sourceMappingURL=index.d.ts.map
20
+ export { DIFF_CSS, decorateDiff, diffMarksOf, hasDiffMarks, injectAttrs, inlineSegmentsToHtml, } from "./diff-decoration.js";
21
+ export type { DiffMarks } from "./diff-decoration.js";
22
+ /**
23
+ * Granularity book view — assemble a nav subtree into one page.
24
+ *
25
+ * Exported because it is the PLATFORM generalization of the same algorithm
26
+ * `format-fluidtopics-rest/src/single-page.ts` implements against khub TOCs:
27
+ * without an export, no other package could reach it, which is a large part of
28
+ * why the fold described in that module never happened.
29
+ *
30
+ * See plans/granularity-views.md and docs/single-page-view.md.
31
+ */
32
+ export { BOOK_SEGMENT, assembleBook, bookHeading, bookSlugFrom, estimateTreeBytes, openAsPageNode, rewriteIntraBookLinks, shiftHeadings, slugFromHref, } from "./granularity.js";
33
+ export type { AssembleBookOptions, BookView } from "./granularity.js";
34
+ export { pageUnitPrefix, inPageUnit, normalizeTarget, collectRedirects } from "./redirects.js";
35
+ export { canonicalPlan } from "./canonical.js";
36
+ export { TONES, CARD_TONE_CLASSES, TAB_VARIANTS, THEME_SCOPES } from "./tone-classes.js";
package/dist/index.js CHANGED
@@ -1,18 +1,25 @@
1
1
  // Astro serializer (TreeNode[] → Astro component markup)
2
2
  export { treeToAstro, escapeExpr, escapeAttr, escapeTemplate } from "./serialize.js";
3
+ export { writeFileSync, copyFileSync } from "./write-if-changed.js";
4
+ // Component × format matrix: what the page output represents, per TreeNode type.
5
+ export { ASTRO_CAPABILITIES } from "./capabilities.js";
3
6
  // Auto-lede detection — figures out whether a page tree already
4
7
  // has a leading H1 / paragraph so format-astro can decide whether
5
8
  // to inject frontmatter title + description at the top of <main>.
6
9
  // See plans/auto-lede.md.
7
- export { detectLeadingNodes } from "./lead.js";
10
+ export { detectLeadingNodes, deriveDescription } from "./lead.js";
8
11
  // Base-path helpers — single source of truth for the `basePath` URL
9
12
  // prefix shared across project / nav / llms-txt / taxonomy emitters.
10
13
  // See plans/configurable-base-path.md.
11
- export { DEFAULT_BASE_PATH, normalizeBasePath, basePathSegments, joinBaseUrl, buildCurrentPath, } from "./base-path.js";
14
+ export { DEFAULT_BASE_PATH, normalizeBasePath, basePathSegments, joinBaseUrl, buildCurrentPath, parseSiteUrl, combinePrefix, resolvePrefixes, } from "./base-path.js";
12
15
  // llms.txt + llms-full.txt builders (LLM discovery files)
13
16
  export { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt, extractFirstParagraph, } from "./llms-txt.js";
14
17
  // Sitemap builders (per-mount, sitemaps.org 0.9)
15
- export { buildSitemap, buildSitemapIndex } from "./sitemap.js";
18
+ export { assertLeafSitemap, buildSitemap, buildSitemapFiles, buildSitemapIndex } from "./sitemap.js";
19
+ // Blog emission — reverse-chron index + post adjacency. The sibling of
20
+ // taxonomy.ts: same data-file + generated-route + writer-override shape,
21
+ // sorted by date instead of grouped by term. See plans/blog-capability.md.
22
+ export { buildBlogData, blogAdjacency, emitBlogFiles, indexPageCount, readingMinutes, countWords, BLOG_DEFAULTS, } from "./blog.js";
16
23
  // Project export — orchestrator + per-tier emitters
17
24
  //
18
25
  // `exportAstroProject` is the high-level entry: generates a complete
@@ -24,7 +31,14 @@ export { buildSitemap, buildSitemapIndex } from "./sitemap.js";
24
31
  // alone; `dogsbay site build` calls `emitAstroPages` +
25
32
  // `emitConfigDerivedFiles` + `emitAgentReadinessFiles`. Pure
26
33
  // `dogsbay convert --to astro` (Step 7) calls only `emitAstroPages`.
27
- export { exportAstroProject, emitSiteScaffold, emitSiteConfig, emitAstroPages, emitConfigDerivedFiles, emitAgentReadinessFiles, emitSwitcherMap, emitMissingTranslationStubs, } from "./project.js";
34
+ export { exportAstroProject, emitSiteScaffold, emitSiteConfig, emitAstroPages, emitConfigDerivedFiles, syncSiteDependencies, copyComponents, emitAgentReadinessFiles, emitSwitcherMap, emitMissingTranslationStubs, emitPassthroughAstroPages, emitDeployArtifacts,
35
+ // Where a Workers subpath-mounted build actually lands. Consumers that
36
+ // read the BUILT site (e.g. `dogsbay site check --dist`) must resolve
37
+ // the same directory the emitter told Astro to write to, or they audit
38
+ // an empty dist/ root.
39
+ workersSubpathOutDir, makeSlugExcluder,
40
+ // Drops nav hrefs with a scheme other than http(s)/mailto/tel.
41
+ dropUnsafeNavHrefs, } from "./project.js";
28
42
  // Plugin runtime codegen — emits the entry, virtual config modules,
29
43
  // stylesheets, and Vite alias file each plugin needs to participate
30
44
  // in the Astro / Vite build. See plans/plugin-api.md.
@@ -33,4 +47,19 @@ export { emitPluginRuntime } from "./plugins.js";
33
47
  // Per declared taxonomy in `dogsbay.config.yml`'s `taxonomies:` block,
34
48
  // emits a JSON data file + index/term .astro route files.
35
49
  export { buildTaxonomyData, emitTaxonomyForName, emitTaxonomyRoutes, getPageTerms, } from "./taxonomy.js";
36
- //# sourceMappingURL=index.js.map
50
+ export { DIFF_CSS, decorateDiff, diffMarksOf, hasDiffMarks, injectAttrs, inlineSegmentsToHtml, } from "./diff-decoration.js";
51
+ /**
52
+ * Granularity book view — assemble a nav subtree into one page.
53
+ *
54
+ * Exported because it is the PLATFORM generalization of the same algorithm
55
+ * `format-fluidtopics-rest/src/single-page.ts` implements against khub TOCs:
56
+ * without an export, no other package could reach it, which is a large part of
57
+ * why the fold described in that module never happened.
58
+ *
59
+ * See plans/granularity-views.md and docs/single-page-view.md.
60
+ */
61
+ export { BOOK_SEGMENT, assembleBook, bookHeading, bookSlugFrom, estimateTreeBytes, openAsPageNode, rewriteIntraBookLinks, shiftHeadings, slugFromHref, } from "./granularity.js";
62
+ export { pageUnitPrefix, inPageUnit, normalizeTarget, collectRedirects } from "./redirects.js";
63
+ export { canonicalPlan } from "./canonical.js";
64
+ // Variants, tones and scoped themes: the class tables both renderers draw from
65
+ export { TONES, CARD_TONE_CLASSES, TAB_VARIANTS, THEME_SCOPES } from "./tone-classes.js";