create-asciidoc-multi-book-workspace 0.1.6 → 0.1.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8 - 2026-06-14
4
+
5
+ ### Fixed
6
+
7
+ - Stop preview builds from emitting the Opal duplicate-load warning by loading Asciidoctor through the ESM `@asciidoctor/core` entry instead of mixing it with the CommonJS `asciidoctor` wrapper.
8
+
9
+ ### Changed
10
+
11
+ - Depend on `@asciidoctor/core` directly in generated workspaces and align `asciidoctor-kroki` with core 3.0.4 in the lockfile.
12
+ - Clarify the structured writing sample guidance for precisely referencing a source region with ordinary `xref` links.
13
+
14
+ ## 0.1.7 - 2026-06-13
15
+
16
+ ### Added
17
+
18
+ - Add a `复制本书为纯文本` control to each generated book page so readers can copy the book source bundle for saving, note-taking, or model analysis.
19
+ - Add a browser fallback that opens the book source bundle as a UTF-8 plain-text page when clipboard access is unavailable.
20
+
21
+ ### Changed
22
+
23
+ - Keep the generated book page controls self-contained by embedding the source bundle in the HTML instead of adding another published source file.
24
+
3
25
  ## 0.1.6 - 2026-06-09
4
26
 
5
27
  ### Added
@@ -80,5 +102,5 @@
80
102
  - Publish the project as `create-asciidoc-multi-book-workspace`, an npm initializer for complete AsciiDoc multi-book workspaces.
81
103
  - Generate user-owned workspaces with seven sample books, `catalog.adoc`, shared assets, explicit AsciiDoc dependencies, and an editable local `tools/adoc-books.mjs` runtime.
82
104
  - Keep maintainer-only files out of generated workspaces and the npm package, including tests, CI, TypeScript source, and `DESIGN.adoc`.
83
- - Build and check generated workspaces through a single Node runtime using Asciidoctor.js, reducer support, local resource checks, Kroki diagram URL rendering, and optional fetched diagram output.
105
+ - Build and check generated workspaces through a single Node runtime using Asciidoctor.js, local resource checks, Kroki diagram URL rendering, and optional fetched diagram output.
84
106
  - Validate that each default sample book can be deleted after removing its catalog entries, while user-authored real broken xrefs still fail checks.
package/README.md CHANGED
@@ -63,7 +63,7 @@ It does not include this repository's `test/`, `.github/`, `src/`, `dist/`, `DES
63
63
  The generated `package.json` directly lists the AsciiDoc toolchain:
64
64
 
65
65
  - `asciidoctor`
66
- - `@asciidoctor/reducer`
66
+ - `asciidoc-abundant-tree`
67
67
  - `asciidoctor-kroki`
68
68
 
69
69
  The generated scripts are:
@@ -3,6 +3,8 @@ import { createRequire } from "node:module";
3
3
  import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import asciidoctorFactory from "@asciidoctor/core";
7
+ import { parseAbundantTree } from "asciidoc-abundant-tree";
6
8
  const require = createRequire(import.meta.url);
7
9
  const DIAGRAM_BLOCK_PATTERN = /^\[(?:actdiag|blockdiag|bpmn|bytefield|c4plantuml|d2|dbml|ditaa|erd|excalidraw|graphviz|mermaid|nomnoml|nwdiag|packetdiag|pikchr|plantuml|rackdiag|seqdiag|svgbob|symbolator|umlet|vega|vegalite|wavedrom|structurizr|diagramsnet|wireviz)(?:,|\])/m;
8
10
  const CATALOG_BOOK_XREF_PATTERN = /xref:books\/([^/\]]+)\/book\.adoc(?:#[^\[]+)?\[/g;
@@ -11,6 +13,7 @@ const ANCHOR_PATTERN = /^\[#([A-Za-z0-9_-]+)(?:[.,][^\]]*)?\]$/gm;
11
13
  const LOCAL_TARGET_PATTERN = /\b(?:href|src)="([^"]+)"/g;
12
14
  const SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
13
15
  const HOME_MARKER = "data-multi-book-home";
16
+ const CONTROLS_MARKER = "data-multi-book-controls";
14
17
  const FETCH_DIAGRAMS_ENV = "ADOC_BOOKS_FETCH_DIAGRAMS";
15
18
  const CONFIG_FILE = "adoc-books.config.mjs";
