@hutusi/amytis 1.15.0 → 1.17.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 (120) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +89 -219
  5. package/bun.lock +185 -547
  6. package/content/books/sample-book/index.mdx +3 -0
  7. package/content/posts/code-block-features-showcase.mdx +223 -0
  8. package/docs/ALERTS.md +112 -0
  9. package/docs/ARCHITECTURE.md +298 -5
  10. package/docs/CODE-BLOCKS.md +238 -0
  11. package/docs/CONTRIBUTING.md +25 -0
  12. package/docs/DIGITAL_GARDEN.md +1 -1
  13. package/docs/guides/README.md +11 -0
  14. package/docs/guides/importing-vuepress-books.md +237 -0
  15. package/eslint.config.mjs +18 -6
  16. package/package.json +42 -20
  17. package/scripts/generate-code-group-icons.ts +79 -0
  18. package/scripts/render-rst.py +207 -3
  19. package/scripts/sync-vuepress-book.ts +710 -0
  20. package/site.config.example.ts +3 -3
  21. package/site.config.ts +3 -3
  22. package/src/app/[slug]/layout.tsx +30 -0
  23. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  24. package/src/app/books/[slug]/layout.tsx +24 -0
  25. package/src/app/books/[slug]/page.tsx +85 -34
  26. package/src/app/globals.css +570 -123
  27. package/src/app/page.tsx +7 -1
  28. package/src/app/posts/layout.tsx +20 -0
  29. package/src/app/series/[slug]/page.tsx +33 -9
  30. package/src/app/sitemap.ts +3 -3
  31. package/src/components/ArticleCopyCleaner.tsx +64 -0
  32. package/src/components/BookMobileNav.tsx +44 -50
  33. package/src/components/BookReadingShell.tsx +145 -0
  34. package/src/components/BookSidebar.tsx +0 -0
  35. package/src/components/CodeBlock.test.tsx +93 -8
  36. package/src/components/CodeBlock.tsx +39 -101
  37. package/src/components/CodeBlockToolbar.tsx +88 -0
  38. package/src/components/CodeGroup.tsx +81 -0
  39. package/src/components/CoverImage.tsx +1 -0
  40. package/src/components/CuratedSeriesSection.tsx +28 -10
  41. package/src/components/ExternalLinkIcon.tsx +15 -0
  42. package/src/components/FeaturedStoriesSection.tsx +44 -23
  43. package/src/components/Footer.tsx +1 -1
  44. package/src/components/GithubAlert.tsx +97 -0
  45. package/src/components/ImmersiveReader.tsx +130 -0
  46. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  47. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  48. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  49. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  50. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  51. package/src/components/ImmersiveToggleButton.tsx +45 -0
  52. package/src/components/MarkdownRenderer.test.tsx +14 -4
  53. package/src/components/MarkdownRenderer.tsx +175 -23
  54. package/src/components/Mermaid.tsx +32 -1
  55. package/src/components/Navbar.tsx +3 -1
  56. package/src/components/PostList.tsx +1 -1
  57. package/src/components/PostNavigation.tsx +13 -2
  58. package/src/components/PostReadingShell.tsx +68 -0
  59. package/src/components/PostSidebar.tsx +13 -2
  60. package/src/components/ReadingProgressBar.tsx +1 -1
  61. package/src/components/RstRenderer.test.tsx +15 -15
  62. package/src/components/RstRenderer.tsx +37 -2
  63. package/src/components/Search.tsx +18 -4
  64. package/src/components/SelectedBooksSection.tsx +27 -8
  65. package/src/components/SeriesCatalog.tsx +1 -1
  66. package/src/components/ShareBar.tsx +5 -0
  67. package/src/components/TocPanel.tsx +10 -2
  68. package/src/hooks/useActiveHeading.ts +35 -13
  69. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  70. package/src/i18n/translations.ts +44 -0
  71. package/src/layouts/BookLayout.tsx +62 -74
  72. package/src/layouts/PostLayout.tsx +154 -111
  73. package/src/lib/code-group-icons.test.ts +78 -0
  74. package/src/lib/code-group-icons.ts +148 -0
  75. package/src/lib/immersive-reading-prefs.ts +104 -0
  76. package/src/lib/markdown.test.ts +56 -13
  77. package/src/lib/markdown.ts +217 -57
  78. package/src/lib/normalize-vuepress-math.ts +118 -0
  79. package/src/lib/rehype-fence-meta.ts +22 -0
  80. package/src/lib/remark-book-chapter-links.ts +106 -0
  81. package/src/lib/remark-code-group.ts +54 -0
  82. package/src/lib/remark-github-alerts.test.ts +83 -0
  83. package/src/lib/remark-github-alerts.ts +65 -0
  84. package/src/lib/remark-vuepress-containers.ts +130 -0
  85. package/src/lib/rst-renderer.ts +19 -7
  86. package/src/lib/rst.test.ts +212 -2
  87. package/src/lib/rst.ts +217 -13
  88. package/src/lib/scroll-utils.ts +44 -6
  89. package/src/lib/shiki-rst.ts +185 -0
  90. package/src/lib/shiki.test.ts +153 -0
  91. package/src/lib/shiki.ts +292 -0
  92. package/src/lib/shuffle.ts +15 -1
  93. package/src/lib/sort.ts +15 -0
  94. package/src/lib/urls.ts +62 -0
  95. package/src/test-utils/render.ts +23 -0
  96. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  97. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  98. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  99. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  100. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  101. package/tests/helpers/env.ts +19 -0
  102. package/tests/integration/book-chapter-links.test.ts +107 -0
  103. package/tests/integration/book-index-cta.test.ts +87 -0
  104. package/tests/integration/books-nested-toc.test.ts +176 -0
  105. package/tests/integration/books.test.ts +3 -2
  106. package/tests/integration/code-block-features.test.ts +188 -0
  107. package/tests/integration/code-group.test.ts +183 -0
  108. package/tests/integration/code-notation.test.ts +97 -0
  109. package/tests/integration/github-alerts.test.ts +82 -0
  110. package/tests/integration/markdown-external-links.test.ts +103 -0
  111. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  112. package/tests/integration/reading-time-headings.test.ts +8 -6
  113. package/tests/integration/series-draft.test.ts +6 -13
  114. package/tests/integration/series-index-cta.test.ts +88 -0
  115. package/tests/integration/sync-vuepress-book.test.ts +443 -0
  116. package/tests/integration/vuepress-containers.test.ts +107 -0
  117. package/tests/tooling/new-post.test.ts +1 -1
  118. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  119. package/tests/unit/static-params.test.ts +32 -19
  120. package/vercel.json +7 -0
