@hutusi/amytis 1.14.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +90 -219
  5. package/README.md +33 -1
  6. package/README.zh.md +33 -1
  7. package/TODO.md +10 -0
  8. package/bun.lock +205 -539
  9. package/content/books/sample-book/index.mdx +3 -0
  10. package/content/posts/code-block-features-showcase.mdx +223 -0
  11. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  12. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  13. package/content/series/rst-legacy/getting-started.rst +24 -0
  14. package/content/series/rst-legacy/index.rst +9 -0
  15. package/content/series/rst-readme/README.rst +9 -0
  16. package/content/series/rst-readme/readme-index-post.rst +10 -0
  17. package/content/series/rst-toctree/first-post.rst +6 -0
  18. package/content/series/rst-toctree/index.rst +10 -0
  19. package/content/series/rst-toctree/second-post.rst +6 -0
  20. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/index.rst +12 -0
  22. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  23. package/docs/ALERTS.md +112 -0
  24. package/docs/ARCHITECTURE.md +239 -8
  25. package/docs/CODE-BLOCKS.md +238 -0
  26. package/docs/CONTRIBUTING.md +36 -0
  27. package/docs/guides/README.md +11 -0
  28. package/docs/guides/importing-vuepress-books.md +178 -0
  29. package/eslint.config.mjs +20 -6
  30. package/next.config.ts +2 -2
  31. package/package.json +52 -24
  32. package/packages/create-amytis/package.json +1 -1
  33. package/packages/create-amytis/src/index.test.ts +43 -1
  34. package/packages/create-amytis/src/index.ts +64 -8
  35. package/public/next-image-export-optimizer-hashes.json +14 -73
  36. package/scripts/build-pagefind.ts +172 -0
  37. package/scripts/copy-assets.ts +246 -56
  38. package/scripts/generate-code-group-icons.ts +79 -0
  39. package/scripts/generate-knowledge-graph.ts +2 -1
  40. package/scripts/render-rst.py +923 -0
  41. package/scripts/run-with-rst-python.ts +42 -0
  42. package/scripts/sync-vuepress-book.ts +499 -0
  43. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  44. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  45. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  46. package/src/app/books/[slug]/page.tsx +67 -32
  47. package/src/app/globals.css +639 -94
  48. package/src/app/page.tsx +1 -1
  49. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  50. package/src/app/series/[slug]/page.tsx +11 -13
  51. package/src/app/series/page.tsx +3 -3
  52. package/src/app/sitemap.ts +3 -3
  53. package/src/components/ArticleCopyCleaner.tsx +64 -0
  54. package/src/components/AuthorCard.tsx +25 -16
  55. package/src/components/BookMobileNav.tsx +44 -50
  56. package/src/components/BookSidebar.tsx +0 -0
  57. package/src/components/CodeBlock.test.tsx +93 -8
  58. package/src/components/CodeBlock.tsx +39 -101
  59. package/src/components/CodeBlockToolbar.tsx +88 -0
  60. package/src/components/CodeGroup.tsx +81 -0
  61. package/src/components/CoverImage.tsx +6 -2
  62. package/src/components/ExternalLinkIcon.tsx +15 -0
  63. package/src/components/FeaturedStoriesSection.tsx +3 -3
  64. package/src/components/GithubAlert.tsx +97 -0
  65. package/src/components/MarkdownRenderer.test.tsx +30 -4
  66. package/src/components/MarkdownRenderer.tsx +148 -24
  67. package/src/components/Mermaid.tsx +32 -1
  68. package/src/components/PostList.tsx +1 -1
  69. package/src/components/PostNavigation.tsx +13 -2
  70. package/src/components/PostSidebar.tsx +13 -2
  71. package/src/components/RstRenderer.test.tsx +93 -0
  72. package/src/components/RstRenderer.tsx +157 -0
  73. package/src/components/Search.tsx +18 -4
  74. package/src/components/SeriesCatalog.tsx +1 -1
  75. package/src/components/ShareBar.tsx +5 -0
  76. package/src/components/TocPanel.tsx +10 -2
  77. package/src/i18n/translations.ts +2 -0
  78. package/src/layouts/BookLayout.tsx +35 -4
  79. package/src/layouts/PostLayout.tsx +10 -2
  80. package/src/layouts/SimpleLayout.tsx +10 -3
  81. package/src/lib/code-group-icons.test.ts +78 -0
  82. package/src/lib/code-group-icons.ts +148 -0
  83. package/src/lib/image-utils.test.ts +19 -0
  84. package/src/lib/image-utils.ts +11 -0
  85. package/src/lib/markdown.test.ts +195 -14
  86. package/src/lib/markdown.ts +928 -254
  87. package/src/lib/normalize-vuepress-math.ts +118 -0
  88. package/src/lib/rehype-fence-meta.ts +22 -0
  89. package/src/lib/rehype-image-metadata.ts +2 -2
  90. package/src/lib/remark-book-chapter-links.ts +106 -0
  91. package/src/lib/remark-code-group.ts +54 -0
  92. package/src/lib/remark-github-alerts.test.ts +83 -0
  93. package/src/lib/remark-github-alerts.ts +65 -0
  94. package/src/lib/remark-vuepress-containers.ts +130 -0
  95. package/src/lib/rst-renderer.test.ts +355 -0
  96. package/src/lib/rst-renderer.ts +629 -0
  97. package/src/lib/rst.test.ts +350 -0
  98. package/src/lib/rst.ts +674 -0
  99. package/src/lib/series-redirects.ts +42 -0
  100. package/src/lib/shiki-rst.ts +185 -0
  101. package/src/lib/shiki.test.ts +153 -0
  102. package/src/lib/shiki.ts +292 -0
  103. package/src/lib/urls.ts +57 -0
  104. package/src/test-utils/render.ts +23 -0
  105. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  106. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  107. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  108. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  109. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  110. package/tests/helpers/env.ts +19 -0
  111. package/tests/integration/book-chapter-links.test.ts +107 -0
  112. package/tests/integration/books-nested-toc.test.ts +176 -0
  113. package/tests/integration/books.test.ts +3 -2
  114. package/tests/integration/code-block-features.test.ts +188 -0
  115. package/tests/integration/code-group.test.ts +183 -0
  116. package/tests/integration/code-notation.test.ts +97 -0
  117. package/tests/integration/feed-utils.test.ts +13 -0
  118. package/tests/integration/github-alerts.test.ts +82 -0
  119. package/tests/integration/markdown-external-links.test.ts +103 -0
  120. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  121. package/tests/integration/reading-time-headings.test.ts +12 -14
  122. package/tests/integration/series-draft.test.ts +12 -5
  123. package/tests/integration/series.test.ts +93 -0
  124. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  125. package/tests/integration/vuepress-containers.test.ts +107 -0
  126. package/tests/tooling/build-pagefind.test.ts +66 -0
  127. package/tests/tooling/new-post.test.ts +1 -1
  128. package/tests/unit/static-params.test.ts +166 -13