16
19
  const DEFAULT_CONFIG = {
@@ -106,10 +109,13 @@ async function readIfExists(filePath) {
106
109
  return await existsFile(filePath) ? readFile(filePath, "utf8") : "";
107
110
  }
108
111
  async function combinedBookSource(bookDir) {
109
- const files = await collectFiles(bookDir, (filePath) => filePath.endsWith(".adoc"));
112
+ const files = await collectFiles(bookDir, (filePath) => filePath.endsWith(".adoc") || filePath.endsWith(".mjs"));
110
113
  const sources = [];
111
- for (const file of files)
112
- sources.push(await readFile(file, "utf8"));
114
+ for (const file of files) {
115
+ const relativePath = path.relative(path.dirname(bookDir), file).split(path.sep).join("/");
116
+ const source = await readFile(file, "utf8");
117
+ sources.push(`// file: ${relativePath}\n${source.endsWith("\n") ? source : `${source}\n`}`);
118
+ }
113
119
  return sources.join("\n");
114
120
  }
115
121
  async function workspaceUsesDiagrams(rootDir, books) {
@@ -119,8 +125,7 @@ async function workspaceUsesDiagrams(rootDir, books) {
119
125
  return sources.some((source) => DIAGRAM_BLOCK_PATTERN.test(source));
120
126
  }
121
127
  function createAsciidoctor(loadKroki) {
122
- const asciidoctor = require("asciidoctor")();
123
- require("@asciidoctor/reducer").register();
128
+ const asciidoctor = asciidoctorFactory();
124
129
  if (loadKroki) {
125
130
  try {
126
131
  require("asciidoctor-kroki").register(asciidoctor.Extensions);
@@ -136,25 +141,6 @@ function shouldFetchDiagrams() {
136
141
  const value = process.env[FETCH_DIAGRAMS_ENV];
137
142
  return value === "1" || value === "true";
138
143
  }
139
- function reduceAdocSource(asciidoctor, input) {
140
- const doc = asciidoctor.loadFile(input, { safe: "unsafe" });
141
- const source = doc.getSource();
142
- if (/^[ \t]*include::/m.test(source)) {
143
- throw new Error(`reducer left unresolved include directives in ${input}`);
144
- }
145
- return source;
146
- }
147
- async function buildReducedAdoc(rootDir, books, asciidoctor) {
148
- const adocDir = path.join(rootDir, "build", "adoc");
149
- await rm(adocDir, { force: true, recursive: true });
150
- await mkdir(path.join(adocDir, "books"), { recursive: true });
151
- const catalogSource = reduceAdocSource(asciidoctor, path.join(rootDir, "catalog.adoc"));
152
- await writeFile(path.join(adocDir, "catalog.adoc"), catalogSource, "utf8");
153
- for (const book of books) {
154
- const source = reduceAdocSource(asciidoctor, book.input);
155
- await writeFile(path.join(adocDir, "books", `${book.bookId}.adoc`), source, "utf8");
156
- }
157
- }
158
144
  async function pruneStaleBookHtmlDirs(rootDir, books) {
159
145
  const htmlBooksDir = path.join(rootDir, "build", "html", "books");
160
146
  if (!await existsDir(htmlBooksDir))
@@ -208,29 +194,613 @@ function escapeHtmlAttribute(value) {
208
194
  .replaceAll("<", "&lt;")
209
195
  .replaceAll(">", "&gt;");
210
196
  }
211
- function addHomeLinkToBookHtml(html, href, homeLink) {
212
- if (html.includes(HOME_MARKER))
197
+ function escapeJsonScript(value) {
198
+ return JSON.stringify(value)
199
+ .replaceAll("<", "\\u003c")
200
+ .replaceAll("\u2028", "\\u2028")
201
+ .replaceAll("\u2029", "\\u2029");
202
+ }
203
+ function escapeJsonValueForScript(value) {
204
+ return JSON.stringify(value)
205
+ .replaceAll("<", "\\u003c")
206
+ .replaceAll("\u2028", "\\u2028")
207
+ .replaceAll("\u2029", "\\u2029");
208
+ }
209
+ function sectionStyle(section) {
210
+ for (const metadata of section.metadata ?? []) {
211
+ const style = metadata.attributes?.style;
212
+ if (typeof style === "string")
213
+ return style;
214
+ }
215
+ return undefined;
216
+ }
217
+ function firstSectionId(section) {
218
+ const id = section.ids?.[0];
219
+ if (!id)
220
+ throw new Error(`section is missing an id: ${section.title ?? "(untitled)"}`);
221
+ return id;
222
+ }
223
+ function childSections(section) {
224
+ return (section.children ?? []).filter((child) => child.kind === "section");
225
+ }
226
+ function sourceSpan(section) {
227
+ const relativePath = section.source?.relativePath;
228
+ if (!relativePath)
229
+ return undefined;
230
+ return {
231
+ relativePath,
232
+ ...(typeof section.source?.span?.startLine === "number" ? { startLine: section.source.span.startLine } : {}),
233
+ ...(typeof section.source?.span?.endLine === "number" ? { endLine: section.source.span.endLine } : {})
234
+ };
235
+ }
236
+ function sectionAnchorTree(section) {
237
+ const anchors = [firstSectionId(section)];
238
+ for (const child of childSections(section))
239
+ anchors.push(...sectionAnchorTree(child));
240
+ return anchors;
241
+ }
242
+ function sectionToc(section, baseDepth = 0, topTitle) {
243
+ const toc = [{ title: topTitle ?? section.title ?? firstSectionId(section), anchor: firstSectionId(section), depth: baseDepth }];
244
+ for (const child of childSections(section)) {
245
+ toc.push(...sectionToc(child, baseDepth + 1));
246
+ }
247
+ return toc;
248
+ }
249
+ function chapterPage(section, parentPageId) {
250
+ return {
251
+ id: firstSectionId(section),
252
+ kind: "chapter",
253
+ title: section.title ?? firstSectionId(section),
254
+ anchors: sectionAnchorTree(section),
255
+ toc: sectionToc(section, 0, "概述"),
256
+ source: sourceSpan(section),
257
+ ...(parentPageId ? { parentPageId } : {})
258
+ };
259
+ }
260
+ function backmatterPage(section, kind) {
261
+ return {
262
+ id: firstSectionId(section),
263
+ kind,
264
+ title: section.title ?? firstSectionId(section),
265
+ anchors: sectionAnchorTree(section),
266
+ toc: sectionToc(section, 0, "概述"),
267
+ source: sourceSpan(section)
268
+ };
269
+ }
270
+ function assertNoToolErrors(bookId, diagnostics) {
271
+ for (const diagnostic of diagnostics) {
272
+ const entry = objectValue(diagnostic);
273
+ if (entry.level === "error") {
274
+ throw new Error(`${bookId}: ${stringValue(entry.code, "error")}: ${stringValue(entry.message, "")}`);
275
+ }
276
+ }
277
+ }
278
+ function sourceBundleFromDocument(document, fallbackPath, fallbackSource) {
279
+ const sourceFiles = Array.isArray(document.sourceFiles) ? document.sourceFiles : [];
280
+ const files = sourceFiles.filter((file) => typeof file.relativePath === "string" && typeof file.raw === "string");
281
+ if (files.length === 0)
282
+ return fallbackSource;
283
+ return files.map((file) => {
284
+ const body = file.raw?.endsWith("\n") ? file.raw : `${file.raw ?? ""}\n`;
285
+ return `// file: ${file.relativePath ?? fallbackPath}\n${body}`;
286
+ }).join("\n");
287
+ }
288
+ function fallbackReaderBookData(rootDir, book, fallbackSource, reason) {
289
+ console.warn(`${book.bookId}: reader page map fell back to cover-only mode: ${reason}`);
290
+ const entry = path.relative(rootDir, book.input).split(path.sep).join("/");
291
+ return {
292
+ pageMap: {
293
+ version: 1,
294
+ book: {
295
+ id: book.bookId,
296
+ title: book.bookId,
297
+ entry
298
+ },
299
+ pages: [{
300
+ id: "cover",
301
+ kind: "cover",
302
+ title: book.bookId,
303
+ anchors: [],
304
+ toc: []
305
+ }]
306
+ },
307
+ sourceBundle: fallbackSource
308
+ };
309
+ }
310
+ function buildReaderBookData(rootDir, book, fallbackSource) {
311
+ let document;
312
+ try {
313
+ document = parseAbundantTree({
314
+ sourcePath: book.input,
315
+ mode: "book-entry",
316
+ documentRoot: rootDir
317
+ });
318
+ }
319
+ catch (error) {
320
+ const message = error instanceof Error ? error.message : String(error);
321
+ return fallbackReaderBookData(rootDir, book, fallbackSource, message);
322
+ }
323
+ const diagnostics = Array.isArray(document.toolDiagnostics) ? document.toolDiagnostics : [];
324
+ assertNoToolErrors(book.bookId, diagnostics);
325
+ const title = objectValue(document.title).text;
326
+ const pages = [{
327
+ id: "cover",
328
+ kind: "cover",
329
+ title: typeof title === "string" ? title : book.bookId,
330
+ anchors: [],
331
+ toc: []
332
+ }];
333
+ const sections = (Array.isArray(document.children) ? document.children : []).filter((child) => {
334
+ return typeof child === "object" && child !== null && child.kind === "section";
335
+ });
336
+ const frontmatterStyles = new Set(["abstract", "colophon", "dedication", "preface", "acknowledgments"]);
337
+ const backmatterStyles = new Set(["appendix", "glossary", "bibliography", "index"]);
338
+ const frontmatter = sections.filter((section) => section.level === 1 && frontmatterStyles.has(sectionStyle(section) ?? ""));
339
+ if (frontmatter.length > 0) {
340
+ pages.push({
341
+ id: "frontmatter",
342
+ kind: "frontmatter",
343
+ title: "前置",
344
+ anchors: frontmatter.map(firstSectionId),
345
+ toc: frontmatter.map((section) => ({
346
+ title: section.title ?? firstSectionId(section),
347
+ anchor: firstSectionId(section),
348
+ depth: 0
349
+ }))
350
+ });
351
+ }
352
+ for (const section of sections) {
353
+ const style = sectionStyle(section);
354
+ if (frontmatterStyles.has(style ?? ""))
355
+ continue;
356
+ if (backmatterStyles.has(style)) {
357
+ pages.push(backmatterPage(section, style));
358
+ continue;
359
+ }
360
+ if (section.level === 0) {
361
+ const partId = `part-${firstSectionId(section)}`;
362
+ const children = childSections(section);
363
+ const childPages = children.map((child) => chapterPage(child, partId));
364
+ pages.push({
365
+ id: partId,
366
+ kind: "part",
367
+ title: section.title ?? firstSectionId(section),
368
+ anchors: [firstSectionId(section)],
369
+ toc: [{ title: "概述", anchor: firstSectionId(section), depth: 0 }],
370
+ source: sourceSpan(section),
371
+ childPageIds: childPages.map((page) => page.id)
372
+ });
373
+ pages.push(...childPages);
374
+ continue;
375
+ }
376
+ pages.push(chapterPage(section));
377
+ }
378
+ const pageMap = {
379
+ version: 1,
380
+ book: {
381
+ id: book.bookId,
382
+ title: pages[0].title,
383
+ entry: path.relative(rootDir, book.input).split(path.sep).join("/")
384
+ },
385
+ pages
386
+ };
387
+ return {
388
+ pageMap,
389
+ sourceBundle: sourceBundleFromDocument(document, pageMap.book.entry, fallbackSource)
390
+ };
391
+ }
392
+ function readerStyles() {
393
+ return `
394
+ .multi-book-reader-toggle {
395
+ display: grid;
396
+ gap: .35rem;
397
+ grid-template-columns: 1fr 1fr;
398
+ margin-top: .75rem;
399
+ }
400
+ .multi-book-reader-toggle button {
401
+ appearance: none;
402
+ background: #ffffff;
403
+ border: 1px solid #cbd5e1;
404
+ border-radius: 4px;
405
+ color: #1f2937;
406
+ cursor: pointer;
407
+ font-size: .82rem;
408
+ font-weight: 600;
409
+ padding: .42rem .5rem;
410
+ }
411
+ .multi-book-reader-toggle button[aria-pressed="true"] {
412
+ border-color: #0f766e;
413
+ color: #0f766e;
414
+ }
415
+ [data-multi-book-page-nav] {
416
+ border-top: 1px solid #e5e7eb;
417
+ margin-top: .75rem;
418
+ padding-top: .75rem;
419
+ }
420
+ [data-multi-book-page-nav][hidden] {
421
+ display: none;
422
+ }
423
+ [data-multi-book-page-nav] a {
424
+ border-left: 3px solid transparent;
425
+ color: #334155;
426
+ display: block;
427
+ font-size: .85rem;
428
+ line-height: 1.35;
429
+ padding: .3rem .35rem .3rem .55rem;
430
+ text-decoration: none;
431
+ }
432
+ [data-multi-book-page-nav] a[data-reader-depth="1"] {
433
+ padding-left: 1.45rem;
434
+ }
435
+ [data-multi-book-page-nav] a[aria-current="page"] {
436
+ border-left-color: #0f766e;
437
+ color: #0f766e;
438
+ font-weight: 700;
439
+ }
440
+ .multi-book-reader-cover {
441
+ border-bottom: 1px solid #e5e7eb;
442
+ margin-bottom: 1.5rem;
443
+ padding-bottom: 1rem;
444
+ }
445
+ .multi-book-reader-cover h2 {
446
+ margin-top: 0;
447
+ }
448
+ [data-multi-book-page-toc] {
449
+ border-left: 1px solid #e5e7eb;
450
+ color: #334155;
451
+ font-size: .85rem;
452
+ padding-left: 1rem;
453
+ }
454
+ [data-multi-book-page-toc][hidden] {
455
+ display: none;
456
+ }
457
+ [data-multi-book-page-toc] strong {
458
+ color: #1f2937;
459
+ display: block;
460
+ font-size: .78rem;
461
+ margin-bottom: .35rem;
462
+ }
463
+ [data-multi-book-page-toc] a {
464
+ color: #334155;
465
+ display: block;
466
+ margin: .35rem 0;
467
+ text-decoration: none;
468
+ }
469
+ [data-multi-book-pagination] {
470
+ border-top: 1px solid #e5e7eb;
471
+ display: flex;
472
+ gap: 1rem;
473
+ justify-content: space-between;
474
+ margin-bottom: 2rem;
475
+ margin-top: 2.5rem;
476
+ padding-top: 1rem;
477
+ }
478
+ [data-multi-book-pagination][hidden] {
479
+ display: none;
480
+ }
481
+ [data-multi-book-pagination] a {
482
+ border: 1px solid #cbd5e1;
483
+ border-radius: 4px;
484
+ color: #1f2937;
485
+ flex: 1 1 0;
486
+ padding: .65rem;
487
+ text-decoration: none;
488
+ }
489
+ [data-multi-book-pagination] span {
490
+ color: #64748b;
491
+ display: block;
492
+ font-size: .78rem;
493
+ }
494
+ @media (min-width: 1024px) {
495
+ body.toc2.toc-left.multi-book-reader-paged #content {
496
+ --multi-book-reader-content-gutter: max(0px, calc((100vw - 20em - 1000px) / 2));
497
+ box-sizing: border-box;
498
+ margin-left: var(--multi-book-reader-content-gutter);
499
+ margin-right: 16rem;
500
+ max-width: min(800px, calc(100vw - 20em - 20rem - var(--multi-book-reader-content-gutter)));
501
+ padding-left: 15px;
502
+ }
503
+ body.toc2.toc-left.multi-book-reader-paged [data-multi-book-pagination] {
504
+ --multi-book-reader-content-gutter: max(0px, calc((100vw - 20em - 1000px) / 2));
505
+ margin-left: var(--multi-book-reader-content-gutter);
506
+ max-width: min(800px, calc(100vw - 20em - 20rem - var(--multi-book-reader-content-gutter)));
507
+ width: calc(100vw - 20em - 20rem - var(--multi-book-reader-content-gutter));
508
+ }
509
+ [data-multi-book-page-toc] {
510
+ position: fixed;
511
+ right: 2rem;
512
+ top: 7rem;
513
+ width: 12rem;
514
+ }
515
+ }
516
+ @media (min-width: 1024px) and (max-width: 1359px) {
517
+ [data-multi-book-page-toc] {
518
+ right: 1rem;
519
+ width: 8rem;
520
+ }
521
+ }
522
+ body.multi-book-reader-paged #footer {
523
+ display: none;
524
+ }
525
+ @media (max-width: 767px) {
526
+ #toc.toc2 {
527
+ position: static;
528
+ width: auto;
529
+ }
530
+ [data-multi-book-pagination] {
531
+ flex-direction: column;
532
+ }
533
+ pre, table {
534
+ max-width: 100%;
535
+ overflow-x: auto;
536
+ }
537
+ }
538
+ `;
539
+ }
540
+ function readerScript() {
541
+ return `
542
+ (function () {
543
+ function initReader() {
544
+ var mapElement = document.getElementById("multi-book-page-map");
545
+ if (!mapElement) return;
546
+ var pageMap;
547
+ try {
548
+ pageMap = JSON.parse(mapElement.textContent || "{}");
549
+ } catch (error) {
550
+ return;
551
+ }
552
+ if (!pageMap || !Array.isArray(pageMap.pages)) return;
553
+
554
+ var content = document.getElementById("content");
555
+ var toc = document.getElementById("toc");
556
+ if (!content || !toc) return;
557
+
558
+ var pages = pageMap.pages;
559
+ var pagesById = new Map();
560
+ var anchorToPageId = new Map();
561
+ pages.forEach(function (page) {
562
+ pagesById.set(page.id, page);
563
+ (page.anchors || []).forEach(function (anchor) { anchorToPageId.set(anchor, page.id); });
564
+ });
565
+
566
+ var controls = document.querySelector("[data-multi-book-controls]");
567
+ var toggle = document.createElement("div");
568
+ toggle.className = "multi-book-reader-toggle";
569
+ toggle.setAttribute("data-multi-book-view-toggle", "");
570
+ toggle.innerHTML = '<button type="button" data-reader-view="continuous">连续</button><button type="button" data-reader-view="paged">页面</button>';
571
+ if (controls) controls.appendChild(toggle);
572
+
573
+ var nav = document.createElement("nav");
574
+ nav.setAttribute("data-multi-book-page-nav", "");
575
+ nav.setAttribute("aria-label", "页面");
576
+ if (controls && controls.nextSibling) toc.insertBefore(nav, controls.nextSibling);
577
+ else toc.appendChild(nav);
578
+
579
+ var pageToc = document.createElement("aside");
580
+ pageToc.setAttribute("data-multi-book-page-toc", "");
581
+ content.parentNode.insertBefore(pageToc, content.nextSibling);
582
+
583
+ var pagination = document.createElement("nav");
584
+ pagination.setAttribute("data-multi-book-pagination", "");
585
+ pagination.setAttribute("aria-label", "分页");
586
+ content.parentNode.insertBefore(pagination, pageToc.nextSibling);
587
+
588
+ var cover = document.createElement("section");
589
+ cover.id = "multi-book-reader-cover";
590
+ cover.className = "multi-book-reader-cover";
591
+ var sourceTitle = document.querySelector("#header > h1");
592
+ var details = document.querySelector("#header > .details");
593
+ cover.innerHTML = "<h2></h2>";
594
+ cover.querySelector("h2").textContent = sourceTitle ? sourceTitle.textContent : pageMap.book.title;
595
+ if (details) cover.appendChild(details.cloneNode(true));
596
+ content.insertBefore(cover, content.firstChild);
597
+
598
+ var originalTocNodes = Array.from(toc.children).filter(function (node) {
599
+ return node !== controls && node !== nav;
600
+ });
601
+ var originalNodes = Array.from(content.children).filter(function (node) { return node !== cover; });
602
+ var currentView = "continuous";
603
+ var currentPageId = "cover";
604
+
605
+ function query() {
606
+ return new URLSearchParams(window.location.search);
607
+ }
608
+
609
+ function writeQuery(values) {
610
+ var url = new URL(window.location.href);
611
+ Object.keys(values).forEach(function (key) {
612
+ if (values[key] == null) url.searchParams.delete(key);
613
+ else url.searchParams.set(key, values[key]);
614
+ });
615
+ history.pushState(null, "", url);
616
+ }
617
+
618
+ function pageNodes(page) {
619
+ if (page.kind === "cover") return [cover];
620
+ if (page.kind === "frontmatter") {
621
+ return (page.anchors || []).map(function (anchor) {
622
+ var heading = document.getElementById(anchor);
623
+ return heading ? heading.closest(".sect1") : null;
624
+ }).filter(Boolean);
625
+ }
626
+ if (page.kind === "part") {
627
+ var partHeading = document.getElementById(page.anchors[0]);
628
+ if (!partHeading) return [];
629
+ var nodes = [partHeading];
630
+ var next = partHeading.nextElementSibling;
631
+ while (next && !next.matches(".sect1") && !next.matches("h1.sect0")) {
632
+ nodes.push(next);
633
+ next = next.nextElementSibling;
634
+ }
635
+ return nodes;
636
+ }
637
+ var heading = document.getElementById(page.anchors[0]);
638
+ var section = heading ? heading.closest(".sect1") : null;
639
+ return section ? [section] : [];
640
+ }
641
+
642
+ function renderNav() {
643
+ nav.hidden = currentView !== "paged";
644
+ nav.textContent = "";
645
+ if (currentView !== "paged") return;
646
+ pages.forEach(function (page) {
647
+ var link = document.createElement("a");
648
+ link.href = "?view=paged&page=" + encodeURIComponent(page.id);
649
+ link.textContent = page.title;
650
+ link.dataset.pageId = page.id;
651
+ link.dataset.readerDepth = page.parentPageId ? "1" : "0";
652
+ if (page.id === currentPageId) link.setAttribute("aria-current", "page");
653
+ link.addEventListener("click", function (event) {
654
+ event.preventDefault();
655
+ setView("paged", page.id, true);
656
+ });
657
+ nav.appendChild(link);
658
+ });
659
+ }
660
+
661
+ function renderPageToc(page) {
662
+ pageToc.hidden = currentView !== "paged";
663
+ pageToc.textContent = "";
664
+ if (currentView !== "paged") return;
665
+ var title = document.createElement("strong");
666
+ title.textContent = "本页内容";
667
+ pageToc.appendChild(title);
668
+ (page.toc || []).forEach(function (item) {
669
+ var link = document.createElement("a");
670
+ link.href = "#" + item.anchor;
671
+ link.textContent = item.title;
672
+ link.style.paddingLeft = (item.depth * 0.8) + "rem";
673
+ link.addEventListener("click", function (event) {
674
+ event.preventDefault();
675
+ var target = document.getElementById(item.anchor);
676
+ if (target) target.scrollIntoView();
677
+ });
678
+ pageToc.appendChild(link);
679
+ });
680
+ }
681
+
682
+ function renderPagination(page) {
683
+ pagination.hidden = currentView !== "paged";
684
+ pagination.textContent = "";
685
+ if (currentView !== "paged") return;
686
+ var index = pages.findIndex(function (candidate) { return candidate.id === page.id; });
687
+ function addLink(label, target) {
688
+ if (!target) {
689
+ var spacer = document.createElement("span");
690
+ pagination.appendChild(spacer);
691
+ return;
692
+ }
693
+ var link = document.createElement("a");
694
+ link.href = "?view=paged&page=" + encodeURIComponent(target.id);
695
+ link.innerHTML = "<span>" + label + "</span>" + target.title;
696
+ link.addEventListener("click", function (event) {
697
+ event.preventDefault();
698
+ setView("paged", target.id, true);
699
+ window.scrollTo(0, 0);
700
+ });
701
+ pagination.appendChild(link);
702
+ }
703
+ addLink("上一页", pages[index - 1]);
704
+ addLink("下一页", pages[index + 1]);
705
+ }
706
+
707
+ function setView(view, pageId, persist) {
708
+ currentView = view;
709
+ currentPageId = pagesById.has(pageId) ? pageId : "cover";
710
+ var page = pagesById.get(currentPageId) || pages[0];
711
+ document.body.classList.toggle("multi-book-reader-paged", view === "paged");
712
+ document.body.classList.toggle("multi-book-reader-continuous", view === "continuous");
713
+ toggle.querySelectorAll("button").forEach(function (button) {
714
+ button.setAttribute("aria-pressed", button.dataset.readerView === view ? "true" : "false");
715
+ });
716
+ if (persist) {
717
+ localStorage.setItem("multi-book-reader-view", view);
718
+ writeQuery({ view: view, page: view === "paged" ? currentPageId : null });
719
+ }
720
+ if (view === "continuous") {
721
+ cover.hidden = true;
722
+ originalNodes.forEach(function (node) { node.hidden = false; });
723
+ originalTocNodes.forEach(function (node) { node.hidden = false; });
724
+ pageToc.hidden = true;
725
+ pagination.hidden = true;
726
+ renderNav();
727
+ return;
728
+ }
729
+ originalTocNodes.forEach(function (node) { node.hidden = true; });
730
+ var visible = new Set(pageNodes(page));
731
+ if (visible.size === 0 && page.kind !== "cover") {
732
+ cover.hidden = false;
733
+ cover.querySelector("h2").textContent = "当前页面无法在生成 HTML 中定位。";
734
+ visible.add(cover);
735
+ }
736
+ cover.hidden = !visible.has(cover);
737
+ originalNodes.forEach(function (node) { node.hidden = !visible.has(node); });
738
+ renderNav();
739
+ renderPageToc(page);
740
+ renderPagination(page);
741
+ }
742
+
743
+ toggle.addEventListener("click", function (event) {
744
+ var button = event.target.closest("button[data-reader-view]");
745
+ if (!button) return;
746
+ setView(button.dataset.readerView, currentPageId, true);
747
+ });
748
+
749
+ document.addEventListener("click", function (event) {
750
+ if (currentView !== "paged") return;
751
+ var link = event.target.closest('a[href^="#"]');
752
+ if (!link) return;
753
+ var anchor = decodeURIComponent(link.getAttribute("href").slice(1));
754
+ var targetPageId = anchorToPageId.get(anchor);
755
+ if (!targetPageId) return;
756
+ if (targetPageId !== currentPageId) {
757
+ event.preventDefault();
758
+ setView("paged", targetPageId, true);
759
+ var target = document.getElementById(anchor);
760
+ if (target) target.scrollIntoView();
761
+ }
762
+ });
763
+
764
+ window.addEventListener("popstate", function () {
765
+ var params = query();
766
+ setView(params.get("view") === "paged" ? "paged" : "continuous", params.get("page") || "cover", false);
767
+ });
768
+
769
+ var params = query();
770
+ var initialView = params.get("view") || localStorage.getItem("multi-book-reader-view") || "continuous";
771
+ setView(initialView === "paged" ? "paged" : "continuous", params.get("page") || "cover", false);
772
+ }
773
+ if (document.readyState === "loading") {
774
+ document.addEventListener("DOMContentLoaded", initReader);
775
+ } else {
776
+ initReader();
777
+ }
778
+ }());
779
+ `;
780
+ }
781
+ function addBookControlsToBookHtml(html, href, homeLink, bookSource, pageMap, bookId) {
782
+ if (html.includes(CONTROLS_MARKER))
213
783
  return html;
214
784
  const marker = '<div id="toc" class="toc2">';
215
785
  const index = html.indexOf(marker);
216
786
  if (index === -1)
217
- throw new Error("book HTML is missing the left TOC container");
787
+ throw new Error(`${bookId}: missing left TOC container`);
218
788
  const insertAt = index + marker.length;
219
- const homeBlock = `
789
+ const controlsBlock = `
220
790
  <style>
221
- .multi-book-home {
791
+ .multi-book-controls {
222
792
  margin: 0 0 1rem;
223
793
  padding-bottom: .75rem;
224
794
  border-bottom: 1px solid #e5e7eb;
225
795
  }
226
- .multi-book-home a {
796
+ .multi-book-home {
227
797
  color: #1f2937;
228
798
  display: block;
229
799
  font-weight: 600;
230
800
  line-height: 1.35;
231
801
  text-decoration: none;
232
802
  }
233
- .multi-book-home a:hover {
803
+ .multi-book-home:hover {
234
804
  color: #0f766e;
235
805
  text-decoration: underline;
236
806
  }
@@ -241,11 +811,96 @@ function addHomeLinkToBookHtml(html, href, homeLink) {
241
811
  font-weight: 400;
242
812
  margin-top: .15rem;
243
813
  }
814
+ .multi-book-copy-source {
815
+ appearance: none;
816
+ background: #ffffff;
817
+ border: 1px solid #cbd5e1;
818
+ border-radius: 4px;
819
+ color: #1f2937;
820
+ cursor: pointer;
821
+ display: block;
822
+ font-size: .82rem;
823
+ font-weight: 600;
824
+ line-height: 1.2;
825
+ margin-top: .75rem;
826
+ padding: .42rem .5rem;
827
+ text-align: center;
828
+ width: 100%;
829
+ }
830
+ .multi-book-copy-source:hover,
831
+ .multi-book-copy-source:focus {
832
+ border-color: #0f766e;
833
+ color: #0f766e;
834
+ }
835
+ .multi-book-copy-status {
836
+ color: #64748b;
837
+ display: block;
838
+ font-size: .75rem;
839
+ line-height: 1.35;
840
+ margin-top: .4rem;
841
+ min-height: 1em;
842
+ }
843
+ ${readerStyles()}
244
844
  </style>
245
- <div class="multi-book-home" ${HOME_MARKER}>
246
- <a href="${escapeHtmlAttribute(href)}">${escapeHtmlAttribute(homeLink.label)}<span>${escapeHtmlAttribute(homeLink.subtitle)}</span></a>
247
- </div>`;
248
- return `${html.slice(0, insertAt)}${homeBlock}${html.slice(insertAt)}`;
845
+ <div class="multi-book-controls" ${CONTROLS_MARKER}>
846
+ <a class="multi-book-home" ${HOME_MARKER} href="${escapeHtmlAttribute(href)}">${escapeHtmlAttribute(homeLink.label)}<span>${escapeHtmlAttribute(homeLink.subtitle)}</span></a>
847
+ <button type="button" class="multi-book-copy-source" data-multi-book-source-copy>复制本书为纯文本</button>
848
+ <span class="multi-book-copy-status" data-multi-book-source-status aria-live="polite"></span>
849
+ </div>
850
+ <script type="application/json" id="multi-book-source-data">${escapeJsonScript(bookSource)}</script>
851
+ <script type="application/json" id="multi-book-page-map">${escapeJsonValueForScript(pageMap)}</script>
852
+ <script>
853
+ (function () {
854
+ var sourceElement = document.getElementById("multi-book-source-data");
855
+ var button = document.querySelector("[data-multi-book-source-copy]");
856
+ var status = document.querySelector("[data-multi-book-source-status]");
857
+ if (!sourceElement || !button) return;
858
+
859
+ var source = "";
860
+ var fallbackMode = false;
861
+ try {
862
+ source = JSON.parse(sourceElement.textContent || '""');
863
+ } catch (error) {
864
+ if (status) status.textContent = "纯文本数据读取失败";
865
+ button.disabled = true;
866
+ return;
867
+ }
868
+
869
+ function setStatus(message) {
870
+ if (status) status.textContent = message;
871
+ }
872
+
873
+ function openSourcePage() {
874
+ var blob = new Blob([source], { type: "text/plain;charset=utf-8" });
875
+ var url = URL.createObjectURL(blob);
876
+ window.open(url, "_blank", "noopener");
877
+ setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
878
+ setStatus("已尝试打开纯文本页;如果没有出现,请允许弹出窗口后再试");
879
+ }
880
+
881
+ button.addEventListener("click", async function () {
882
+ if (fallbackMode) {
883
+ openSourcePage();
884
+ return;
885
+ }
886
+
887
+ try {
888
+ if (!navigator.clipboard || !navigator.clipboard.writeText) throw new Error("clipboard unavailable");
889
+ await navigator.clipboard.writeText(source);
890
+ button.textContent = "已复制";
891
+ setStatus("");
892
+ setTimeout(function () { button.textContent = "复制本书为纯文本"; }, 1800);
893
+ } catch (error) {
894
+ fallbackMode = true;
895
+ button.textContent = "打开纯文本页";
896
+ openSourcePage();
897
+ }
898
+ });
899
+ }());
900
+ </script>`;
901
+ const withReaderScript = `${controlsBlock}
902
+ <script>${readerScript()}</script>`;
903
+ return `${html.slice(0, insertAt)}${withReaderScript}${html.slice(insertAt)}`;
249
904
  }
250
905
  async function addHomeLinks(rootDir, books, homeLink) {
251
906
  const catalog = path.join(rootDir, "build", "html", "catalog.html");
@@ -253,7 +908,9 @@ async function addHomeLinks(rootDir, books, homeLink) {
253
908
  const htmlFile = path.join(book.htmlOutputDir, "book.html");
254
909
  const html = await readFile(htmlFile, "utf8");
255
910
  const href = path.relative(path.dirname(htmlFile), catalog);
256
- await writeFile(htmlFile, addHomeLinkToBookHtml(html, href, homeLink), "utf8");
911
+ const fallbackSource = await combinedBookSource(book.bookDir);
912
+ const readerData = buildReaderBookData(rootDir, book, fallbackSource);
913
+ await writeFile(htmlFile, addBookControlsToBookHtml(html, href, homeLink, readerData.sourceBundle, readerData.pageMap, book.bookId), "utf8");
257
914
  }
258
915
  }
259
916
  async function writeRootIndex(rootDir, rootIndex) {
@@ -394,7 +1051,6 @@ export async function buildWorkspace(rootDir = process.cwd()) {
394
1051
  const useKroki = await workspaceUsesDiagrams(rootDir, books);
395
1052
  const fetchDiagrams = shouldFetchDiagrams();
396
1053
  const asciidoctor = createAsciidoctor(useKroki);
397
- await buildReducedAdoc(rootDir, books, asciidoctor);
398
1054
  await buildHtml(rootDir, books, asciidoctor, useKroki, fetchDiagrams);
399
1055
  await copyAssets(rootDir, books);
400
1056
  await addHomeLinks(rootDir, books, config.homeLink);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-asciidoc-multi-book-workspace",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Create a complete AsciiDoc multi-book workspace.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -52,10 +52,11 @@
52
52
  "node": ">=20.19.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@asciidoctor/reducer": "1.1.2",
55
+ "@asciidoctor/core": "3.0.4",
56
56
  "@types/node": "24.10.1",
57
- "asciidoctor": "2.2.9",
57
+ "asciidoc-abundant-tree": "0.1.14",
58
58
  "asciidoctor-kroki": "0.18.1",
59
+ "playwright": "1.59.1",
59
60
  "typescript": "5.9.3"
60
61
  },
61
62
  "scripts": {
@@ -19,8 +19,8 @@ Outputs:
19
19
  - `build/html/index.html`
20
20
  - `build/html/catalog.html`
21
21
  - `build/html/books/<book-id>/book.html`
22
- - `build/adoc/catalog.adoc`
23
- - `build/adoc/books/<book-id>.adoc`
22
+
23
+ Each book HTML includes continuous and paged reading views. The copy-source control copies an abundant-tree source bundle for the current book; default builds do not write expanded ADOC files under `build/adoc/`.
24
24
 
25
25
  Diagram blocks render as Kroki image URLs by default, so normal builds do not depend on the Kroki service being available at build time.
26
26
  To fetch diagram images into the local HTML output, run:
@@ -60,3 +60,5 @@ role 描述标题身份。rel 描述 xref 边上的关系谓词。named attribut
60
60
  对于跨文件的交叉引用,为了避免重复编写相对路径,你可以在文档的头部(Header)定义一个文档属性(Document Attribute)来存储这个基础路径。
61
61
 
62
62
  你可以在主文档的开头定义一个变量(比如 `:book-b-dir:`),后续引用时通过 `{book-b-dir}` 来调用它。
63
+
64
+ 如果你的建模中存在需要对一段区域精准指向、提及的情况,优先考虑使用 **更小一级/更小一节** 的标题把这段区域划分出来,之后就可以继续使用 `xref` 进行交叉引用。
@@ -12,8 +12,8 @@
12
12
  "clean": "node tools/adoc-books.mjs clean"
13
13
  },
14
14
  "devDependencies": {
15
- "@asciidoctor/reducer": "1.1.2",
16
- "asciidoctor": "2.2.9",
15
+ "@asciidoctor/core": "3.0.4",
16
+ "asciidoc-abundant-tree": "0.1.14",
17
17
  "asciidoctor-kroki": "0.18.1"
18
18
  }
19
19
  }