@hutusi/amytis 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CLAUDE.md +90 -219
  3. package/bun.lock +185 -547
  4. package/content/books/sample-book/index.mdx +3 -0
  5. package/content/posts/code-block-features-showcase.mdx +223 -0
  6. package/docs/ALERTS.md +112 -0
  7. package/docs/ARCHITECTURE.md +217 -5
  8. package/docs/CODE-BLOCKS.md +238 -0
  9. package/docs/CONTRIBUTING.md +25 -0
  10. package/docs/guides/README.md +11 -0
  11. package/docs/guides/importing-vuepress-books.md +178 -0
  12. package/eslint.config.mjs +18 -6
  13. package/package.json +42 -20
  14. package/scripts/generate-code-group-icons.ts +79 -0
  15. package/scripts/render-rst.py +207 -3
  16. package/scripts/sync-vuepress-book.ts +499 -0
  17. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  18. package/src/app/books/[slug]/page.tsx +67 -32
  19. package/src/app/globals.css +503 -123
  20. package/src/app/page.tsx +1 -1
  21. package/src/app/sitemap.ts +3 -3
  22. package/src/components/ArticleCopyCleaner.tsx +64 -0
  23. package/src/components/BookMobileNav.tsx +44 -50
  24. package/src/components/BookSidebar.tsx +0 -0
  25. package/src/components/CodeBlock.test.tsx +93 -8
  26. package/src/components/CodeBlock.tsx +39 -101
  27. package/src/components/CodeBlockToolbar.tsx +88 -0
  28. package/src/components/CodeGroup.tsx +81 -0
  29. package/src/components/CoverImage.tsx +1 -0
  30. package/src/components/ExternalLinkIcon.tsx +15 -0
  31. package/src/components/FeaturedStoriesSection.tsx +3 -3
  32. package/src/components/GithubAlert.tsx +97 -0
  33. package/src/components/MarkdownRenderer.test.tsx +14 -4
  34. package/src/components/MarkdownRenderer.tsx +144 -23
  35. package/src/components/Mermaid.tsx +32 -1
  36. package/src/components/PostList.tsx +1 -1
  37. package/src/components/PostNavigation.tsx +13 -2
  38. package/src/components/PostSidebar.tsx +13 -2
  39. package/src/components/RstRenderer.test.tsx +15 -15
  40. package/src/components/RstRenderer.tsx +37 -2
  41. package/src/components/Search.tsx +18 -4
  42. package/src/components/SeriesCatalog.tsx +1 -1
  43. package/src/components/ShareBar.tsx +5 -0
  44. package/src/components/TocPanel.tsx +10 -2
  45. package/src/i18n/translations.ts +2 -0
  46. package/src/layouts/BookLayout.tsx +35 -4
  47. package/src/layouts/PostLayout.tsx +5 -1
  48. package/src/lib/code-group-icons.test.ts +78 -0
  49. package/src/lib/code-group-icons.ts +148 -0
  50. package/src/lib/markdown.test.ts +56 -13
  51. package/src/lib/markdown.ts +203 -50
  52. package/src/lib/normalize-vuepress-math.ts +118 -0
  53. package/src/lib/rehype-fence-meta.ts +22 -0
  54. package/src/lib/remark-book-chapter-links.ts +106 -0
  55. package/src/lib/remark-code-group.ts +54 -0
  56. package/src/lib/remark-github-alerts.test.ts +83 -0
  57. package/src/lib/remark-github-alerts.ts +65 -0
  58. package/src/lib/remark-vuepress-containers.ts +130 -0
  59. package/src/lib/rst-renderer.ts +19 -7
  60. package/src/lib/rst.test.ts +212 -2
  61. package/src/lib/rst.ts +217 -13
  62. package/src/lib/shiki-rst.ts +185 -0
  63. package/src/lib/shiki.test.ts +153 -0
  64. package/src/lib/shiki.ts +292 -0
  65. package/src/lib/urls.ts +57 -0
  66. package/src/test-utils/render.ts +23 -0
  67. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  68. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  69. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  70. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  71. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  72. package/tests/helpers/env.ts +19 -0
  73. package/tests/integration/book-chapter-links.test.ts +107 -0
  74. package/tests/integration/books-nested-toc.test.ts +176 -0
  75. package/tests/integration/books.test.ts +3 -2
  76. package/tests/integration/code-block-features.test.ts +188 -0
  77. package/tests/integration/code-group.test.ts +183 -0
  78. package/tests/integration/code-notation.test.ts +97 -0
  79. package/tests/integration/github-alerts.test.ts +82 -0
  80. package/tests/integration/markdown-external-links.test.ts +103 -0
  81. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  82. package/tests/integration/reading-time-headings.test.ts +8 -6
  83. package/tests/integration/series-draft.test.ts +6 -13
  84. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  85. package/tests/integration/vuepress-containers.test.ts +107 -0
  86. package/tests/tooling/new-post.test.ts +1 -1
  87. package/tests/unit/static-params.test.ts +32 -19