@@ -6,11 +6,31 @@ import BookLayout from '@/layouts/BookLayout';
6
6
  import { resolveLocale } from '@/lib/i18n';
7
7
  import { buildBookChapterJsonLd, serializeJsonLd } from '@/lib/json-ld';
8
8
  import { getBookUrl, getBookChapterUrl } from '@/lib/urls';
9
+ import { safeDecodeParam } from '@/lib/series-redirects';
10
+
11
+ /**
12
+ * The chapter route is a catch-all (`[...chapter]`) so that nested chapter ids
13
+ * like `maths/linear/introduction` can be served at `/books/<slug>/maths/linear/introduction`
14
+ * — mapping VuePress-style nested folder paths to URLs 1:1. Single-segment legacy
15
+ * ids continue to work since catch-all matches one-or-more segments.
16
+ */
17
+
18
+ function chapterIdFromParams(rawChapter: string | string[] | undefined): string {
19
+ if (!rawChapter) return '';
20
+ if (Array.isArray(rawChapter)) {
21
+ return rawChapter.map(safeDecodeParam).join('/');
22
+ }
23
+ return safeDecodeParam(rawChapter);
24
+ }
25
+
26
+ function chapterIdToParamSegments(chapterId: string): string[] {
27
+ return chapterId.split('/').filter(Boolean);
28
+ }
9
29
 
