@dogsbay/format-gitbook 0.2.0-beta.130 → 0.2.0-beta.132

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.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @dogsbay/format-gitbook
2
+
3
+ GitBook (Git Sync) import for Dogsbay: the `{% %}` blocks translated to Dogsbay Markdown,
4
+ `SUMMARY.md` as the navigation, `.gitbook.yaml` read as data. Nothing in the repository is run.
5
+
6
+ | Entry | For |
7
+ |---|---|
8
+ | `@dogsbay/format-gitbook/migrate` | The browser: a corpus as a map of files → a Dogsbay project as a map of files. No Node |
9
+ | `@dogsbay/format-gitbook` | The parts, with no Node: `parseGitbook`, `gitbookToDogsbayMd`, `importGitbookCorpus`, `gitbookProject` |
10
+ | `@dogsbay/format-gitbook/cli` | Node: the `dogsbay` CLI's format plugin, and a folder as a corpus |
11
+
12
+ ```ts
13
+ import { migrate } from "@dogsbay/format-gitbook/migrate";
14
+ const { files, ledger, redirects } = await migrate({ files: corpus, siteName: "My Docs" });
15
+ ```
16
+
17
+ The CLI's `dogsbay migrate-gitbook` runs the same import and project writer, so both give the
18
+ same project. Find the corpus in a repository with `@dogsbay/detect`. See
19
+ [Migrating from GitBook](https://github.com/dogsbay/dogsbay/blob/main/docs/migrating-gitbook.md).
20
+
21
+ ## License
22
+
23
+ MIT
package/dist/cli.d.ts CHANGED
@@ -1,14 +1,11 @@
1
- import type { FormatPlugin, ExportPage, NavItem } from "@dogsbay/types";
2
- export interface GitbookImportResult {
3
- pages: ExportPage[];
4
- nav: NavItem[];
5
- redirects: Record<string, string>;
6
- spaces: string[];
7
- /** Docs roots to mirror non-`.md` assets from (dir + slug prefix). */
8
- assetRoots: {
9
- dir: string;
10
- prefix: string;
11
- }[];
12
- }
13
- export declare function importGitbook(source: string, opts: Record<string, unknown>): Promise<GitbookImportResult>;
1
+ import type { FormatPlugin } from "@dogsbay/types";
2
+ import { type GitbookImportResult } from "./import.js";
3
+ import { type FolderCorpus } from "./loaders/folder.js";
4
+ export { readFolder, readGitbookFolder, type FolderCorpus } from "./loaders/folder.js";
5
+ export { gitbookProject, type GitbookProject, type GitbookProjectOptions } from "./project.js";
6
+ export type { GitbookImportResult } from "./import.js";
7
+ /** Import a GitBook folder. Prints a one-line summary of the ledger, as the CLI always has. */
8
+ export declare function importGitbook(source: string, opts: Record<string, unknown>): Promise<GitbookImportResult & {
9
+ corpus: FolderCorpus;
10
+ }>;
14
11
  export declare const plugin: FormatPlugin;
package/dist/cli.js CHANGED
@@ -1,78 +1,38 @@
1
1
  /**
2
- * FormatPlugin for GitBook as a source format.
2
+ * FormatPlugin for GitBook as a source format (Node: reads a folder).
3
3
  *
4
- * Orchestrates: detect corpus → loadGitbook (config + SUMMARY + pages) →
5
- * translate each page (parseGitbook) → assemble ExportPage[] + NavItem[].
6
- * Fragments (`.gitbook/includes/*`) are emitted with `fragment: true` so
7
- * the CLI writes them as `{% include %}` targets, not routable pages —
8
- * the same fragment model AsciiBinder/Antora use.
4
+ * A folder becomes a {@link Corpus} (`loaders/folder.ts`), and the import itself is
5
+ * `importGitbookCorpus`, the same one the browser entry runs. Fragments
6
+ * (`.gitbook/includes/*`) come back with `fragment: true` so the CLI writes them as
7
+ * `{% include %}` targets, not routable pages.
9
8
  *
10
- * The redirects from `.gitbook.yaml` are surfaced on the result so
11
- * `migrate-gitbook` can carry them into the site config.
9
+ * The redirects from `.gitbook.yaml` are surfaced on the result so `migrate-gitbook` can
10
+ * carry them into the site.
12
11
  */
13
- import { existsSync, readFileSync } from "node:fs";
14
- import { basename } from "node:path";
15
- import { extractHeadings } from "@dogsbay/format-dogsbay-md";
16
- import { parseGitbook } from "./parse.js";
17
- import { loadGitbook, detectGitbook } from "./loaders/load.js";
18
- function prettify(slug) {
19
- return slug
20
- .split(/[-_/]/)
21
- .filter(Boolean)
22
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
23
- .join(" ");
24
- }
12
+ import { existsSync } from "node:fs";
13
+ import { importGitbookCorpus } from "./import.js";
14
+ import { detectGitbook, readGitbookFolder } from "./loaders/folder.js";
15
+ export { readFolder, readGitbookFolder } from "./loaders/folder.js";
16
+ export { gitbookProject } from "./project.js";
17
+ /** Import a GitBook folder. Prints a one-line summary of the ledger, as the CLI always has. */
25
18
  export async function importGitbook(source, opts) {
26
19
  if (!existsSync(source)) {
27
20
  throw new Error(`Source directory not found: ${source}`);
28
21
  }
29
22
  const space = typeof opts.space === "string" ? opts.space : undefined;
30
- const hrefPrefix = typeof opts.hrefPrefix === "string" ? opts.hrefPrefix : "";
31
- const { pages: pageSources, nav, redirects, spaces, assetRoots } = loadGitbook(source, {
23
+ const corpus = readGitbookFolder(source, space);
24
+ const result = importGitbookCorpus(corpus, {
32
25
  space,
33
- hrefPrefix,
26
+ hrefPrefix: typeof opts.hrefPrefix === "string" ? opts.hrefPrefix : "",
34
27
  });
35
- const pages = [];
36
- let failed = 0;
37
- let ledgerTotal = 0;
38
- for (const ps of pageSources) {
39
- try {
40
- const content = readFileSync(ps.filePath, "utf-8");
41
- const { tree, frontmatter, body, ledger } = parseGitbook(content);
42
- ledgerTotal += ledger.length;
43
- const headings = extractHeadings(tree);
44
- const h1 = headings.find((h) => h.depth === 1);
45
- // A whitespace-only source `title:` must not win — `.trim()` so a
46
- // blank falls through to the H1 / slug (review #6).
47
- const fmTitle = typeof frontmatter.title === "string" && frontmatter.title.trim()
48
- ? frontmatter.title
49
- : undefined;
50
- const title = fmTitle || h1?.text || prettify(basename(ps.slug));
51
- pages.push({
52
- slug: ps.slug,
53
- title,
54
- tree,
55
- headings,
56
- frontmatter,
57
- meta: {},
58
- ...(ps.fragment ? { fragment: true } : {}),
59
- // Translated Dogsbay-MD body — what migrate-gitbook writes to disk.
60
- bodyMarkdown: body,
61
- });
62
- }
63
- catch (err) {
64
- failed += 1;
65
- const msg = err instanceof Error ? err.message : String(err);
66
- console.warn(`gitbook import: failed to convert ${ps.filePath} (slug "${ps.slug}"), skipping: ${msg}`);
67
- }
68
- }
69
- if (failed > 0) {
70
- console.warn(`gitbook import: ${failed} of ${pageSources.length} page(s) failed and were skipped`);
28
+ for (const e of result.ledger.filter((e) => e.code === "page-failed")) {
29
+ console.warn(`gitbook import: failed to convert ${e.file}, skipping: ${e.message}`);
71
30
  }
72
- if (ledgerTotal > 0) {
73
- console.warn(`gitbook import: ${ledgerTotal} construct(s) mapped with caveats (see per-page ledger)`);
31
+ const mapped = result.ledger.filter((e) => e.code === "unmapped-construct").length;
32
+ if (mapped > 0) {
33
+ console.warn(`gitbook import: ${mapped} construct(s) mapped with caveats (see the ledger)`);
74
34
  }
75
- return { pages, nav, redirects, spaces, assetRoots };
35
+ return { ...result, corpus };
76
36
  }
77
37
  export const plugin = {
78
38
  name: "gitbook",
@@ -87,11 +47,11 @@ export const plugin = {
87
47
  ],
88
48
  exportOptions: [],
89
49
  async import(source, opts) {
90
- const { pages, nav, assetRoots } = await importGitbook(source, opts);
91
- // Slugs are relative to the space's docs root (`.gitbook.yaml` `root:`,
92
- // or the `--space` folder), so references resolve there: a relative
93
- // `:::openapi` spec or include looked up from the repo root missed.
94
- const only = assetRoots.length === 1 && assetRoots[0].prefix === "" ? assetRoots[0].dir : undefined;
95
- return { pages, nav, ...(only ? { contentRoot: only } : {}) };
50
+ const { pages, nav, assetRoots, ledger, corpus } = await importGitbook(source, opts);
51
+ // Slugs are relative to the space's docs root (`.gitbook.yaml` `root:`, or the `--space`
52
+ // folder), so references resolve there: a relative `:::openapi` spec or include looked up
53
+ // from the repo root missed.
54
+ const only = assetRoots.length === 1 && assetRoots[0].prefix === "" ? corpus.absolute(assetRoots[0].dir) : undefined;
55
+ return { pages, nav, ledger, ...(only ? { contentRoot: only } : {}) };
96
56
  },
97
57
  };
@@ -0,0 +1,2 @@
1
+ /** The corpus an importer reads: `@dogsbay/corpus`, shared by every format's browser import. */
2
+ export { corpusFromMap, type Corpus, type CorpusFiles } from "@dogsbay/corpus";
package/dist/corpus.js ADDED
@@ -0,0 +1,2 @@
1
+ /** The corpus an importer reads: `@dogsbay/corpus`, shared by every format's browser import. */
2
+ export { corpusFromMap } from "@dogsbay/corpus";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * GitBook corpus → pages, nav, redirects and a ledger, over a {@link Corpus} (no Node, quiet).
3
+ * The one import: the plugin's `import()` (`convert`, `site build`), `migrate-gitbook` and
4
+ * the browser (`./migrate`) all run it.
5
+ *
6
+ * Fragments (`.gitbook/includes/*`) come out with `fragment: true`, so a writer emits them as
7
+ * `{% include %}` targets, not routable pages (the fragment model AsciiBinder/Antora use).
8
+ */
9
+ import type { ExportPage, ImportLedgerEntry, NavItem } from "@dogsbay/types";
10
+ import type { Corpus } from "./corpus.js";
11
+ import { type GitbookAssetRoot } from "./loaders/load.js";
12
+ export interface GitbookImportOptions {
13
+ /** Import one space (a subfolder with a SUMMARY.md) of a multi-space repo. */
14
+ space?: string;
15
+ /** URL prefix applied to nav hrefs. */
16
+ hrefPrefix?: string;
17
+ }
18
+ export interface GitbookImportResult {
19
+ pages: ExportPage[];
20
+ nav: NavItem[];
21
+ redirects: Record<string, string>;
22
+ spaces: string[];
23
+ /** Docs roots to mirror non-`.md` assets from (corpus-relative folder + slug prefix). */
24
+ assetRoots: GitbookAssetRoot[];
25
+ ledger: ImportLedgerEntry[];
26
+ }
27
+ export declare function importGitbookCorpus(corpus: Corpus, opts?: GitbookImportOptions): GitbookImportResult;
package/dist/import.js ADDED
@@ -0,0 +1,62 @@
1
+ import { extractHeadings } from "@dogsbay/format-dogsbay-md/browser";
2
+ import { loadGitbookCorpus, prettify } from "./loaders/load.js";
3
+ import { followRenames } from "./links.js";
4
+ import { parseGitbook } from "./parse.js";
5
+ export function importGitbookCorpus(corpus, opts = {}) {
6
+ const { pages: sources, nav, redirects, spaces, assetRoots } = loadGitbookCorpus(corpus, opts);
7
+ const pages = [];
8
+ const ledger = [];
9
+ // A page written under another file name (README.md → index.md, a landing page → index.md):
10
+ // links to it follow the rename.
11
+ const renames = new Map();
12
+ for (const ps of sources) {
13
+ if (ps.fragment)
14
+ continue;
15
+ const name = `${ps.slug.slice(ps.slug.lastIndexOf("/") + 1)}.md`;
16
+ if (name !== ps.path.slice(ps.path.lastIndexOf("/") + 1))
17
+ renames.set(ps.path, name);
18
+ }
19
+ for (const ps of sources) {
20
+ try {
21
+ const content = corpus.text(ps.path);
22
+ if (content === undefined)
23
+ throw new Error("the file could not be read");
24
+ const { tree, frontmatter, body, ledger: notes, bodyLine } = parseGitbook(content, { links: (body) => followRenames(body, ps.path, renames) });
25
+ for (const n of notes) {
26
+ ledger.push({
27
+ file: ps.path,
28
+ ...(n.line ? { line: n.line + bodyLine - 1 } : {}),
29
+ severity: "warning",
30
+ code: "unmapped-construct",
31
+ message: `${n.construct}: ${n.note}`,
32
+ });
33
+ }
34
+ const headings = extractHeadings(tree);
35
+ const h1 = headings.find((h) => h.depth === 1);
36
+ // A whitespace-only source `title:` must not win — `.trim()` so a blank falls through
37
+ // to the H1 / slug (review #6).
38
+ const fmTitle = typeof frontmatter.title === "string" && frontmatter.title.trim() ? frontmatter.title : undefined;
39
+ const title = fmTitle || h1?.text || prettify(ps.slug.slice(ps.slug.lastIndexOf("/") + 1));
40
+ pages.push({
41
+ slug: ps.slug,
42
+ title,
43
+ tree,
44
+ headings,
45
+ frontmatter,
46
+ meta: {},
47
+ ...(ps.fragment ? { fragment: true } : {}),
48
+ // Translated Dogsbay-MD body — what the project writer writes.
49
+ bodyMarkdown: body,
50
+ });
51
+ }
52
+ catch (err) {
53
+ ledger.push({
54
+ file: ps.path,
55
+ severity: "warning",
56
+ code: "page-failed",
57
+ message: `not converted, skipped: ${err instanceof Error ? err.message : String(err)}`,
58
+ });
59
+ }
60
+ }
61
+ return { pages, nav, redirects, spaces, assetRoots, ledger };
62
+ }
package/dist/index.d.ts CHANGED
@@ -2,9 +2,13 @@
2
2
  * @dogsbay/format-gitbook — GitBook (Git Sync + legacy gitbook-cli)
3
3
  * format plugin. Imports GitBook corpora into TreeNode[] by translating
4
4
  * the Git Sync markdown dialect to Dogsbay-MD, then parsing with the
5
- * canonical dogsbay-md parser.
5
+ * canonical dogsbay-md parser. No Node in this entry or in `./migrate`;
6
+ * reading a folder is `./cli`.
6
7
  *
7
8
  * See plans/format-gitbook.md.
8
9
  */
9
10
  export { parseGitbook, type ParseGitbookResult } from "./parse.js";
10
11
  export { gitbookToDogsbayMd, type TranslateResult, type TranslateLedgerEntry, } from "./translate.js";
12
+ export { corpusFromMap, type Corpus, type CorpusFiles } from "./corpus.js";
13
+ export { importGitbookCorpus, type GitbookImportOptions, type GitbookImportResult } from "./import.js";
14
+ export { gitbookProject, type GitbookProject, type GitbookProjectOptions } from "./project.js";
package/dist/index.js CHANGED
@@ -2,9 +2,13 @@
2
2
  * @dogsbay/format-gitbook — GitBook (Git Sync + legacy gitbook-cli)
3
3
  * format plugin. Imports GitBook corpora into TreeNode[] by translating
4
4
  * the Git Sync markdown dialect to Dogsbay-MD, then parsing with the
5
- * canonical dogsbay-md parser.
5
+ * canonical dogsbay-md parser. No Node in this entry or in `./migrate`;
6
+ * reading a folder is `./cli`.
6
7
  *
7
8
  * See plans/format-gitbook.md.
8
9
  */
9
10
  export { parseGitbook } from "./parse.js";
10
11
  export { gitbookToDogsbayMd, } from "./translate.js";
12
+ export { corpusFromMap } from "./corpus.js";
13
+ export { importGitbookCorpus } from "./import.js";
14
+ export { gitbookProject } from "./project.js";
@@ -0,0 +1,4 @@
1
+ /** Corpus path of a page → the file name it is written under, when that differs. */
2
+ export type Renames = Map<string, string>;
3
+ /** Rewrite the page links in `markdown`, a page at corpus path `from`. */
4
+ export declare function followRenames(markdown: string, from: string, renames: Renames): string;
package/dist/links.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Page links that follow a renamed file. The migration writes `README.md` as `index.md`, and a
3
+ * space's landing page (`structure.readme: intro.md`) as `index.md` too, in the same folder.
4
+ * A link that named the old file (`guide/README.md`, `../intro.md#setup`) would then name
5
+ * nothing, so it is rewritten to the new file name; everything else is left as written.
6
+ *
7
+ * Text work over the translated Dogsbay MD, before it is parsed, so the written page and the
8
+ * tree agree. Fenced code and code spans are left alone: a page that SHOWS a link keeps it.
9
+ */
10
+ import { dirOf, joinPath, normalizePath } from "@dogsbay/detect";
11
+ const FENCE_RE = /^\s*(`{3,}|~{3,})/; // any indent: fences sit inside list items and directives
12
+ /** `](dest)` / `](<dest>)`, a reference definition `[x]: dest`, and `href="dest"`. */
13
+ const DEST_RE = /(\]\(\s*)(<[^>\n]*>|[^)\s]+)|(^ {0,3}\[[^\]\n]+\]:\s*)(<[^>\n]*>|\S+)|(\bhref=")([^"\n]*)(")/g;
14
+ /** Rewrite the page links in `markdown`, a page at corpus path `from`. */
15
+ export function followRenames(markdown, from, renames) {
16
+ if (renames.size === 0)
17
+ return markdown;
18
+ const base = dirOf(from);
19
+ let fence;
20
+ return markdown
21
+ .split("\n")
22
+ .map((line) => {
23
+ const f = FENCE_RE.exec(line);
24
+ if (fence) {
25
+ if (f && f[1][0] === fence[0] && f[1].length >= fence.length && line.trim() === f[1])
26
+ fence = undefined;
27
+ return line;
28
+ }
29
+ if (f) {
30
+ fence = f[1];
31
+ return line;
32
+ }
33
+ // Code spans stay as written: rewrite only the text between them
34
+ return line
35
+ .split(/(`+[^`]*`+)/)
36
+ .map((part, i) => (i % 2 ? part : part.replace(DEST_RE, (m, ...g) => rewrite(m, g, base, renames))))
37
+ .join("");
38
+ })
39
+ .join("\n");
40
+ }
41
+ function rewrite(match, g, base, renames) {
42
+ const [pre, dest, close] = g[0] !== undefined ? [g[0], g[1], ""] : g[2] !== undefined ? [g[2], g[3], ""] : [g[4], g[5], g[6]];
43
+ const angled = dest.startsWith("<") && dest.endsWith(">");
44
+ const raw = angled ? dest.slice(1, -1) : dest;
45
+ const cut = raw.search(/[#?]/);
46
+ const file = cut < 0 ? raw : raw.slice(0, cut);
47
+ // Only relative paths: not a scheme, a site path, or a bare #fragment
48
+ if (!file || /^[a-z][a-z0-9+.-]*:/i.test(file) || file.startsWith("/"))
49
+ return match;
50
+ let decoded = file;
51
+ try {
52
+ decoded = decodeURIComponent(file);
53
+ }
54
+ catch {
55
+ // keep it as written
56
+ }
57
+ const target = normalizePath(joinPath(base, decoded.replace(/^\.\//, "")));
58
+ const name = target === undefined ? undefined : renames.get(target);
59
+ if (name === undefined)
60
+ return match;
61
+ const slash = file.lastIndexOf("/");
62
+ const next = `${file.slice(0, slash + 1)}${name}${cut < 0 ? "" : raw.slice(cut)}`;
63
+ return `${pre}${angled ? `<${next}>` : next}${close}`;
64
+ }
@@ -4,5 +4,5 @@ export interface GitbookConfig {
4
4
  summary: string;
5
5
  redirects: Record<string, string>;
6
6
  }
7
- /** Read `.gitbook.yaml` from `dir` if present; else return defaults. */
8
- export declare function readGitbookConfig(dir: string): GitbookConfig;
7
+ /** Read a `.gitbook.yaml`'s text (undefined: no file); defaults for anything absent or unreadable. */
8
+ export declare function parseGitbookConfig(text: string | undefined): GitbookConfig;
@@ -8,24 +8,24 @@
8
8
  * summary: SUMMARY.md # table of contents, default SUMMARY.md
9
9
  * redirects:
10
10
  * old/path: new/page.md # old URL → target md file
11
+ *
12
+ * js-yaml, not `yaml`: the playground already ships it, and its `load` reads plain data only
13
+ * (no custom tags), which is all `.gitbook.yaml` holds.
11
14
  */
12
- import { existsSync, readFileSync } from "node:fs";
13
- import { join } from "node:path";
14
- import YAML from "yaml";
15
+ import * as yaml from "js-yaml";
15
16
  const DEFAULTS = {
16
17
  root: "./",
17
18
  readme: "README.md",
18
19
  summary: "SUMMARY.md",
19
20
  redirects: {},
20
21
  };
21
- /** Read `.gitbook.yaml` from `dir` if present; else return defaults. */
22
- export function readGitbookConfig(dir) {
23
- const p = join(dir, ".gitbook.yaml");
24
- if (!existsSync(p))
22
+ /** Read a `.gitbook.yaml`'s text (undefined: no file); defaults for anything absent or unreadable. */
23
+ export function parseGitbookConfig(text) {
24
+ if (text === undefined)
25
25
  return { ...DEFAULTS };
26
26
  let parsed;
27
27
  try {
28
- parsed = YAML.parse(readFileSync(p, "utf-8"));
28
+ parsed = yaml.load(text);
29
29
  }
30
30
  catch {
31
31
  return { ...DEFAULTS };
@@ -0,0 +1,19 @@
1
+ import { readFolder, type FolderCorpus } from "@dogsbay/corpus/folder";
2
+ import { type GitbookConfig } from "./config.js";
3
+ import { type GitbookLoadOptions, type GitbookLoadResult } from "./load.js";
4
+ export { readFolder, type FolderCorpus };
5
+ /** Read `.gitbook.yaml` from `dir` if present; else return defaults. */
6
+ export declare function readGitbookConfig(dir: string): GitbookConfig;
7
+ /**
8
+ * Does this directory look like a GitBook corpus? The importer's own rule (a marker at the
9
+ * top, or subfolders with a SUMMARY.md), over the folder's top two levels, through the shared
10
+ * path detector (so an mdBook's `src/SUMMARY.md` beside `book.toml` is not claimed).
11
+ */
12
+ export declare function detectGitbook(sourceDir: string): boolean;
13
+ /**
14
+ * A GitBook folder as a corpus, its docs roots walked first: the spaces and each one's
15
+ * `.gitbook.yaml` `root:` are read from the top two levels, the same rule the import applies.
16
+ */
17
+ export declare function readGitbookFolder(sourceDir: string, space?: string): FolderCorpus;
18
+ /** `loadGitbookCorpus` over a folder. */
19
+ export declare function loadGitbook(sourceDir: string, opts?: GitbookLoadOptions): GitbookLoadResult;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Node only: a GitBook folder as a corpus (`@dogsbay/corpus/folder`, its docs roots walked
3
+ * first), and the CLI's on-disk detector. Everything else in the loader reads the corpus, so
4
+ * the CLI and the browser import run the same code.
5
+ */
6
+ import { existsSync, readFileSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { detectGitbook as detectGitbookPaths, gitbookSpaces, joinPath, normalizePath } from "@dogsbay/detect";
9
+ import { readFolder } from "@dogsbay/corpus/folder";
10
+ import { parseGitbookConfig } from "./config.js";
11
+ import { loadGitbookCorpus } from "./load.js";
12
+ export { readFolder };
13
+ /** Read `.gitbook.yaml` from `dir` if present; else return defaults. */
14
+ export function readGitbookConfig(dir) {
15
+ const p = join(dir, ".gitbook.yaml");
16
+ return parseGitbookConfig(existsSync(p) ? readFileSync(p, "utf-8") : undefined);
17
+ }
18
+ /**
19
+ * Does this directory look like a GitBook corpus? The importer's own rule (a marker at the
20
+ * top, or subfolders with a SUMMARY.md), over the folder's top two levels, through the shared
21
+ * path detector (so an mdBook's `src/SUMMARY.md` beside `book.toml` is not claimed).
22
+ */
23
+ export function detectGitbook(sourceDir) {
24
+ try {
25
+ if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory())
26
+ return false;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ const { paths } = readFolder(sourceDir, 1);
32
+ return detectGitbookPaths(paths) !== undefined && gitbookSpaces(paths).length > 0;
33
+ }
34
+ /**
35
+ * A GitBook folder as a corpus, its docs roots walked first: the spaces and each one's
36
+ * `.gitbook.yaml` `root:` are read from the top two levels, the same rule the import applies.
37
+ */
38
+ export function readGitbookFolder(sourceDir, space) {
39
+ const top = readFolder(sourceDir, 1);
40
+ const spaces = space ? [space] : gitbookSpaces(top.paths);
41
+ const roots = [];
42
+ for (const s of spaces.length ? spaces : ["."]) {
43
+ const spaceDir = normalizePath(s);
44
+ if (spaceDir === undefined)
45
+ continue;
46
+ const { root } = parseGitbookConfig(top.text(joinPath(spaceDir, ".gitbook.yaml")));
47
+ const docsDir = root && root !== "./" ? normalizePath(joinPath(spaceDir, root)) : spaceDir;
48
+ if (docsDir)
49
+ roots.push(docsDir);
50
+ }
51
+ return readFolder(sourceDir, Infinity, roots);
52
+ }
53
+ /** `loadGitbookCorpus` over a folder. */
54
+ export function loadGitbook(sourceDir, opts = {}) {
55
+ return loadGitbookCorpus(readGitbookFolder(sourceDir, opts.space), opts);
56
+ }
@@ -1,13 +1,30 @@
1
+ /**
2
+ * GitBook corpus loader, over a {@link Corpus} (no Node): find the spaces, read each
3
+ * `.gitbook.yaml`, parse `SUMMARY.md` for nav, list every page (including orphans not in the
4
+ * summary), and surface redirects + include fragments. The CLI runs it over a folder
5
+ * (`folder.ts`), the playground over a map of files.
6
+ *
7
+ * Multi-space corpora (e.g. snyk/user-docs holds six spaces, each a dir with its own
8
+ * SUMMARY.md) are detected by `gitbookSpaces` (@dogsbay/detect, the rule detection uses too):
9
+ * when the corpus root has no marker, every direct subfolder with a SUMMARY.md is a space. The
10
+ * default import concatenates all spaces (each summary's nav becomes a top-level section);
11
+ * `space` selects one.
12
+ *
13
+ * Paths are posix and relative to the corpus root. Pages come out in path order, so the same
14
+ * corpus always gives the same result (a folder's listing order is the file system's).
15
+ */
1
16
  import type { NavItem } from "@dogsbay/types";
17
+ import type { Corpus } from "../corpus.js";
2
18
  export interface GitbookPageSource {
3
- filePath: string;
19
+ /** The page's file, relative to the corpus root. */
20
+ path: string;
4
21
  slug: string;
5
22
  /** True for `.gitbook/includes/*` — mirrored as a fragment, not a page. */
6
23
  fragment?: boolean;
7
24
  }
8
25
  /** A resolved docs root to mirror assets from, and its slug prefix. */
9
26
  export interface GitbookAssetRoot {
10
- /** Absolute docs directory (space dir + `.gitbook.yaml` `root:`). */
27
+ /** Docs folder (space folder + `.gitbook.yaml` `root:`), relative to the corpus root. */
11
28
  dir: string;
12
29
  /** Slug prefix for pages/assets under this root ("" or "<space>/"). */
13
30
  prefix: string;
@@ -16,17 +33,16 @@ export interface GitbookLoadResult {
16
33
  pages: GitbookPageSource[];
17
34
  nav: NavItem[];
18
35
  redirects: Record<string, string>;
19
- /** Space directories detected (relative to source); ≥2 ⇒ multi-space. */
36
+ /** Space directories detected (relative to the corpus root); ≥2 ⇒ multi-space. */
20
37
  spaces: string[];
21
38
  /** Docs roots to mirror non-`.md` assets from, with their slug prefix. */
22
39
  assetRoots: GitbookAssetRoot[];
23
40
  }
24
41
  export interface GitbookLoadOptions {
25
- /** Restrict to a single space directory (relative to source root). */
42
+ /** Restrict to a single space directory (relative to the corpus root). */
26
43
  space?: string;
27
44
  /** URL prefix applied to nav hrefs. */
28
45
  hrefPrefix?: string;
29
46
  }
30
- /** Does this directory look like a GitBook corpus? */
31
- export declare function detectGitbook(sourceDir: string): boolean;
32
- export declare function loadGitbook(sourceDir: string, opts?: GitbookLoadOptions): GitbookLoadResult;
47
+ export declare function loadGitbookCorpus(corpus: Corpus, opts?: GitbookLoadOptions): GitbookLoadResult;
48
+ export declare function prettify(s: string): string;
@@ -1,163 +1,35 @@
1
- /**
2
- * GitBook corpus loader — discover the docs root, read `.gitbook.yaml`,
3
- * parse `SUMMARY.md` for nav, enumerate every page (including orphans not
4
- * in the summary), and surface redirects + include fragments.
5
- *
6
- * Multi-space corpora (e.g. snyk/user-docs holds six spaces, each a dir
7
- * with its own SUMMARY.md) are detected: when the caller doesn't point at
8
- * a single space, every dir containing a SUMMARY.md is a space. The
9
- * default import concatenates all spaces (each summary's nav becomes a
10
- * top-level section); `--space <dir>` selects one.
11
- */
12
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
13
- import { basename, join, relative, sep } from "node:path";
14
- import { readGitbookConfig } from "./config.js";
1
+ import { gitbookSpaces, gitbookVisible, joinPath, normalizePath, within } from "@dogsbay/detect";
2
+ import { parseGitbookConfig } from "./config.js";
15
3
  import { parseSummary, pathToSlug } from "./summary.js";
16
- /** Does this directory look like a GitBook corpus? */
17
- export function detectGitbook(sourceDir) {
18
- if (!existsSync(sourceDir))
19
- return false;
20
- try {
21
- if (!statSync(sourceDir).isDirectory())
22
- return false;
23
- }
24
- catch {
25
- return false;
26
- }
27
- // A .gitbook.yaml anywhere near the top, or a SUMMARY.md, is the signal.
28
- if (existsSync(join(sourceDir, ".gitbook.yaml")))
29
- return true;
30
- if (existsSync(join(sourceDir, "SUMMARY.md")))
31
- return true;
32
- // Or a single-level space dir carrying either marker.
33
- return findSpaces(sourceDir).length > 0;
34
- }
35
4
  /**
36
- * Directories (relative) that contain a SUMMARY.md — each is a space.
37
- *
38
- * If the ROOT itself has a SUMMARY.md, the corpus is single-space and we
39
- * return just `["."]` — we do NOT also scan subdirectories, because a
40
- * nested `SUMMARY.md` (e.g. a stray one under `api/`) would otherwise be
41
- * misread as a second space, duplicating that subtree's pages and adding
42
- * a phantom top-level nav section (review #8). Multi-space is the
43
- * monorepo shape where the root has NO SUMMARY.md but ≥1 subdir does.
5
+ * The pages under one docs folder: every visible `.md` but SUMMARY and GLOSSARY, and only what
6
+ * stays in that folder once links resolve (a link from the docs root elsewhere in the repo
7
+ * does not bring pages in).
44
8
  */
45
- function findSpaces(sourceDir) {
46
- if (existsSync(join(sourceDir, "SUMMARY.md")) || existsSync(join(sourceDir, ".gitbook.yaml"))) {
47
- return ["."];
48
- }
9
+ function pagesUnder(corpus, paths, docsDir, landing) {
49
10
  const out = [];
50
- const seen = new Set();
51
- const consider = (dir) => {
52
- if (existsSync(join(dir, "SUMMARY.md"))) {
53
- const rel = relative(sourceDir, dir) || ".";
54
- if (!seen.has(rel)) {
55
- seen.add(rel);
56
- out.push(rel);
57
- }
58
- }
59
- };
60
- // One level down (the snyk monorepo shape: docs/, platform-administration/…).
61
- let entries = [];
62
- try {
63
- entries = readdirSync(sourceDir);
64
- }
65
- catch {
66
- return out;
67
- }
68
- for (const name of entries) {
69
- if (name.startsWith(".") || name === "node_modules")
11
+ for (const path of paths) {
12
+ if (!within(path, docsDir) || path === docsDir)
70
13
  continue;
71
- const full = join(sourceDir, name);
72
- try {
73
- if (statSync(full).isDirectory())
74
- consider(full);
75
- }
76
- catch {
77
- /* ignore */
78
- }
79
- }
80
- return out;
81
- }
82
- /** Walk `spaceDir` for every `.md` file; classify includes as fragments. */
83
- /** Is `p` (once links resolve) `dir` or inside it? False when either does not exist. */
84
- function insideDir(p, dir) {
85
- try {
86
- const real = realpathSync(p);
87
- const root = realpathSync(dir);
88
- return real === root || real.startsWith(root + sep);
89
- }
90
- catch {
91
- return !existsSync(p);
92
- }
93
- }
94
- function walkPages(spaceDir, sourceDir) {
95
- const out = [];
96
- // Inside the docs root once links resolve, and each directory once: a
97
- // symlink out of the repo read pages from elsewhere, and two links to `.`
98
- // made the walk branch twice per level until ELOOP (about 2^40 visits).
99
- let realRoot;
100
- try {
101
- realRoot = realpathSync(spaceDir);
102
- }
103
- catch {
104
- return out;
14
+ if (corpus.contains && !corpus.contains(path, docsDir))
15
+ continue;
16
+ const rel = docsDir ? path.slice(docsDir.length + 1) : path;
17
+ if (!gitbookVisible(rel))
18
+ continue;
19
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
20
+ if (!name.toLowerCase().endsWith(".md"))
21
+ continue;
22
+ if (name === "SUMMARY.md" || name === "GLOSSARY.md")
23
+ continue;
24
+ out.push({ path, slug: pathToSlug(rel, landing), ...(rel.includes(".gitbook/includes/") ? { fragment: true } : {}) });
105
25
  }
106
- const seen = new Set([realRoot]);
107
- const walk = (dir) => {
108
- let entries;
109
- try {
110
- entries = readdirSync(dir);
111
- }
112
- catch {
113
- return;
114
- }
115
- for (const name of entries) {
116
- if (name === "node_modules")
117
- continue;
118
- const full = join(dir, name);
119
- let st;
120
- let real;
121
- try {
122
- real = realpathSync(full);
123
- if (real !== realRoot && !real.startsWith(realRoot + sep))
124
- continue;
125
- st = statSync(real);
126
- }
127
- catch {
128
- continue;
129
- }
130
- if (st.isDirectory()) {
131
- // Hidden dirs are skipped EXCEPT `.gitbook` (holds includes).
132
- if (name.startsWith(".") && name !== ".gitbook")
133
- continue;
134
- if (seen.has(real))
135
- continue;
136
- seen.add(real);
137
- walk(full);
138
- continue;
139
- }
140
- if (!name.toLowerCase().endsWith(".md"))
141
- continue;
142
- if (name === "SUMMARY.md" || name === "GLOSSARY.md")
143
- continue;
144
- const relFromSpace = relative(spaceDir, full).replace(/\\/g, "/");
145
- const isInclude = relFromSpace.includes(".gitbook/includes/");
146
- out.push({
147
- filePath: full,
148
- slug: pathToSlug(relFromSpace),
149
- ...(isInclude ? { fragment: true } : {}),
150
- });
151
- }
152
- };
153
- walk(spaceDir);
154
26
  return out;
155
27
  }
156
- export function loadGitbook(sourceDir, opts = {}) {
28
+ export function loadGitbookCorpus(corpus, opts = {}) {
157
29
  const hrefPrefix = opts.hrefPrefix ?? "";
158
- const allSpaces = findSpaces(sourceDir);
159
- // Which spaces to load: an explicit --space, else all detected (or the
160
- // root itself if none matched).
30
+ const paths = [...corpus.paths].sort();
31
+ const allSpaces = gitbookSpaces(paths);
32
+ // Which spaces to load: an explicit `space`, else all detected (or the root itself if none matched).
161
33
  let spaces = opts.space ? [opts.space] : allSpaces;
162
34
  if (spaces.length === 0)
163
35
  spaces = ["."];
@@ -167,33 +39,38 @@ export function loadGitbook(sourceDir, opts = {}) {
167
39
  const assetRoots = [];
168
40
  const multi = spaces.length > 1;
169
41
  for (const space of spaces) {
170
- const spaceDir = space === "." ? sourceDir : join(sourceDir, space);
171
- if (!existsSync(spaceDir))
42
+ const spaceDir = normalizePath(space);
43
+ if (spaceDir === undefined)
44
+ throw new Error(`space "${space}" is outside the source directory`);
45
+ if (spaceDir && !paths.some((p) => within(p, spaceDir)))
172
46
  continue;
173
- const config = readGitbookConfig(spaceDir);
174
- // `root:` relocates the docs dir within the space.
175
- const docsDir = config.root && config.root !== "./" ? join(spaceDir, config.root) : spaceDir;
176
- // `root:` comes from the repo being imported: it may not lead out of it.
177
- if (!insideDir(docsDir, sourceDir)) {
47
+ const config = parseGitbookConfig(corpus.text(joinPath(spaceDir, ".gitbook.yaml")));
48
+ // `root:` relocates the docs dir within the space. It comes from the repo being imported:
49
+ // it may not lead out of it.
50
+ const docsDir = config.root && config.root !== "./" ? normalizePath(joinPath(spaceDir, config.root)) : spaceDir;
51
+ if (docsDir === undefined) {
178
52
  throw new Error(`.gitbook.yaml in ${space === "." ? "the source" : space}: root "${config.root}" is outside the source directory`);
179
53
  }
54
+ // `structure.readme` names the home page when it is not README.md (`readme: intro.md`).
55
+ // It becomes `index`, like a README; one that is not in the docs root is ignored.
56
+ const readme = normalizePath(config.readme);
57
+ const landing = readme && !/^readme\.md$/i.test(readme) && paths.includes(joinPath(docsDir, readme)) ? readme : undefined;
180
58
  // Page prefix so multi-space slugs don't collide (space dir name).
181
- const slugPrefix = multi && space !== "." ? `${space}/` : "";
59
+ const slugPrefix = multi && spaceDir ? `${spaceDir}/` : "";
182
60
  assetRoots.push({ dir: docsDir, prefix: slugPrefix });
183
- for (const p of walkPages(docsDir, docsDir)) {
61
+ for (const p of pagesUnder(corpus, paths, docsDir, landing)) {
184
62
  pages.push({ ...p, slug: `${slugPrefix}${p.slug}`.replace(/\/+/g, "/") });
185
63
  }
186
64
  // Nav from SUMMARY.md (under docsDir per config.summary).
187
- const summaryPath = join(docsDir, config.summary);
65
+ const summaryPath = normalizePath(joinPath(docsDir, config.summary));
66
+ const summary = summaryPath === undefined ? undefined : corpus.text(summaryPath);
188
67
  const navPrefix = `${hrefPrefix}${slugPrefix ? "/" + slugPrefix.replace(/\/$/, "") : ""}`;
189
- if (existsSync(summaryPath)) {
190
- const parsed = parseSummary(readFileSync(summaryPath, "utf-8"), navPrefix);
191
- if (multi) {
68
+ if (summary !== undefined) {
69
+ const parsed = parseSummary(summary, navPrefix, landing);
70
+ if (multi)
192
71
  nav.push({ label: prettify(space), children: parsed.nav });
193
- }
194
- else {
72
+ else
195
73
  nav.push(...parsed.nav);
196
- }
197
74
  }
198
75
  else {
199
76
  // No SUMMARY.md — infer nav from folder structure (paths → slugs).
@@ -203,13 +80,14 @@ export function loadGitbook(sourceDir, opts = {}) {
203
80
  else
204
81
  nav.push(...inferred);
205
82
  }
206
- // Redirect keys (old URL) AND values (target .md) are relative to the
207
- // space, so BOTH get the space slug prefix in a multi-space import —
208
- // else a redirect in space `docs` would point at an unprefixed target
209
- // that doesn't exist under `docs/`.
83
+ // Redirect keys (old URL) AND values (target .md) are relative to the space, so BOTH get
84
+ // the space slug prefix in a multi-space import — else a redirect in space `docs` would
85
+ // point at an unprefixed target that doesn't exist under `docs/`.
210
86
  for (const [k, v] of Object.entries(config.redirects)) {
211
87
  const key = slugPrefix ? `${slugPrefix}${k}` : k;
212
- const val = slugPrefix ? `${slugPrefix}${v.replace(/^\.\//, "")}` : v;
88
+ // A redirect to the landing page goes to the space's home, as one to its README does
89
+ const target = landing && normalizePath(v) === landing ? "README.md" : v;
90
+ const val = slugPrefix ? `${slugPrefix}${target.replace(/^\.\//, "")}` : target;
213
91
  redirects[key] = val;
214
92
  }
215
93
  }
@@ -219,10 +97,10 @@ export function loadGitbook(sourceDir, opts = {}) {
219
97
  function inferNavFromPages(pages) {
220
98
  return pages
221
99
  .filter((p) => p.slug !== "index")
222
- .map((p) => ({ label: prettify(basename(p.slug)), href: `/${p.slug}` }))
100
+ .map((p) => ({ label: prettify(p.slug.slice(p.slug.lastIndexOf("/") + 1)), href: `/${p.slug}` }))
223
101
  .sort((a, b) => a.label.localeCompare(b.label));
224
102
  }
225
- function prettify(s) {
103
+ export function prettify(s) {
226
104
  return s
227
105
  .split(/[-_/]/)
228
106
  .filter(Boolean)
@@ -22,6 +22,10 @@ export interface ParsedSummary {
22
22
  /** Every local page path referenced by the summary (posix, .md kept). */
23
23
  referenced: string[];
24
24
  }
25
- /** Normalize a GitBook page path to a Dogsbay slug (README → index). */
26
- export declare function pathToSlug(relPath: string): string;
27
- export declare function parseSummary(content: string, hrefPrefix?: string): ParsedSummary;
25
+ /**
26
+ * Normalize a GitBook page path to a Dogsbay slug (README → index). `landing` is a space's
27
+ * `.gitbook.yaml` `structure.readme` when it is not README.md: that page is the home page, and
28
+ * a README.md beside it is an ordinary page, `readme`.
29
+ */
30
+ export declare function pathToSlug(relPath: string, landing?: string): string;
31
+ export declare function parseSummary(content: string, hrefPrefix?: string, landing?: string): ParsedSummary;
@@ -1,6 +1,16 @@
1
- /** Normalize a GitBook page path to a Dogsbay slug (README → index). */
2
- export function pathToSlug(relPath) {
1
+ /**
2
+ * Normalize a GitBook page path to a Dogsbay slug (README → index). `landing` is a space's
3
+ * `.gitbook.yaml` `structure.readme` when it is not README.md: that page is the home page, and
4
+ * a README.md beside it is an ordinary page, `readme`.
5
+ */
6
+ export function pathToSlug(relPath, landing) {
3
7
  let p = relPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\.md$/i, "");
8
+ if (landing !== undefined) {
9
+ if (p === landing.replace(/\.md$/i, ""))
10
+ return "index";
11
+ if (p === "README" || p === "readme")
12
+ return "readme";
13
+ }
4
14
  if (p === "README" || p === "readme")
5
15
  return "index";
6
16
  p = p.replace(/\/README$/i, "/index");
@@ -16,7 +26,7 @@ const LINK_RE = /^\[([^\]]*)\]\(\s*(?:<([^>]*)>|([^)\s]+))[^)]*\)/;
16
26
  const EXTERNAL_RE = /^(https?:\/\/|mailto:|tel:)/i;
17
27
  // Strip a trailing `<a href id>` anchor GitBook adds to group headings.
18
28
  const ANCHOR_TAIL_RE = /\s*<a\b[^>]*><\/a>\s*$/;
19
- export function parseSummary(content, hrefPrefix = "") {
29
+ export function parseSummary(content, hrefPrefix = "", landing) {
20
30
  const referenced = [];
21
31
  const top = [];
22
32
  // Current group container the following items attach to (null = top level).
@@ -43,7 +53,7 @@ export function parseSummary(content, hrefPrefix = "") {
43
53
  if (!it)
44
54
  continue;
45
55
  const indent = it[1].replace(/\t/g, " ").length;
46
- const item = parseItem(it[2], hrefPrefix, referenced);
56
+ const item = parseItem(it[2], hrefPrefix, referenced, landing);
47
57
  if (!item)
48
58
  continue;
49
59
  // Attach by indentation. Pop deeper-or-equal entries; the remaining
@@ -62,7 +72,7 @@ export function parseSummary(content, hrefPrefix = "") {
62
72
  strip(top);
63
73
  return { nav: top, referenced };
64
74
  }
65
- function parseItem(text, hrefPrefix, referenced) {
75
+ function parseItem(text, hrefPrefix, referenced, landing) {
66
76
  const m = text.match(LINK_RE);
67
77
  if (!m) {
68
78
  // A plain-text item (no link) — a group-ish header without a page.
@@ -79,7 +89,7 @@ function parseItem(text, hrefPrefix, referenced) {
79
89
  const frag = hashIdx === -1 ? "" : target.slice(hashIdx);
80
90
  if (filePart)
81
91
  referenced.push(filePart.replace(/^\.\//, ""));
82
- const slug = pathToSlug(filePart);
92
+ const slug = pathToSlug(filePart, landing);
83
93
  const href = `${hrefPrefix}/${slug}`.replace(/\/+/g, "/");
84
94
  return { label, href: `${href}${frag}` };
85
95
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `@dogsbay/format-gitbook/migrate` — the browser entry. A GitBook corpus as a map of files →
3
+ * a Dogsbay project as a map of files: the same import and project writer `dogsbay
4
+ * migrate-gitbook` runs, without the Astro scaffold. No Node, nothing from the corpus is run,
5
+ * no console, clock or network. Find the corpus with `@dogsbay/detect` first.
6
+ */
7
+ import type { ImportLedgerEntry } from "@dogsbay/types";
8
+ import { type CorpusFiles } from "./corpus.js";
9
+ export type { CorpusFiles } from "./corpus.js";
10
+ export interface MigrateInput {
11
+ /** The corpus: paths relative to its root (a detection's `root`), text or bytes. */
12
+ files: CorpusFiles;
13
+ /** One space of a multi-space repo (a detection's `choices.spaces`); all when omitted. */
14
+ choose?: {
15
+ space?: string;
16
+ };
17
+ siteName?: string;
18
+ siteUrl?: string;
19
+ /** Where the corpus came from, for MIGRATION.md (e.g. the repo URL). */
20
+ sourceLabel?: string;
21
+ }
22
+ export interface MigrateResult {
23
+ /** The Dogsbay project: dogsbay.config.yml, content/** (pages, nav.yml, assets), MIGRATION.md. */
24
+ files: Map<string, string | Uint8Array>;
25
+ ledger: ImportLedgerEntry[];
26
+ /** Old URL → served URL, both root-absolute; the host decides how to serve them. */
27
+ redirects: {
28
+ from: string;
29
+ to: string;
30
+ }[];
31
+ }
32
+ export declare function migrate(input: MigrateInput): Promise<MigrateResult>;
@@ -0,0 +1,19 @@
1
+ import { corpusFromMap } from "./corpus.js";
2
+ import { importGitbookCorpus } from "./import.js";
3
+ import { gitbookProject } from "./project.js";
4
+ export async function migrate(input) {
5
+ const corpus = corpusFromMap(input.files);
6
+ const imported = importGitbookCorpus(corpus, { space: input.choose?.space });
7
+ const project = gitbookProject(imported, corpus, {
8
+ siteName: input.siteName?.trim() || "Docs",
9
+ siteUrl: input.siteUrl,
10
+ sourceLabel: input.sourceLabel ?? "GitBook corpus",
11
+ });
12
+ const files = new Map(project.files);
13
+ for (const { from, to } of project.assets) {
14
+ const bytes = input.files.get(from);
15
+ if (bytes !== undefined)
16
+ files.set(to, bytes);
17
+ }
18
+ return { files, ledger: project.ledger, redirects: project.redirects };
19
+ }
package/dist/parse.d.ts CHANGED
@@ -7,6 +7,12 @@ export interface ParseGitbookResult {
7
7
  markdown: string;
8
8
  /** Translated body only (no frontmatter) — for callers writing their own. */
9
9
  body: string;
10
+ /** Ledger lines count from the body: the source line the body starts on (1 with no front matter). */
11
+ bodyLine: number;
10
12
  ledger: TranslateLedgerEntry[];
11
13
  }
12
- export declare function parseGitbook(src: string): ParseGitbookResult;
14
+ export interface ParseGitbookOptions {
15
+ /** Applied to the translated body before it is parsed (the import's renamed page links). */
16
+ links?: (body: string) => string;
17
+ }
18
+ export declare function parseGitbook(src: string, opts?: ParseGitbookOptions): ParseGitbookResult;
package/dist/parse.js CHANGED
@@ -15,18 +15,21 @@
15
15
  * writes to disk — canonical, editable Dogsbay-MD. `tree` feeds the
16
16
  * runtime path (format-astro emit, headings, title).
17
17
  */
18
- import { dogsbayMdToTree } from "@dogsbay/format-dogsbay-md";
18
+ import { dogsbayMdToTree } from "@dogsbay/format-dogsbay-md/browser";
19
19
  import { gitbookToDogsbayMd } from "./translate.js";
20
20
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
21
- export function parseGitbook(src) {
21
+ export function parseGitbook(src, opts = {}) {
22
22
  const normalized = src.replace(/\r\n?/g, "\n");
23
23
  const fmMatch = normalized.match(FRONTMATTER_RE);
24
24
  const fmBlock = fmMatch ? fmMatch[0] : "";
25
25
  const body = fmMatch ? normalized.slice(fmMatch[0].length) : normalized;
26
- const { markdown: translatedBody, ledger } = gitbookToDogsbayMd(body);
26
+ const translated = gitbookToDogsbayMd(body);
27
+ const { ledger } = translated;
28
+ const translatedBody = opts.links ? opts.links(translated.markdown) : translated.markdown;
27
29
  const markdown = fmBlock ? `${fmBlock.replace(/\n?$/, "\n")}\n${translatedBody}` : translatedBody;
28
30
  // dogsbayMdToTree extracts frontmatter itself (gray-matter) and parses
29
31
  // the body into TreeNode[]. Feed it the recombined markdown.
30
32
  const { tree, frontmatter } = dogsbayMdToTree(markdown);
31
- return { tree, frontmatter: frontmatter ?? {}, markdown, body: translatedBody, ledger };
33
+ const bodyLine = fmBlock ? fmBlock.split("\n").length - (fmBlock.endsWith("\n") ? 1 : 0) + 1 : 1;
34
+ return { tree, frontmatter: frontmatter ?? {}, markdown, body: translatedBody, bodyLine, ledger };
32
35
  }
@@ -0,0 +1,34 @@
1
+ import type { ImportLedgerEntry } from "@dogsbay/types";
2
+ import type { Corpus } from "./corpus.js";
3
+ import type { GitbookImportResult } from "./import.js";
4
+ export interface GitbookProjectOptions {
5
+ siteName: string;
6
+ siteUrl?: string;
7
+ /** Where the corpus came from, for MIGRATION.md (a folder, a repo URL). */
8
+ sourceLabel: string;
9
+ /** Where the host writes the redirects, for MIGRATION.md (the CLI: `astro/public/_redirects`). */
10
+ redirectsFile?: string;
11
+ }
12
+ export interface GitbookProject {
13
+ /** The project's text files, by path relative to the project root. */
14
+ files: Map<string, string>;
15
+ /** Files to copy unchanged: corpus path → project path. */
16
+ assets: {
17
+ from: string;
18
+ to: string;
19
+ }[];
20
+ /** Old URL → served URL, both root-absolute (`/old/path` → `/guide`). */
21
+ redirects: {
22
+ from: string;
23
+ to: string;
24
+ }[];
25
+ /** The import's ledger plus the writer's own notes (slug collisions). */
26
+ ledger: ImportLedgerEntry[];
27
+ stats: {
28
+ pages: number;
29
+ fragments: number;
30
+ collisions: number;
31
+ spaces: string[];
32
+ };
33
+ }
34
+ export declare function gitbookProject(result: GitbookImportResult, corpus: Corpus, opts: GitbookProjectOptions): GitbookProject;
@@ -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.130",
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.132",
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,16 @@
20
24
  "README.md"
21
25
  ],
22
26
  "dependencies": {
23
- "yaml": "^2.9.0",
24
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.130",
25
- "@dogsbay/types": "0.2.0-beta.130"
27
+ "js-yaml": "^5.2.1",
28
+ "@dogsbay/corpus": "0.2.0-beta.132",
29
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.132",
30
+ "@dogsbay/types": "0.2.0-beta.132",
31
+ "@dogsbay/detect": "0.2.0-beta.132"
26
32
  },
27
33
  "devDependencies": {
34
+ "@types/js-yaml": "^4.0.9",
28
35
  "@types/node": "^25.9.5",
36
+ "esbuild": "^0.25.0",
29
37
  "typescript": "^5.9.3",
30
38
  "vitest": "^4.1.10"
31
39
  },