@@ -0,0 +1,79 @@
1
+ /**
2
+ * One-shot generator: builds CSS rules for the code-group tab icons by reading
3
+ * Iconify's logos / vscode-icons packs and embedding each icon as a data URI.
4
+ *
5
+ * Usage:
6
+ * bun scripts/generate-code-group-icons.ts > /tmp/icons.css
7
+ * Then replace the block between the BEGIN/END markers in src/app/globals.css.
8
+ *
9
+ * Each icon key here corresponds to a value returned by resolveCodeGroupIcon
10
+ * in src/lib/code-group-icons.ts. Add to both files to support a new key.
11
+ */
12
+ import { icons as logos } from '@iconify-json/logos';
13
+ import { icons as vscodeIcons } from '@iconify-json/vscode-icons';
14
+
15
+ type IconSrc = typeof logos | typeof vscodeIcons;
16
+
17
+ const MAP: Record<string, { src: IconSrc; name: string }> = {
18
+ npm: { src: logos, name: 'npm-icon' },
19
+ yarn: { src: logos, name: 'yarn' },
20
+ pnpm: { src: logos, name: 'pnpm' },
21
+ bun: { src: logos, name: 'bun' },
22
+ deno: { src: logos, name: 'deno' },
23
+ typescript: { src: logos, name: 'typescript-icon' },
24
+ javascript: { src: logos, name: 'javascript' },
25
+ python: { src: logos, name: 'python' },
26
+ rust: { src: logos, name: 'rust' },
27
+ go: { src: logos, name: 'go' },
28
+ java: { src: logos, name: 'java' },
29
+ ruby: { src: logos, name: 'ruby' },
30
+ php: { src: logos, name: 'php' },
31
+ c: { src: logos, name: 'c' },
32
+ cpp: { src: logos, name: 'c-plusplus' },
33
+ html: { src: logos, name: 'html-5' },
34
+ css: { src: logos, name: 'css-3' },
35
+ json: { src: vscodeIcons, name: 'file-type-json' },
36
+ yaml: { src: vscodeIcons, name: 'file-type-yaml' },
37
+ markdown: { src: vscodeIcons, name: 'file-type-markdown' },
38
+ bash: { src: logos, name: 'bash-icon' },
39
+ docker: { src: logos, name: 'docker-icon' },
40
+ vite: { src: logos, name: 'vitejs' },
41
+ react: { src: logos, name: 'react' },
42
+ vue: { src: logos, name: 'vue' },
43
+ nextjs: { src: logos, name: 'nextjs-icon' },
44
+ node: { src: logos, name: 'nodejs-icon' },
45
+ tailwind: { src: logos, name: 'tailwindcss-icon' },
46
+ };
47
+
48
+ function buildSvg(src: IconSrc, name: string): string | null {
49
+ const icon = src.icons[name];
50
+ if (!icon) return null;
51
+ const w = icon.width ?? src.width ?? 24;
52
+ const h = icon.height ?? src.height ?? 24;
53
+ // Iconify "body" is the inner content of the <svg> tag; wrap to get a full SVG.
54
+ return `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${w} ${h}'>${icon.body}</svg>`;
55
+ }
56
+
57
+ function encodeDataUri(svg: string): string {
58
+ // CSS data URIs only require `#` and `"` to be encoded; iconify bodies use
59
+ // double-quoted attributes, so swap to single quotes (valid SVG).
60
+ return svg.replace(/"/g, "'").replace(/#/g, '%23').replace(/\n/g, '').replace(/\s+/g, ' ');
61
+ }
62
+
63
+ const lines: string[] = [
64
+ '/* === BEGIN: generated by scripts/generate-code-group-icons.ts — do not hand-edit === */',
65
+ ];
66
+ for (const [key, { src, name }] of Object.entries(MAP)) {
67
+ const svg = buildSvg(src, name);
68
+ if (!svg) {
69
+ console.error(`Missing icon: ${name}`);
70
+ continue;
71
+ }
72
+ const dataUri = encodeDataUri(svg);
73
+ lines.push(`.cg-tab[data-cg-icon="${key}"]::before {`);
74
+ lines.push(` content: '';`);
75
+ lines.push(` background-image: url("data:image/svg+xml,${dataUri}");`);
76
+ lines.push(`}`);
77
+ }
78
+ lines.push('/* === END: generated icons === */');
79
+ console.log(lines.join('\n'));
@@ -563,6 +563,140 @@ def strip_preamble_nodes(document: Any) -> Any:
563
563
  return stripped
564
564
 
565
565
 
566
+ def _language_from_classes(classes: list[str] | None) -> str:
567
+ """Recover the source language from a literal_block's class list when the
568
+ explicit `language` attribute is absent. Docutils stores ``.. code-block:: foo``
569
+ as classes=['code', 'foo']; the first class that isn't a docutils-internal
570
+ marker is the language name.
571
+ """
572
+ if not classes:
573
+ return ""
574
+ for cls in classes:
575
+ if cls not in ("code", "literal-block", "linenos"):
576
+ return cls
577
+ return ""
578
+
579
+
580
+ def _build_amytis_code_marker(
581
+ text: str,
582
+ language: str,
583
+ highlight_lines: list[int] | None,
584
+ linenos: bool,
585
+ title: str | None,
586
+ ) -> str:
587
+ """Build the opaque <pre data-amytis-code> marker that the JS-side
588
+ Shiki post-processor in src/lib/shiki-rst.ts replaces with highlighted HTML.
589
+ """
590
+ attrs = ['data-amytis-code=""']
591
+ if language:
592
+ attrs.append(f'data-language="{html.escape(language, quote=True)}"')
593
+ if highlight_lines:
594
+ attrs.append(
595
+ f'data-highlight-lines="{",".join(str(n) for n in highlight_lines)}"'
596
+ )
597
+ if linenos:
598
+ attrs.append('data-line-numbers="true"')
599
+ if title:
600
+ attrs.append(f'data-title="{html.escape(title, quote=True)}"')
601
+
602
+ escaped = html.escape(text, quote=False)
603
+ return f'<pre {" ".join(attrs)}><code>{escaped}</code></pre>'
604
+
605
+
606
+ def _build_inner_block_marker(block: Any) -> tuple[str, str]:
607
+ """Helper: build the per-block <pre data-amytis-code> marker AND return the
608
+ tab label (from the new `:label:` option, or the language as fallback).
609
+ Used by both the standalone-literal_block path and the code-group path.
610
+ """
611
+ classes = list(block.get("classes") or [])
612
+ language = block.get("language") or _language_from_classes(classes)
613
+ highlight_args = block.get("highlight_args") or {}
614
+ hl_lines = list(highlight_args.get("hl_lines") or [])
615
+ linenos = "linenos" in classes
616
+ caption_text = block.get("amytis_caption") # set by the directive when :caption: is present
617
+ label = block.get("amytis_label") or language or ""
618
+
619
+ marker = _build_amytis_code_marker(
620
+ text=block.astext(),
621
+ language=language,
622
+ highlight_lines=hl_lines,
623
+ linenos=linenos,
624
+ title=caption_text,
625
+ )
626
+ return marker, label
627
+
628
+
629
+ def transform_literal_blocks_to_markers(document: Any) -> None:
630
+ """Replace every literal_block with an opaque <pre data-amytis-code> marker
631
+ so the JS-side post-processor can run Shiki uniformly. Caption-bearing
632
+ literal-block-wrapper containers are flattened into the marker's data-title.
633
+
634
+ Code-group containers (emitted by the .. code-group:: directive) are
635
+ handled FIRST so their child literal_blocks are consumed before the
636
+ standalone-block pass sees them — otherwise the standalone pass would
637
+ replace them and we'd lose the grouping wrapper.
638
+ """
639
+ from docutils import nodes
640
+ import json
641
+
642
+ # Pass 1: collapse caption containers so child literal_blocks carry their caption
643
+ # as a custom attribute. (Doing this once here means the helper doesn't need to
644
+ # walk back up to find a parent literal-block-wrapper.)
645
+ for container in list(document.findall(nodes.container)):
646
+ if "literal-block-wrapper" not in (container.get("classes") or []):
647
+ continue
648
+ caption_node = next(
649
+ (c for c in container.children if isinstance(c, nodes.caption)),
650
+ None,
651
+ )
652
+ inner_block = next(
653
+ (c for c in container.children if isinstance(c, nodes.literal_block)),
654
+ None,
655
+ )
656
+ if caption_node is not None and inner_block is not None:
657
+ inner_block["amytis_caption"] = caption_node.astext().strip()
658
+ container.parent.replace(container, inner_block)
659
+
660
+ # Pass 2: handle code-group containers. The directive marks them with the
661
+ # 'amytis-code-group-source' class. Per CLAUDE.md "strict build over silent
662
+ # runtime failure", malformed groups raise rather than getting dropped, and
663
+ # group ids are issued from a monotonic counter so two groups with identical
664
+ # label sets never share an id (which would couple their tab radios).
665
+ group_counter = 0
666
+ for container in list(document.findall(nodes.container)):
667
+ if "amytis-code-group-source" not in (container.get("classes") or []):
668
+ continue
669
+
670
+ inner_blocks = list(container.findall(nodes.literal_block))
671
+ if not inner_blocks:
672
+ raise RstRenderError(
673
+ "Empty or malformed '.. code-group::' directive: expected at least one nested .. code-block:: child."
674
+ )
675
+
676
+ markers: list[str] = []
677
+ labels: list[str] = []
678
+ for block in inner_blocks:
679
+ marker, label = _build_inner_block_marker(block)
680
+ markers.append(marker)
681
+ labels.append(label)
682
+
683
+ group_counter += 1
684
+ group_id = f"rst-{group_counter}"
685
+ labels_json = html.escape(json.dumps(labels, ensure_ascii=False), quote=True)
686
+ wrapper_html = (
687
+ f'<div data-amytis-code-group="" data-labels="{labels_json}" '
688
+ f'data-group-id="{group_id}">'
689
+ + "".join(markers)
690
+ + "</div>"
691
+ )
692
+ container.parent.replace(container, nodes.raw("", wrapper_html, format="html"))
693
+
694
+ # Pass 3: replace remaining (non-grouped) literal_blocks.
695
+ for block in list(document.findall(nodes.literal_block)):
696
+ marker, _ = _build_inner_block_marker(block)
697
+ block.parent.replace(block, nodes.raw("", marker, format="html"))
698
+
699
+
566
700
  def extract_html_body_from_doctree(document: Any) -> str:
567
701
  from docutils.core import publish_from_doctree
568
702
 
@@ -598,22 +732,92 @@ def build_output(document: Any, source_file: Path, image_base_slug: str, warning
598
732
  raise RstRenderError("Missing document title.")
599
733
 
600
734
  assets = extract_assets(document, source_file, image_base_slug)
735
+ # Read-only extractions first; the literal-block transformation mutates the tree.
736
+ text = extract_body_text(document)
737
+ headings = extract_headings(document)
738
+ metadata = extract_metadata(document)
739
+
740
+ transform_literal_blocks_to_markers(document)
601
741
  html_body = extract_html_body_from_doctree(strip_preamble_nodes(document))
602
742
 
603
743
  return {
604
744
  "title": title_node.astext().strip(),
605
745
  "html": rewrite_html_assets(html_body, assets),
606
- "text": extract_body_text(document),
607
- "headings": extract_headings(document),
608
- "metadata": extract_metadata(document),
746
+ "text": text,
747
+ "headings": headings,
748
+ "metadata": metadata,
609
749
  "assets": assets,
610
750
  "warnings": list(dict.fromkeys(warnings)),
611
751
  }
612
752
 
613
753
 
754
+ _amytis_directives_registered = False
755
+
756
+
757
+ def register_amytis_directives() -> None:
758
+ """Register the .. code-group:: directive and a code-block subclass that
759
+ accepts a :label: option. Both are global to the docutils registry, so
760
+ registering once per process is enough.
761
+
762
+ The code-block override only ADDS the :label: option; standard behavior
763
+ (language argument, :linenos:, :emphasize-lines:, :caption:) goes through
764
+ docutils' built-in implementation unchanged. The label is stashed on the
765
+ resulting literal_block via a custom amytis_label attribute and consumed
766
+ by transform_literal_blocks_to_markers.
767
+ """
768
+ global _amytis_directives_registered
769
+ if _amytis_directives_registered:
770
+ return
771
+
772
+ from docutils import nodes
773
+ from docutils.parsers.rst import Directive, directives
774
+ from docutils.parsers.rst.directives.body import CodeBlock as BaseCodeBlock
775
+
776
+ class LabeledCodeBlock(BaseCodeBlock):
777
+ option_spec = {
778
+ **BaseCodeBlock.option_spec,
779
+ "label": directives.unchanged,
780
+ }
781
+
782
+ def run(self):
783
+ result = super().run()
784
+ label = self.options.get("label")
785
+ if label:
786
+ for node in result:
787
+ for lb in node.findall(nodes.literal_block):
788
+ lb["amytis_label"] = label
789
+ return result
790
+
791
+ class CodeGroup(Directive):
792
+ """Wrap nested code-blocks into a tabbed code-group.
793
+
794
+ Body content is parsed as rST and contributes literal_block children;
795
+ transform_literal_blocks_to_markers later consumes the whole subtree
796
+ and emits the <div data-amytis-code-group> wrapper marker.
797
+ """
798
+
799
+ has_content = True
800
+ required_arguments = 0
801
+ optional_arguments = 0
802
+ option_spec = {}
803
+
804
+ def run(self):
805
+ wrapper = nodes.container()
806
+ wrapper["classes"].append("amytis-code-group-source")
807
+ self.state.nested_parse(self.content, self.content_offset, wrapper)
808
+ return [wrapper]
809
+
810
+ directives.register_directive("code-block", LabeledCodeBlock)
811
+ directives.register_directive("code", LabeledCodeBlock)
812
+ directives.register_directive("sourcecode", LabeledCodeBlock)
813
+ directives.register_directive("code-group", CodeGroup)
814
+ _amytis_directives_registered = True
815
+
816
+
614
817
  def render_single_file(source_file: Path, image_base_slug: str, strict: bool) -> dict[str, Any]:
615
818
  from docutils.core import publish_doctree
616
819
 
820
+ register_amytis_directives()
617
821
  warnings: list[str] = []
618
822
  source = normalize_legacy_doc_role_syntax(source_file.read_text(encoding="utf-8"))
619
823
  with temporary_role_overrides(source_file, warnings):