@@ -1,114 +1,52 @@
1
- "use client";
2
-
3
- import React, { useState } from 'react';
4
- import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
5
- import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
6
- import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
7
- import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
8
- import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
9
- import markdown from 'react-syntax-highlighter/dist/esm/languages/prism/markdown';
10
- import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
11
- import css from 'react-syntax-highlighter/dist/esm/languages/prism/css';
12
- import python from 'react-syntax-highlighter/dist/esm/languages/prism/python';
13
- import rust from 'react-syntax-highlighter/dist/esm/languages/prism/rust';
14
- import go from 'react-syntax-highlighter/dist/esm/languages/prism/go';
15
- import c from 'react-syntax-highlighter/dist/esm/languages/prism/c';
16
- import cpp from 'react-syntax-highlighter/dist/esm/languages/prism/cpp';
17
- import java from 'react-syntax-highlighter/dist/esm/languages/prism/java';
18
- import ruby from 'react-syntax-highlighter/dist/esm/languages/prism/ruby';
19
- import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
20
- import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
21
- import diff from 'react-syntax-highlighter/dist/esm/languages/prism/diff';
22
- import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup';
23
-
24
- SyntaxHighlighter.registerLanguage('tsx', tsx);
25
- SyntaxHighlighter.registerLanguage('typescript', typescript);
26
- SyntaxHighlighter.registerLanguage('javascript', javascript);
27
- SyntaxHighlighter.registerLanguage('js', javascript);
28
- SyntaxHighlighter.registerLanguage('bash', bash);
29
- SyntaxHighlighter.registerLanguage('sh', bash);
30
- SyntaxHighlighter.registerLanguage('shell', bash);
31
- SyntaxHighlighter.registerLanguage('markdown', markdown);
32
- SyntaxHighlighter.registerLanguage('md', markdown);
33
- SyntaxHighlighter.registerLanguage('json', json);
34
- SyntaxHighlighter.registerLanguage('css', css);
35
- SyntaxHighlighter.registerLanguage('python', python);
36
- SyntaxHighlighter.registerLanguage('py', python);
37
- SyntaxHighlighter.registerLanguage('rust', rust);
38
- SyntaxHighlighter.registerLanguage('go', go);
39
- SyntaxHighlighter.registerLanguage('golang', go);
40
- SyntaxHighlighter.registerLanguage('c', c);
41
- SyntaxHighlighter.registerLanguage('cpp', cpp);
42
- SyntaxHighlighter.registerLanguage('c++', cpp);
43
- SyntaxHighlighter.registerLanguage('java', java);
44
- SyntaxHighlighter.registerLanguage('ruby', ruby);
45
- SyntaxHighlighter.registerLanguage('rb', ruby);
46
- SyntaxHighlighter.registerLanguage('sql', sql);
47
- SyntaxHighlighter.registerLanguage('yaml', yaml);
48
- SyntaxHighlighter.registerLanguage('yml', yaml);
49
- SyntaxHighlighter.registerLanguage('diff', diff);
50
- SyntaxHighlighter.registerLanguage('html', markup);
51
- SyntaxHighlighter.registerLanguage('xml', markup);
52
- SyntaxHighlighter.registerLanguage('svg', markup);
1
+ import { toHtml } from 'hast-util-to-html';
2
+ import CodeBlockToolbar from './CodeBlockToolbar';
3
+ import { getLanguageDisplayName, highlightToHast } from '@/lib/shiki';
53
4
 
