@dogsbay/format-gitbook 0.2.0-beta.129 → 0.2.0-beta.131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * An imported GitBook corpus → a Dogsbay project, as data (no Node, quiet, deterministic):
3
+ * `content/<slug>.md` (front matter + translated body), fragments as include targets,
4
+ * `content/nav.yml` from SUMMARY.md, `dogsbay.config.yml`, `MIGRATION.md`, the assets to copy
5
+ * and the redirects. `migrate-gitbook` writes it to disk and adds the Astro scaffold and
6
+ * `_redirects`; the browser (`./migrate`) returns it as a map of files.
7
+ */
8
+ import * as yaml from "js-yaml";
9
+ import { gitbookVisible, within } from "@dogsbay/detect";
10
+ function yamlQuote(s) {
11
+ return /[:#&*!|>%@`,'"[\]{}]|^\s|\s$/.test(s) ? JSON.stringify(s) : s;
12
+ }
13
+ /** Serialize NavItem[] to nav.yml (mirrors migrate-asciidoc's navToYaml). */
14
+ function navToYaml(items, indent = 0) {
15
+ const pad = " ".repeat(indent);
16
+ const lines = [];
17
+ for (const item of items) {
18
+ const hasChildren = item.children !== undefined && item.children.length > 0;
19
+ const target = item.href === undefined
20
+ ? undefined
21
+ : /^(?:https?:\/\/|mailto:|tel:)/i.test(item.href) || item.href.includes("#")
22
+ ? item.href
23
+ : `${item.href.replace(/^\/+/, "")}.md`;
24
+ if (target !== undefined && !hasChildren) {
25
+ lines.push(`${pad}- ${yamlQuote(item.label)}: ${target}`);
26
+ }
27
+ else {
28
+ lines.push(`${pad}- ${yamlQuote(item.label)}:`);
29
+ if (target !== undefined)
30
+ lines.push(`${pad} - ${yamlQuote(target)}`);
31
+ if (hasChildren)
32
+ lines.push(navToYaml(item.children, indent + 2));
33
+ }
34
+ }
35
+ return lines.join("\n");
36
+ }
37
+ function buildConfig(siteName, siteUrl) {
38
+ const lines = [`schemaVersion: 1`, `site:`, ` name: ${yamlQuote(siteName)}`, ` basePath: ""`];
39
+ if (siteUrl)
40
+ lines.push(` url: ${yamlQuote(siteUrl)}`);
41
+ lines.push(`content:`, ` sources:`, ` - path: ./content`, ` from: dogsbay-md`,
42
+ // `.gitbook/includes` fragments are `{% include %}` targets, not routable pages.
43
+ // routablesFrom: nav restricts routes to SUMMARY-named pages; the fragments stay on disk
44
+ // so includes resolve.
45
+ ` excludeFromRoutes:`, ` - .gitbook`, ` routablesFrom: nav`, ``);
46
+ return lines.join("\n");
47
+ }
48
+ /**
49
+ * Front matter (source keys + a title) as a YAML block. Array/object values (`tags: [a, b]`,
50
+ * GitBook `cover:` maps) round-trip instead of being dropped (review #4). The title is the
51
+ * source `title:` only when it's a non-empty string; otherwise the computed title (H1 / slug):
52
+ * a blank source title must NOT clobber it (review #6).
53
+ */
54
+ // No `noRefs`: an alias the source used stays an alias. Writing each out in full turned ten
55
+ // lines of nested aliases into hundreds of MB (an alias bomb, review 2026-10-20).
56
+ function frontmatterBlock(fm, title) {
57
+ const sourceTitle = typeof fm.title === "string" && fm.title.trim() ? fm.title : undefined;
58
+ const out = { title: sourceTitle ?? title, ...fm };
59
+ out.title = sourceTitle ?? title;
60
+ const body = yaml.dump(out, { lineWidth: -1, skipInvalid: true }).replace(/\n$/, "");
61
+ return `---\n${body}\n---\n\n`;
62
+ }
63
+ /** A redirect's target as the URL it is served at: drop ./ and .md; a README is its folder. */
64
+ function servedUrl(target) {
65
+ // A bare `README` maps to the site root `/`, not a dead `/README` (review #5).
66
+ return `/${target.replace(/^\.\//, "").replace(/\.md$/i, "").replace(/(^|\/)README$/i, "$1").replace(/\/$/, "")}`;
67
+ }
68
+ function migrationNotes(a) {
69
+ const lines = [
70
+ `# Migration: ${a.siteName}`,
71
+ ``,
72
+ `Migrated from a GitBook Git Sync corpus by \`dogsbay migrate-gitbook\`.`,
73
+ ``,
74
+ `- **Source**: \`${a.sourceLabel}\``,
75
+ `- **Pages**: ${a.pages}${a.fragments ? ` (+ ${a.fragments} include fragments)` : ""}`,
76
+ ];
77
+ if (a.spaces.length > 1)
78
+ lines.push(`- **Spaces**: ${a.spaces.join(", ")} (each a top-level nav section)`);
79
+ if (a.redirects)
80
+ lines.push(`- **Redirects**: ${a.redirects}${a.redirectsFile ? ` carried into \`${a.redirectsFile}\`` : ""}`);
81
+ lines.push(``, `## What was converted`, ``, `- GitBook \`{% hint %}\` → \`:::note/:::warning/:::info/:::danger\` callouts`, `- \`{% tabs %}\` / \`{% stepper %}\` / \`{% columns %}\` → \`:::tabs\` / \`:::steps\` / \`:::grid\``, `- \`{% code %}\`, \`{% content-ref %}\`, \`{% embed %}\`, \`{% file %}\`, \`{% page-ref %}\` → native Dogsbay-MD`, `- \`{% include %}\` reusable content preserved (resolved at build time)`, `- \`<figure>\` images, GitBook emoji spans, raw HTML tables handled`, `- \`SUMMARY.md\` → \`content/nav.yml\`; \`.gitbook.yaml\` redirects kept`, ``, `## Known caveats`, ``, `- \`{% openapi %}\` / \`{% swagger %}\` blocks emit \`:::api-endpoint\` placeholders — wire them to the OpenAPI spec for full rendering.`);
82
+ if (a.ledger.length) {
83
+ lines.push(``, `## Review these`, ``);
84
+ for (const e of a.ledger)
85
+ lines.push(`- \`${e.file ?? "?"}${e.line ? `:${e.line}` : ""}\`: ${e.message}`);
86
+ }
87
+ return lines.join("\n") + "\n";
88
+ }
89
+ export function gitbookProject(result, corpus, opts) {
90
+ const files = new Map();
91
+ const ledger = [...result.ledger];
92
+ // Two source files can slugify to the same path (README.md and index.md both → `index`).
93
+ // Keep the first, skip and note the rest rather than silently overwriting a page (review #3).
94
+ // Paths compared without case: on macOS and Windows `INDEX.md` overwrites `index.md`.
95
+ const taken = new Set();
96
+ let fragments = 0;
97
+ let collisions = 0;
98
+ for (const page of result.pages) {
99
+ const dst = `content/${page.slug}.md`;
100
+ if (taken.has(dst.toLowerCase())) {
101
+ collisions += 1;
102
+ ledger.push({ severity: "warning", code: "slug-collision", message: `two pages map to ${dst}: kept the first, skipped a later one` });
103
+ continue;
104
+ }
105
+ taken.add(dst.toLowerCase());
106
+ const body = page.bodyMarkdown ?? "";
107
+ const text = body.endsWith("\n") ? body : body + "\n";
108
+ if (page.fragment) {
109
+ // Fragments: pure body, no frontmatter (they're include targets).
110
+ files.set(dst, text);
111
+ fragments += 1;
112
+ }
113
+ else {
114
+ files.set(dst, frontmatterBlock(page.frontmatter ?? {}, page.title) + text);
115
+ }
116
+ }
117
+ // Non-`.md` files under each docs root, at the same relative place under content/, so the
118
+ // translated relative image refs resolve. From the docs root, not the repo root: with
119
+ // `root: ./docs/gitbook/` unrelated repo source must not land in content/.
120
+ const assets = [];
121
+ for (const root of result.assetRoots) {
122
+ for (const path of corpus.paths) {
123
+ if (!within(path, root.dir) || path === root.dir)
124
+ continue;
125
+ // Only what stays in the docs root once links resolve: a link named `env.txt` to the
126
+ // repo's `.env`, or `g` to `.git`, must not be published with the site.
127
+ if (corpus.contains && !corpus.contains(path, root.dir))
128
+ continue;
129
+ const rel = root.dir ? path.slice(root.dir.length + 1) : path;
130
+ if (!gitbookVisible(rel))
131
+ continue;
132
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
133
+ // A hidden file is the repo's (`.env`, `.npmrc`, `.gitbook/assets/.DS_Store`), never a
134
+ // page's asset; `.gitbook.yaml` is read as config, not published.
135
+ if (name.toLowerCase().endsWith(".md") || name.startsWith("."))
136
+ continue;
137
+ assets.push({ from: path, to: `content/${root.prefix}${rel}` });
138
+ }
139
+ }
140
+ files.set("content/nav.yml", `# Migrated by dogsbay migrate-gitbook.\n# Edit freely.\n\n${navToYaml(result.nav)}\n`);
141
+ files.set("dogsbay.config.yml", buildConfig(opts.siteName, opts.siteUrl));
142
+ files.set("MIGRATION.md", "");
143
+ // A corpus file at a generated path (a `nav.yml` in the docs root) must not replace it, nor
144
+ // may two assets that differ only in case replace each other
145
+ for (const f of files.keys())
146
+ taken.add(f.toLowerCase());
147
+ const copies = assets.filter((a) => {
148
+ const key = a.to.toLowerCase();
149
+ if (!taken.has(key)) {
150
+ taken.add(key);
151
+ return true;
152
+ }
153
+ ledger.push({ file: a.from, severity: "warning", code: "file-replaced", message: `not copied: ${a.to} would replace a file the migration writes` });
154
+ return false;
155
+ });
156
+ // A redirect is one `_redirects` line: no whitespace or control character in either side, or
157
+ // a crafted key (`"x https://evil 301\n/y"`) adds a line of its own. A backslash is a slash
158
+ // to a browser (`/\evil.example` is another host), and a `*` or `:name` in the old path is a
159
+ // pattern that would capture the whole site.
160
+ const redirects = [];
161
+ for (const [from, target] of Object.entries(result.redirects)) {
162
+ const r = { from: `/${from.replace(/^\/+/, "")}`, to: servedUrl(target) };
163
+ if (/[\s\0-\x1f\\]/.test(r.from + r.to) ||
164
+ r.from.startsWith("//") ||
165
+ r.to.startsWith("//") ||
166
+ /\*|(^|\/):/.test(r.from)) {
167
+ ledger.push({ file: ".gitbook.yaml", severity: "warning", code: "redirect-refused", message: `redirect not kept: ${JSON.stringify(from)} → ${JSON.stringify(target)}` });
168
+ continue;
169
+ }
170
+ redirects.push(r);
171
+ }
172
+ const pages = result.pages.filter((p) => !p.fragment).length;
173
+ files.set("MIGRATION.md", migrationNotes({ ...opts, pages, fragments, spaces: result.spaces, redirects: redirects.length, ledger }));
174
+ return { files, assets: copies, redirects, ledger, stats: { pages, fragments, collisions, spaces: result.spaces } };
175
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dogsbay/format-gitbook",
3
- "version": "0.2.0-beta.129",
4
- "description": "GitBook format plugin for Dogsbay — imports GitBook Git Sync (and legacy gitbook-cli) corpora into TreeNode[] by translating the Git Sync markdown dialect to Dogsbay-MD.",
3
+ "version": "0.2.0-beta.131",
4
+ "description": "GitBook format plugin for Dogsbay — imports GitBook Git Sync (and legacy gitbook-cli) corpora by translating the Git Sync markdown dialect to Dogsbay-MD. ./migrate converts a corpus given as a map of files in the browser (no Node), the same import the CLI runs.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -13,6 +13,10 @@
13
13
  "./cli": {
14
14
  "types": "./dist/cli.d.ts",
15
15
  "import": "./dist/cli.js"
16
+ },
17
+ "./migrate": {
18
+ "types": "./dist/migrate.d.ts",
19
+ "import": "./dist/migrate.js"
16
20
  }
17
21
  },
18
22
  "files": [
@@ -20,12 +24,15 @@
20
24
  "README.md"
21
25
  ],
22
26
  "dependencies": {
23
- "yaml": "^2.9.0",
24
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.129",
25
- "@dogsbay/types": "0.2.0-beta.129"
27
+ "js-yaml": "^5.2.1",
28
+ "@dogsbay/detect": "0.2.0-beta.131",
29
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.131",
30
+ "@dogsbay/types": "0.2.0-beta.131"
26
31
  },
27
32
  "devDependencies": {
33
+ "@types/js-yaml": "^4.0.9",
28
34
  "@types/node": "^25.9.5",
35
+ "esbuild": "^0.25.0",
29
36
  "typescript": "^5.9.3",
30
37
  "vitest": "^4.1.10"
31
38
  },