10
30
  export async function generateStaticParams() {
11
31
  const books = getAllBooks();
12
- if (books.length === 0) return [{ slug: '_', chapter: '_' }];
13
- const params: { slug: string; chapter: string }[] = [];
32
+ if (books.length === 0) return [{ slug: '_', chapter: ['_'] }];
33
+ const params: { slug: string; chapter: string[] }[] = [];
14
34
 
15
35
  for (const book of books) {
16
36
  for (const ch of book.chapters) {
@@ -19,21 +39,23 @@ export async function generateStaticParams() {
19
39
  // frontmatter) would cause notFound() at render time, which in
20
40
  // output:export dev mode surfaces as a confusing "missing param" 500.
21
41
  if (getBookChapter(book.slug, ch.id) !== null) {
22
- params.push({ slug: book.slug, chapter: ch.id });
42
+ params.push({ slug: book.slug, chapter: chapterIdToParamSegments(ch.id) });
23
43
  }
24
44
  }
25
45
  }
26
46
 
27
47
  // Ensure we never return an empty array with output: export
28
- return params.length > 0 ? params : [{ slug: '_', chapter: '_' }];
48
+ return params.length > 0 ? params : [{ slug: '_', chapter: ['_'] }];
29
49
  }
30
50
 
31
51
  export const dynamicParams = false;
32
52
 
33
- export async function generateMetadata({ params }: { params: Promise<{ slug: string; chapter: string }> }): Promise<Metadata> {
53
+ type ChapterPageParams = Promise<{ slug: string; chapter: string[] }>;
54
+
55
+ export async function generateMetadata({ params }: { params: ChapterPageParams }): Promise<Metadata> {
34
56
  const { slug: rawSlug, chapter: rawChapter } = await params;
35
- const slug = decodeURIComponent(rawSlug);
36
- const chapterSlug = decodeURIComponent(rawChapter);
57
+ const slug = safeDecodeParam(rawSlug);
58
+ const chapterSlug = chapterIdFromParams(rawChapter);
37
59
 
38
60
  const book = getBookData(slug);
39
61
  const chapter = getBookChapter(slug, chapterSlug);
@@ -66,10 +88,10 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
66
88
  };
67
89
  }
68
90
 
69
- export default async function BookChapterPage({ params }: { params: Promise<{ slug: string; chapter: string }> }) {
91
+ export default async function BookChapterPage({ params }: { params: ChapterPageParams }) {
70
92
  const { slug: rawSlug, chapter: rawChapter } = await params;
71
- const slug = decodeURIComponent(rawSlug);
72
- const chapterSlug = decodeURIComponent(rawChapter);
93
+ const slug = safeDecodeParam(rawSlug);
94
+ const chapterSlug = chapterIdFromParams(rawChapter);
73
95
 
74
96
  const book = getBookData(slug);
75
97
  const chapter = getBookChapter(slug, chapterSlug);
@@ -1,4 +1,4 @@
1
- import { getBookData, getAllBooks, getAuthorSlug } from '@/lib/markdown';
1
+ import { getBookData, getAllBooks, getAuthorSlug, type BookTocSection, type BookChapterRef } from '@/lib/markdown';
2
2
  import { notFound } from 'next/navigation';
3
3
  import { Metadata } from 'next';
4
4
  import { siteConfig } from '../../../../site.config';
@@ -7,7 +7,52 @@ import MarkdownRenderer from '@/components/MarkdownRenderer';
7
7
  import Link from 'next/link';
8
8
  import { t, resolveLocale } from '@/lib/i18n';
9
9
  import { buildBookJsonLd, serializeJsonLd } from '@/lib/json-ld';
10
- import { getBookUrl } from '@/lib/urls';
10
+ import { getBookUrl, getBookChapterUrl } from '@/lib/urls';
11
+ import { safeDecodeParam } from '@/lib/series-redirects';
12
+
13
+ // Visual depth limit for nested-section headings. After the first two levels
14
+ // we keep nesting structurally but stop bumping the heading style so deeply
15
+ // nested books don't degrade into tiny text.
16
+ const MAX_HEADING_DEPTH = 2;
17
+
18
+ function chapterRow(ref: BookChapterRef, slug: string, key: string) {
19
+ return (
20
+ <li key={key}>
21
+ <Link
22
+ href={getBookChapterUrl(slug, ref.id)}
23
+ className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
24
+ >
25
+ <svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
26
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
27
+ </svg>
28
+ <span className="text-base">{ref.title}</span>
29
+ </Link>
30
+ </li>
31
+ );
32
+ }
33
+
34
+ function renderTocSection(section: BookTocSection, slug: string, keyPrefix: string, depth: number): React.ReactNode {
35
+ const headingDepth = Math.min(depth, MAX_HEADING_DEPTH);
36
+ const headingClass =
37
+ headingDepth === 0
38
+ ? 'text-lg font-serif font-bold text-heading mb-3'
39
+ : headingDepth === 1
40
+ ? 'text-sm font-sans font-bold uppercase tracking-wider text-muted mb-3'
41
+ : 'text-xs font-sans font-semibold uppercase tracking-wider text-muted/80 mb-2';
42
+
43
+ return (
44
+ <div key={keyPrefix} className={depth === 0 ? '' : 'mt-3'}>
45
+ <h3 className={headingClass}>{section.section}</h3>
46
+ <ol className="space-y-2 pl-4 border-l-2 border-muted/10">
47
+ {section.items.map((child, idx) =>
48
+ 'section' in child
49
+ ? <li key={`${keyPrefix}-${idx}`}>{renderTocSection(child, slug, `${keyPrefix}-${idx}`, depth + 1)}</li>
50
+ : chapterRow(child, slug, `${keyPrefix}-${child.id}`)
51
+ )}
52
+ </ol>
53
+ </div>
54
+ );
55
+ }
11
56
 
12
57
  export async function generateStaticParams() {
13
58
  const books = getAllBooks();
@@ -19,7 +64,7 @@ export const dynamicParams = false;
19
64
 
20
65
  export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
21
66
  const { slug } = await params;
22
- const book = getBookData(decodeURIComponent(slug));
67
+ const book = getBookData(safeDecodeParam(slug));
23
68
 
24
69
  if (!book) {
25
70
  return { title: 'Book Not Found' };
@@ -36,7 +81,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
36
81
  title: book.title,
37
82
  description: book.excerpt,
38
83
  type: 'website',
39
- url: `${siteConfig.baseUrl}/books/${slug}`,
84
+ url: `${siteConfig.baseUrl}${getBookUrl(book.slug)}`,
40
85
  siteName: resolveLocale(siteConfig.title),
41
86
  images: [{ url: ogImage, width: 1200, height: 630, alt: book.title }],
42
87
  },
@@ -51,7 +96,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
51
96
 
52
97
  export default async function BookLandingPage({ params }: { params: Promise<{ slug: string }> }) {
53
98
  const { slug: rawSlug } = await params;
54
- const slug = decodeURIComponent(rawSlug);
99
+ const slug = safeDecodeParam(rawSlug);
55
100
  const book = getBookData(slug);
56
101
 
57
102
  if (!book || (process.env.NODE_ENV === 'production' && book.draft)) {
@@ -118,7 +163,7 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
118
163
  {firstChapter && (
119
164
  <div className="mt-8">
120
165
  <Link
121
- href={`/books/${book.slug}/${firstChapter.id}`}
166
+ href={getBookChapterUrl(book.slug, firstChapter.id)}
122
167
  className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white rounded-xl font-sans font-medium text-sm hover:bg-accent/90 no-underline transition-colors shadow-lg shadow-accent/20"
123
168
  >
124
169
  {t('start_reading')}
@@ -143,36 +188,26 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
143
188
  {item.part}
144
189
  </h3>
145
190
  <ol className="space-y-2 pl-4 border-l-2 border-muted/10">
146
- {item.chapters.map(ch => (
147
- <li key={ch.id}>
148
- <Link
149
- href={`/books/${book.slug}/${ch.id}`}
150
- className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
151
- >
152
- <svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
153
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
154
- </svg>
155
- <span className="text-base">{ch.title}</span>
156
- </Link>
157
- </li>
158
- ))}
191
+ {item.chapters.map(ch => chapterRow(ch, book.slug, ch.id))}
159
192
  </ol>
160
193
  </div>
161
194
  );
162
- } else {
163
- return (
164
- <Link
165
- key={item.id}
166
- href={`/books/${book.slug}/${item.id}`}
167
- className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
168
- >
169
- <svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
170
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
171
- </svg>
172
- <span className="text-base">{item.title}</span>
173
- </Link>
174
- );
175
195
  }
196
+ if ('section' in item) {
197
+ return renderTocSection(item, book.slug, `section-${idx}`, 0);
198
+ }
199
+ return (
200
+ <Link
201
+ key={item.id}
202
+ href={getBookChapterUrl(book.slug, item.id)}
203
+ className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
204
+ >
205
+ <svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
206
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
207
+ </svg>
208
+ <span className="text-base">{item.title}</span>
209
+ </Link>
210
+ );
176
211
  })}
177
212
  </div>
178
213
  </section>