54
5
  interface CodeBlockProps {
55
6
  language: string;
56
7
  children: string;
57
- [key: string]: unknown;
8
+ title?: string;
9
+ showLineNumbers?: boolean;
10
+ highlightLines?: number[];
58
11
  }
59
12
 
60
- export default function CodeBlock({ language, children, ...props }: CodeBlockProps) {
61
- const [copied, setCopied] = useState(false);
62
-
63
- const handleCopy = async () => {
64
- await navigator.clipboard.writeText(children);
65
- setCopied(true);
66
- setTimeout(() => setCopied(false), 2000);
67
- };
13
+ export default async function CodeBlock({
14
+ language,
15
+ children,
16
+ title,
17
+ showLineNumbers,
18
+ highlightLines,
19
+ }: CodeBlockProps) {
20
+ const hast = await highlightToHast(children, language, {
21
+ showLineNumbers,
22
+ highlightLines,
23
+ title,
24
+ });
25
+ const html = toHtml(hast);
26
+ const displayLang = getLanguageDisplayName(language || 'text');
68
27
 
69
28
  return (
70
- <div className="relative my-6 w-full min-w-0 max-w-full rounded-lg border border-muted/20 bg-background/50 overflow-hidden shadow-sm">
71
- <div className="flex items-center justify-between px-4 py-2 border-b border-muted/10 bg-muted/5">
72
- <span className="text-xs font-mono text-muted uppercase tracking-wider">
73
- {language || 'text'}
74
- </span>
75
- <button
76
- onClick={handleCopy}
77
- className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
78
- aria-label="Copy code"
79
- >
80
- {copied ? (
81
- <>
82
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
83
- <span>Copied</span>
84
- </>
85
- ) : (
86
- <>
87
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
88
- <span>Copy</span>
89
- </>
29
+ <div
30
+ data-cb-root=""
31
+ className="cb-root relative my-6 w-full min-w-0 max-w-full rounded-lg border border-muted/20 bg-background/50 overflow-hidden shadow-sm"
32
+ >
33
+ <div className="cb-header flex items-center justify-between px-4 py-2 border-b border-muted/10 bg-muted/5 gap-3">
34
+ <div className="flex items-center gap-3 min-w-0">
35
+ <span className="cb-lang text-xs font-mono text-muted tracking-wider">
36
+ {displayLang}
37
+ </span>
38
+ {title && (
39
+ <span className="cb-title truncate text-xs text-foreground/80" title={title}>
40
+ {title}
41
+ </span>
90
42
  )}
91
- </button>
92
- </div>
93
- <div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden">
94
- <SyntaxHighlighter
95
- language={language || 'text'}
96
- PreTag="div"
97
- useInlineStyles={false}
98
- customStyle={{
99
- margin: 0,
100
- padding: '1.5rem',
101
- background: 'transparent',
102
- fontSize: '0.9rem',
103
- lineHeight: '1.6',
104
- maxWidth: '100%',
105
- boxSizing: 'border-box',
106
- }}
107
- {...props}
108
- >
109
- {children}
110
- </SyntaxHighlighter>
43
+ </div>
44
+ <CodeBlockToolbar code={children} />
111
45
  </div>
46
+ <div
47
+ className="cb-scroll w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden"
48
+ dangerouslySetInnerHTML={{ __html: html }}
49
+ />
112
50
  </div>
113
51
  );
114
52
  }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from 'react';
4
+
5
+ interface CodeBlockToolbarProps {
6
+ code: string;
7
+ }
8
+
9
+ export default function CodeBlockToolbar({ code }: CodeBlockToolbarProps) {
10
+ const [copied, setCopied] = useState(false);
11
+ const [wrapped, setWrapped] = useState(false);
12
+ const buttonRef = useRef<HTMLButtonElement | null>(null);
13
+
14
+ const handleCopy = async () => {
15
+ try {
16
+ await navigator.clipboard.writeText(code);
17
+ setCopied(true);
18
+ setTimeout(() => setCopied(false), 2000);
19
+ } catch {
20
+ // Clipboard may be unavailable (e.g. insecure context); fail silently.
21
+ }
22
+ };
23
+
24
+ const handleToggleWrap = () => {
25
+ const next = !wrapped;
26
+ setWrapped(next);
27
+ const root = buttonRef.current?.closest('[data-cb-root]') as HTMLElement | null;
28
+ if (root) {
29
+ if (next) {
30
+ root.setAttribute('data-wrap', 'true');
31
+ } else {
32
+ root.removeAttribute('data-wrap');
33
+ }
34
+ }
35
+ };
36
+
37
+ return (
38
+ <div className="flex items-center gap-3">
39
+ <button
40
+ ref={buttonRef}
41
+ onClick={handleToggleWrap}
42
+ className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
43
+ aria-label={wrapped ? 'Disable word wrap' : 'Enable word wrap'}
44
+ aria-pressed={wrapped}
45
+ type="button"
46
+ >
47
+ {wrapped ? (
48
+ <>
49
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
50
+ <path d="M3 6h18" />
51
+ <path d="M3 12h15a3 3 0 0 1 0 6h-4" />
52
+ <path d="m16 16-2 2 2 2" />
53
+ <path d="M3 18h7" />
54
+ </svg>
55
+ <span>Wrap</span>
56
+ </>
57
+ ) : (
58
+ <>
59
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
60
+ <path d="M3 6h18" />
61
+ <path d="M3 12h18" />
62
+ <path d="M3 18h18" />
63
+ </svg>
64
+ <span>No wrap</span>
65
+ </>
66
+ )}
67
+ </button>
68
+ <button
69
+ onClick={handleCopy}
70
+ className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
71
+ aria-label="Copy code"
72
+ type="button"
73
+ >
74
+ {copied ? (
75
+ <>
76
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
77
+ <span>Copied</span>
78
+ </>
79
+ ) : (
80
+ <>
81
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
82
+ <span>Copy</span>
83
+ </>
84
+ )}
85
+ </button>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,81 @@
1
+ import { Children, type ReactNode } from 'react';
2
+ import { resolveCodeGroupIcon } from '@/lib/code-group-icons';
3
+
4
+ interface CodeGroupProps {
5
+ 'data-labels'?: string;
6
+ 'data-group-id'?: string;
7
+ children?: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * Tabbed code group widget. CSS-only — the radio + label sibling trick handles
12
+ * tab switching with zero JavaScript. The matching CSS in globals.css picks the
13
+ * checked input's matching panel via attribute selectors (input[data-idx=N] →
14
+ * .cg-panel[data-panel=N]).
15
+ *
16
+ * Children are already-highlighted CodeBlock server components from the
17
+ * outer MarkdownRenderer pipeline; this component just composes the tab UI
18
+ * around them.
19
+ */
20
+ export default function CodeGroup(props: CodeGroupProps) {
21
+ const labelsRaw = props['data-labels'] ?? '[]';
22
+ const groupId = props['data-group-id'] ?? 'cg-default';
23
+ let labels: string[];
24
+ try {
25
+ const parsed = JSON.parse(labelsRaw);
26
+ labels = Array.isArray(parsed) ? parsed.map(String) : [];
27
+ } catch {
28
+ labels = [];
29
+ }
30
+
31
+ // Drop whitespace text nodes that remark-rehype leaves between code children.
32
+ const panels = Children.toArray(props.children).filter((child) => {
33
+ if (typeof child === 'string') return child.trim().length > 0;
34
+ return true;
35
+ });
36
+ const tabs = labels.length > 0 ? labels : panels.map((_, i) => `Tab ${i + 1}`);
37
+
38
+ return (
39
+ <div className="code-group my-6" data-group-id={groupId}>
40
+ {tabs.map((_, i) => (
41
+ <input
42
+ key={`r-${i}`}
43
+ type="radio"
44
+ name={`cg-${groupId}`}
45
+ id={`cg-${groupId}-${i}`}
46
+ data-idx={String(i)}
47
+ defaultChecked={i === 0}
48
+ aria-controls={`cg-${groupId}-panel-${i}`}
49
+ tabIndex={i === 0 ? 0 : -1}
50
+ />
51
+ ))}
52
+ <div className="cg-tablist" role="tablist">
53
+ {tabs.map((label, i) => {
54
+ const icon = resolveCodeGroupIcon(label);
55
+ return (
56
+ <label
57
+ key={`l-${i}`}
58
+ htmlFor={`cg-${groupId}-${i}`}
59
+ className="cg-tab"
60
+ role="tab"
61
+ {...(icon ? { 'data-cg-icon': icon } : {})}
62
+ >
63
+ {label}
64
+ </label>
65
+ );
66
+ })}
67
+ </div>
68
+ {panels.map((child, i) => (
69
+ <div
70
+ key={`p-${i}`}
71
+ className="cg-panel"
72
+ data-panel={String(i)}
73
+ role="tabpanel"
74
+ id={`cg-${groupId}-panel-${i}`}
75
+ >
76
+ {child}
77
+ </div>
78
+ ))}
79
+ </div>
80
+ );
81
+ }
@@ -101,6 +101,7 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
101
101
  unoptimized={!useBlurPlaceholder}
102
102
  placeholder={useBlurPlaceholder ? 'blur' : 'empty'}
103
103
  loading={loading}
104
+ fetchPriority={loading === 'eager' ? 'high' : 'low'}
104
105
  />
105
106
  );
106
107
  }
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import Link from 'next/link';
5
5
  import HorizontalScroll from './HorizontalScroll';
6
6
  import CoverImage from './CoverImage';
7
7
  import { useLanguage } from './LanguageProvider';
8
- import { shuffle, shuffleSeeded } from '@/lib/shuffle';
8
+ import { shuffle } from '@/lib/shuffle';
9
+ import { byDateAsc, byDateDesc } from '@/lib/sort';
9
10
  import { getPostUrl, getSeriesListUrl } from '@/lib/urls';
10
11
 
11
12
  export interface SeriesItem {
@@ -16,21 +17,38 @@ export interface SeriesItem {
16
17
  url: string;
17
18
  postCount: number;
18
19
  topPosts: { slug: string; title: string }[];
20
+ date: string;
19
21
  }
20
22
 
23
+ type SeriesOrder = 'shuffle' | 'date-desc' | 'date-asc';
24
+
21
25
  interface CuratedSeriesSectionProps {
22
26
  allSeries: SeriesItem[];
23
27
  maxItems: number;
28
+ order?: SeriesOrder;
29
+ }
30
+
31
+ function canonicalOrder(series: SeriesItem[], order: SeriesOrder): SeriesItem[] {
32
+ if (order === 'date-desc') return [...series].sort(byDateDesc);
33
+ if (order === 'date-asc') return [...series].sort(byDateAsc);
34
+ return series;
24
35
  }
25
36
 
26
- export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSeriesSectionProps) {
37
+ export default function CuratedSeriesSection({ allSeries, maxItems, order = 'shuffle' }: CuratedSeriesSectionProps) {
27
38
  const { t } = useLanguage();
28
- // Use a daily seed so SSR and client hydration agree on the initial order,
29
- // preventing a visible reshuffle flash on page load.
30
- const [displayed, setDisplayed] = useState(() => {
31
- const dailySeed = Math.floor(Date.now() / 86400000);
32
- return shuffleSeeded(allSeries, dailySeed).slice(0, maxItems);
33
- });
39
+ // SSR renders the canonical input order so server and client agree on first paint.
40
+ // For 'shuffle', the post-mount useEffect swaps to a fresh random permutation,
41
+ // so every reload re-rolls without any hydration mismatch.
42
+ const [displayed, setDisplayed] = useState(() => canonicalOrder(allSeries, order).slice(0, maxItems));
43
+
44
+ // Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
45
+ // post-hydration swap is the intentional client-only behaviour, not a sync issue.
46
+ useEffect(() => {
47
+ if (order === 'shuffle') {
48
+ // eslint-disable-next-line react-hooks/set-state-in-effect
49
+ setDisplayed(shuffle(allSeries).slice(0, maxItems));
50
+ }
51
+ }, [allSeries, maxItems, order]);
34
52
 
35
53
  const handleShuffle = useCallback(() => {
36
54
  setDisplayed(shuffle(allSeries).slice(0, maxItems));
@@ -43,7 +61,7 @@ export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSer
43
61
  <div className="flex items-center justify-between mb-8">
44
62
  <h2 className="text-2xl sm:text-3xl font-serif font-bold text-heading">{t('curated_series')}</h2>
45
63
  <div className="flex items-center gap-4">
46
- {allSeries.length > maxItems && (
64
+ {order === 'shuffle' && allSeries.length > maxItems && (
47
65
  <button
48
66
  onClick={handleShuffle}
49
67
  className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
@@ -0,0 +1,15 @@
1
+ import { LuArrowUpRight } from 'react-icons/lu';
2
+
3
+ /**
4
+ * Inline outward-arrow indicator appended after external-link text by
5
+ * `MarkdownRenderer`'s `<a>` override. Sized relative to the surrounding
6
+ * text and aria-hidden — the link's accessible name already carries intent.
7
+ */
8
+ export default function ExternalLinkIcon() {
9
+ return (
10
+ <LuArrowUpRight
11
+ aria-hidden="true"
12
+ className="inline-block align-text-top ml-0.5 text-[0.85em] opacity-70"
13
+ />
14
+ );
15
+ }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import Link from 'next/link';
5
5
  import CoverImage from './CoverImage';
6
6
  import { useLanguage } from './LanguageProvider';
7
- import { shuffle, shuffleSeeded } from '@/lib/shuffle';
7
+ import { shuffle } from '@/lib/shuffle';
8
+ import { byDateAsc, byDateDesc } from '@/lib/sort';
8
9
  import { getPostUrl } from '@/lib/urls';
9
10
 
10
11
  export interface FeaturedPost {
@@ -14,62 +15,82 @@ export interface FeaturedPost {
14
15
  excerpt: string;
15
16
  date: string;
16
17
  category: string;
17
- readingTime: string;
18
+ readingMinutes: number;
18
19
  coverImage?: string;
19
20
  series?: string;
20
21
  pinned?: boolean;
21
22
  }
22
23
 
24
+ type PostOrder = 'shuffle' | 'date-desc' | 'date-asc';
25
+
23
26
  interface FeaturedStoriesSectionProps {
24
27
  allFeatured: FeaturedPost[];
25
28
  maxItems: number;
29
+ order?: PostOrder;
30
+ }
31
+
32
+ function canonicalOrder(posts: FeaturedPost[], order: PostOrder): FeaturedPost[] {
33
+ if (order === 'date-desc') return [...posts].sort(byDateDesc);
34
+ if (order === 'date-asc') return [...posts].sort(byDateAsc);
35
+ return posts;
26
36
  }
27
37
 
28
- function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number, shuffledNonPinned: FeaturedPost[]): FeaturedPost[] {
38
+ function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number, orderedNonPinned: FeaturedPost[]): FeaturedPost[] {
29
39
  const pinned = allFeatured.filter(p => p.pinned);
30
- const nonPinned = allFeatured.filter(p => !p.pinned);
31
40
 
32
- const hero = pinned[0] ?? nonPinned[0];
41
+ const hero = pinned[0] ?? orderedNonPinned[0];
33
42
  if (!hero) return [];
34
43
 
35
44
  const maxSecondaries = maxItems - 1;
36
45
  const fixedSecondaries = pinned.slice(1, maxSecondaries + 1); // cap to available secondary slots
37
- const shuffleSlots = Math.max(0, maxSecondaries - fixedSecondaries.length);
46
+ const fillSlots = Math.max(0, maxSecondaries - fixedSecondaries.length);
38
47
 
39
48
  // Non-pinned pool excludes the hero if the hero is non-pinned
40
49
  const heroIsNonPinned = !hero.pinned;
41
- const shufflePool = heroIsNonPinned ? nonPinned.filter(p => p.slug !== hero.slug) : nonPinned;
42
- const shuffledSlice = shuffledNonPinned.filter(p => shufflePool.some(q => q.slug === p.slug)).slice(0, shuffleSlots);
50
+ const fillPool = heroIsNonPinned ? orderedNonPinned.filter(p => p.slug !== hero.slug) : orderedNonPinned;
51
+ const fillSlice = fillPool.slice(0, fillSlots);
43
52
 
44
- return [hero, ...fixedSecondaries, ...shuffledSlice];
53
+ return [hero, ...fixedSecondaries, ...fillSlice];
45
54
  }
46
55
 
47
- export default function FeaturedStoriesSection({ allFeatured, maxItems }: FeaturedStoriesSectionProps) {
56
+ export default function FeaturedStoriesSection({ allFeatured, maxItems, order = 'shuffle' }: FeaturedStoriesSectionProps) {
48
57
  const { t } = useLanguage();
49
58
 
50
59
  const nonPinned = allFeatured.filter(p => !p.pinned);
51
60
 
52
- // Use a daily seed so SSR and client hydration agree on the initial order,
53
- // preventing a visible reshuffle flash on page load.
54
- const [shuffledNonPinned, setShuffledNonPinned] = useState<FeaturedPost[]>(() => {
55
- const dailySeed = Math.floor(Date.now() / 86400000);
56
- return shuffleSeeded(nonPinned, dailySeed);
57
- });
61
+ // SSR renders the canonical input order so server and client agree on first paint.
62
+ // For 'shuffle', the post-mount useEffect swaps to a fresh random permutation,
63
+ // so every reload re-rolls without any hydration mismatch.
64
+ const [orderedNonPinned, setOrderedNonPinned] = useState<FeaturedPost[]>(() => canonicalOrder(nonPinned, order));
65
+
66
+ // Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
67
+ // post-hydration swap is the intentional client-only behaviour, not a sync issue.
68
+ useEffect(() => {
69
+ if (order === 'shuffle') {
70
+ // eslint-disable-next-line react-hooks/set-state-in-effect
71
+ setOrderedNonPinned(shuffle(nonPinned));
72
+ }
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [allFeatured, order]);
58
75
 
59
76
  const handleShuffle = useCallback(() => {
60
- setShuffledNonPinned(shuffle(nonPinned));
77
+ setOrderedNonPinned(shuffle(nonPinned));
61
78
  // eslint-disable-next-line react-hooks/exhaustive-deps
62
79
  }, [allFeatured]);
63
80
 
64
- const displayed = buildDisplayed(allFeatured, maxItems, shuffledNonPinned);
81
+ const displayed = buildDisplayed(allFeatured, maxItems, orderedNonPinned);
65
82
 
66
83
  if (displayed.length === 0) return null;
67
84
 
68
- // Show shuffle button only when there are more non-pinned posts than available shuffle slots
85
+ // Show shuffle button only when shuffling AND there's at least one non-pinned slot
86
+ // AND there are more non-pinned posts than available slots
69
87
  const pinned = allFeatured.filter(p => p.pinned);
70
88
  const fixedCount = 1 + Math.min(pinned.slice(1).length, maxItems - 1);
71
89
  const shuffleSlots = Math.max(0, maxItems - fixedCount);
72
- const canShuffle = nonPinned.length > shuffleSlots + (pinned.length === 0 ? 1 : 0);
90
+ const canShuffle =
91
+ order === 'shuffle'
92
+ && shuffleSlots > 0
93
+ && nonPinned.length > shuffleSlots + (pinned.length === 0 ? 1 : 0);
73
94
 
74
95
  const [hero, ...secondary] = displayed;
75
96
 
@@ -110,7 +131,7 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems }: Featur
110
131
  <div className="flex items-center gap-2 text-xs font-mono text-white/60 mb-3 overflow-hidden">
111
132
  <span className="text-accent uppercase tracking-wider truncate min-w-0">{hero.category}</span>
112
133
  <span className="shrink-0">·</span>
113
- <span className="shrink-0 whitespace-nowrap">{hero.readingTime}</span>
134
+ <span className="shrink-0 whitespace-nowrap">{hero.readingMinutes} {t('reading_time')}</span>
114
135
  <span className="shrink-0">·</span>
115
136
  <span className="shrink-0 whitespace-nowrap">{hero.date}</span>
116
137
  </div>
@@ -141,7 +162,7 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems }: Featur
141
162
  <div className="flex items-center gap-2 text-xs font-mono text-muted mb-2">
142
163
  <span className="text-accent uppercase tracking-wider truncate max-w-[4rem]">{post.category}</span>
143
164
  <span className="shrink-0 hidden sm:inline">·</span>
144
- <span className="shrink-0 hidden sm:inline">{post.readingTime}</span>
165
+ <span className="shrink-0 hidden sm:inline">{post.readingMinutes} {t('reading_time')}</span>
145
166
  <span className="shrink-0">·</span>
146
167
  <span className="shrink-0">{post.date}</span>
147
168
  </div>
@@ -11,7 +11,7 @@ export default function Footer() {
11
11
  const { t, language } = useLanguage();
12
12
 
13
13
  return (
14
- <footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
14
+ <footer data-site-footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
15
15
  <div className="max-w-6xl mx-auto px-6 py-10 lg:py-16">
16
16
  <div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12 mb-10 lg:mb-12">
17
17
  {/* Brand */}
@@ -0,0 +1,97 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ type AlertType = 'note' | 'tip' | 'important' | 'warning' | 'caution';
4
+
5
+ const KNOWN_TYPES: ReadonlySet<AlertType> = new Set(['note', 'tip', 'important', 'warning', 'caution']);
6
+
7
+ const ALERT_LABELS: Record<AlertType, string> = {
8
+ note: 'Note',
9
+ tip: 'Tip',
10
+ important: 'Important',
11
+ warning: 'Warning',
12
+ caution: 'Caution',
13
+ };
14
+
15
+ interface GithubAlertProps {
16
+ 'data-alert-type'?: string;
17
+ /** Custom title from the directive label (e.g. `:::tip 智慧的疆界`). Falls back to ALERT_LABELS when absent. */
18
+ 'data-alert-title'?: string;
19
+ children?: ReactNode;
20
+ }
21
+
22
+ function AlertIcon({ type }: { type: AlertType }) {
23
+ const common = {
24
+ width: 16,
25
+ height: 16,
26
+ viewBox: '0 0 24 24',
27
+ fill: 'none',
28
+ stroke: 'currentColor',
29
+ strokeWidth: 2,
30
+ strokeLinecap: 'round' as const,
31
+ strokeLinejoin: 'round' as const,
32
+ 'aria-hidden': true,
33
+ };
34
+ switch (type) {
35
+ case 'note':
36
+ return (
37
+ <svg {...common}>
38
+ <circle cx="12" cy="12" r="10" />
39
+ <path d="M12 16v-4" />
40
+ <path d="M12 8h.01" />
41
+ </svg>
42
+ );
43
+ case 'tip':
44
+ return (
45
+ <svg {...common}>
46
+ <path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
47
+ <path d="M9 18h6" />
48
+ <path d="M10 22h4" />
49
+ </svg>
50
+ );
51
+ case 'important':
52
+ return (
53
+ <svg {...common}>
54
+ <path d="M21 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
55
+ <path d="M12 8v4" />
56
+ <path d="M12 16h.01" />
57
+ </svg>
58
+ );
59
+ case 'warning':
60
+ return (
61
+ <svg {...common}>
62
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
63
+ <path d="M12 9v4" />
64
+ <path d="M12 17h.01" />
65
+ </svg>
66
+ );
67
+ case 'caution':
68
+ return (
69
+ <svg {...common}>
70
+ <polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
71
+ <path d="M12 8v4" />
72
+ <path d="M12 16h.01" />
73
+ </svg>
74
+ );
75
+ }
76
+ }
77
+
78
+ export default function GithubAlert(props: GithubAlertProps) {
79
+ const raw = (props['data-alert-type'] ?? '').toLowerCase();
80
+ if (!KNOWN_TYPES.has(raw as AlertType)) {
81
+ // Defensive: an unrecognized type means the plugin matched but we're missing
82
+ // a mapping. Fall through to a plain blockquote so content still renders.
83
+ return <blockquote>{props.children}</blockquote>;
84
+ }
85
+ const type = raw as AlertType;
86
+ const customTitle = props['data-alert-title']?.trim();
87
+ const title = customTitle && customTitle.length > 0 ? customTitle : ALERT_LABELS[type];
88
+ return (
89
+ <aside className={`alert alert-${type}`} role="note" aria-label={title}>
90
+ <div className="alert-title">
91
+ <AlertIcon type={type} />
92
+ <span>{title}</span>
93
+ </div>
94
+ <div className="alert-body">{props.children}</div>
95
+ </aside>
96
+ );
97
+ }