@hutusi/amytis 1.14.0 → 1.15.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 (63) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +33 -1
  5. package/README.zh.md +33 -1
  6. package/TODO.md +10 -0
  7. package/bun.lock +69 -41
  8. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  9. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  10. package/content/series/rst-legacy/getting-started.rst +24 -0
  11. package/content/series/rst-legacy/index.rst +9 -0
  12. package/content/series/rst-readme/README.rst +9 -0
  13. package/content/series/rst-readme/readme-index-post.rst +10 -0
  14. package/content/series/rst-toctree/first-post.rst +6 -0
  15. package/content/series/rst-toctree/index.rst +10 -0
  16. package/content/series/rst-toctree/second-post.rst +6 -0
  17. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  18. package/content/series/rst-toctree-precedence/index.rst +12 -0
  19. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  20. package/docs/ARCHITECTURE.md +22 -3
  21. package/docs/CONTRIBUTING.md +11 -0
  22. package/eslint.config.mjs +2 -0
  23. package/next.config.ts +2 -2
  24. package/package.json +22 -16
  25. package/packages/create-amytis/package.json +1 -1
  26. package/packages/create-amytis/src/index.test.ts +43 -1
  27. package/packages/create-amytis/src/index.ts +64 -8
  28. package/public/next-image-export-optimizer-hashes.json +14 -73
  29. package/scripts/build-pagefind.ts +172 -0
  30. package/scripts/copy-assets.ts +246 -56
  31. package/scripts/generate-knowledge-graph.ts +2 -1
  32. package/scripts/render-rst.py +719 -0
  33. package/scripts/run-with-rst-python.ts +42 -0
  34. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  35. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  36. package/src/app/globals.css +165 -0
  37. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  38. package/src/app/series/[slug]/page.tsx +11 -13
  39. package/src/app/series/page.tsx +3 -3
  40. package/src/components/AuthorCard.tsx +25 -16
  41. package/src/components/CoverImage.tsx +5 -2
  42. package/src/components/MarkdownRenderer.test.tsx +16 -0
  43. package/src/components/MarkdownRenderer.tsx +4 -1
  44. package/src/components/RstRenderer.test.tsx +93 -0
  45. package/src/components/RstRenderer.tsx +122 -0
  46. package/src/layouts/PostLayout.tsx +5 -1
  47. package/src/layouts/SimpleLayout.tsx +10 -3
  48. package/src/lib/image-utils.test.ts +19 -0
  49. package/src/lib/image-utils.ts +11 -0
  50. package/src/lib/markdown.test.ts +140 -2
  51. package/src/lib/markdown.ts +731 -210
  52. package/src/lib/rehype-image-metadata.ts +2 -2
  53. package/src/lib/rst-renderer.test.ts +355 -0
  54. package/src/lib/rst-renderer.ts +617 -0
  55. package/src/lib/rst.test.ts +140 -0
  56. package/src/lib/rst.ts +470 -0
  57. package/src/lib/series-redirects.ts +42 -0
  58. package/tests/integration/feed-utils.test.ts +13 -0
  59. package/tests/integration/reading-time-headings.test.ts +5 -9
  60. package/tests/integration/series-draft.test.ts +16 -2
  61. package/tests/integration/series.test.ts +93 -0
  62. package/tests/tooling/build-pagefind.test.ts +66 -0
  63. package/tests/unit/static-params.test.ts +140 -0
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ const [command, ...args] = Bun.argv.slice(2);
6
+
7
+ if (!command) {
8
+ console.error('Missing command to run.');
9
+ process.exit(1);
10
+ }
11
+
12
+ const env = { ...process.env };
13
+ if (!env.AMYTIS_RST_PYTHON) {
14
+ const localPython = path.join(
15
+ process.cwd(),
16
+ '.venv-rst',
17
+ process.platform === 'win32' ? 'Scripts' : 'bin',
18
+ process.platform === 'win32' ? 'python.exe' : 'python',
19
+ );
20
+ if (fs.existsSync(localPython)) {
21
+ env.AMYTIS_RST_PYTHON = localPython;
22
+ }
23
+ }
24
+
25
+ const child = spawn(command, args, {
26
+ stdio: 'inherit',
27
+ env,
28
+ shell: process.platform === 'win32',
29
+ });
30
+
31
+ child.on('exit', (code, signal) => {
32
+ if (signal) {
33
+ process.kill(process.pid, signal);
34
+ return;
35
+ }
36
+ process.exit(code ?? 1);
37
+ });
38
+
39
+ child.on('error', (error) => {
40
+ console.error(error.message);
41
+ process.exit(1);
42
+ });
@@ -67,17 +67,26 @@ export async function generateStaticParams() {
67
67
  }
68
68
  }
69
69
 
70
- // Work around Next dev static-param checks for percent-encoded Unicode postSlugs
71
- // under `output: "export"` — dev server may receive percent-encoded forms of Unicode paths.
70
+ // Work around Next dev static-param checks for percent-encoded Unicode slugs
71
+ // under `output: "export"` — dev server may receive encoded forms of either segment.
72
72
  // Include encoded variants in development only; production export keeps raw segment values.
73
73
  if (process.env.NODE_ENV !== 'production') {
74
74
  const existing = new Set(params.map(p => `${p.slug}/${p.postSlug}`));
75
75
  for (const p of [...params]) {
76
+ const encodedSlug = encodeURIComponent(p.slug);
76
77
  const encodedPostSlug = encodeURIComponent(p.postSlug);
77
- const key = `${p.slug}/${encodedPostSlug}`;
78
- if (!existing.has(key)) {
79
- existing.add(key);
80
- params.push({ slug: p.slug, postSlug: encodedPostSlug });
78
+ const variants = [
79
+ { slug: p.slug, postSlug: encodedPostSlug },
80
+ { slug: encodedSlug, postSlug: p.postSlug },
81
+ { slug: encodedSlug, postSlug: encodedPostSlug },
82
+ ];
83
+
84
+ for (const variant of variants) {
85
+ const key = `${variant.slug}/${variant.postSlug}`;
86
+ if (!existing.has(key)) {
87
+ existing.add(key);
88
+ params.push(variant);
89
+ }
81
90
  }
82
91
  }
83
92
  }
@@ -154,7 +163,8 @@ export default async function PrefixPostPage({
154
163
  params: Promise<{ slug: string; postSlug: string }>;
155
164
  }) {
156
165
  const { slug: prefix, postSlug: rawPostSlug } = await params;
157
- const currentPath = `/${safeDecodeParam(prefix)}/${safeDecodeParam(rawPostSlug)}`;
166
+ const decodedPrefix = safeDecodeParam(prefix);
167
+ const currentPath = `/${decodedPrefix}/${safeDecodeParam(rawPostSlug)}`;
158
168
 
159
169
  // Resolve the post: first by slug, then fall back to redirectFrom lookup for renamed slugs.
160
170
  const post =
@@ -168,9 +178,9 @@ export default async function PrefixPostPage({
168
178
  // or a legacy redirectFrom path declared on the resolved post.
169
179
  const basePath = getPostsBasePath();
170
180
  const customPaths = getSeriesCustomPaths();
171
- const isValidBasePath = prefix === basePath && basePath !== 'posts';
172
- const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
173
- const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths, prefix) && getSeriesData(prefix) !== null;
181
+ const isValidBasePath = decodedPrefix === basePath && basePath !== 'posts';
182
+ const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === decodedPrefix)?.[0];
183
+ const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths, decodedPrefix) && getSeriesData(decodedPrefix) !== null;
174
184
  const isLegacyRedirect = post.redirectFrom?.includes(currentPath) ?? false;
175
185
 
176
186
  if (!isValidBasePath && !matchedSeriesSlug && !isAutoSeriesPath && !isLegacyRedirect) {
@@ -57,6 +57,21 @@ export async function generateStaticParams() {
57
57
  }
58
58
  }
59
59
 
60
+ // Work around Next dev static-param checks for percent-encoded Unicode slugs
61
+ // under `output: "export"` — dev server may receive encoded forms of the
62
+ // prefix segment for paginated listings.
63
+ if (process.env.NODE_ENV !== 'production') {
64
+ const existing = new Set(params.map(p => `${p.slug}/${p.page}`));
65
+ for (const p of [...params]) {
66
+ const encodedSlug = encodeURIComponent(p.slug);
67
+ const key = `${encodedSlug}/${p.page}`;
68
+ if (!existing.has(key)) {
69
+ existing.add(key);
70
+ params.push({ slug: encodedSlug, page: p.page });
71
+ }
72
+ }
73
+ }
74
+
60
75
  // Placeholder keeps Next.js happy with output: export when no custom paths configured.
61
76
  // dynamicParams = false ensures any unrecognised slug/page combo returns 404.
62
77
  return params.length > 0 ? params : [{ slug: '_', page: '2' }];
@@ -451,3 +451,168 @@ body {
451
451
  [id] {
452
452
  scroll-margin-top: 5rem;
453
453
  }
454
+
455
+ .rst-rendered figure {
456
+ margin: 2rem 0;
457
+ }
458
+
459
+ .rst-rendered figure > img {
460
+ display: block;
461
+ width: 100%;
462
+ height: auto;
463
+ border-radius: 0.75rem;
464
+ }
465
+
466
+ .rst-rendered figcaption {
467
+ margin-top: 0.75rem;
468
+ text-align: center;
469
+ font-size: 0.95rem;
470
+ line-height: 1.6;
471
+ color: var(--muted);
472
+ font-style: italic;
473
+ }
474
+
475
+ .rst-rendered aside.admonition {
476
+ margin: 2rem 0;
477
+ border-left: 4px solid var(--accent);
478
+ border-radius: 0.75rem;
479
+ padding: 1rem 1.25rem;
480
+ background: color-mix(in srgb, var(--accent) 8%, var(--background));
481
+ }
482
+
483
+ .rst-rendered aside.admonition > :first-child {
484
+ margin-top: 0;
485
+ }
486
+
487
+ .rst-rendered aside.admonition > :last-child {
488
+ margin-bottom: 0;
489
+ }
490
+
491
+ .rst-rendered .admonition-title {
492
+ margin-bottom: 0.5rem;
493
+ font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
494
+ font-size: 0.8rem;
495
+ font-weight: 700;
496
+ letter-spacing: 0.08em;
497
+ text-transform: uppercase;
498
+ color: var(--heading);
499
+ }
500
+
501
+ .rst-rendered .docutils.literal {
502
+ border: 1px solid color-mix(in srgb, var(--muted) 24%, transparent);
503
+ border-radius: 0.375rem;
504
+ background: color-mix(in srgb, var(--muted) 10%, var(--background));
505
+ padding: 0.1rem 0.35rem;
506
+ font-size: 0.9em;
507
+ color: var(--heading);
508
+ }
509
+
510
+ .rst-rendered .dtag {
511
+ display: inline-block;
512
+ border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
513
+ border-radius: 999px;
514
+ background: color-mix(in srgb, var(--accent) 10%, var(--background));
515
+ padding: 0.1rem 0.55rem;
516
+ font-size: 0.78rem;
517
+ font-weight: 600;
518
+ color: var(--accent);
519
+ }
520
+
521
+ .rst-rendered a[href]:not([role="doc-noteref"]) {
522
+ color: var(--accent);
523
+ text-decoration: none;
524
+ transition: color 160ms ease, text-decoration-color 160ms ease;
525
+ }
526
+
527
+ .rst-rendered a[href]:not([role="doc-noteref"]):hover,
528
+ .rst-rendered a[href]:not([role="doc-noteref"]):focus-visible {
529
+ color: var(--accent);
530
+ text-decoration: underline;
531
+ }
532
+
533
+ .rst-rendered .numref {
534
+ color: inherit;
535
+ font-weight: inherit;
536
+ }
537
+
538
+ .rst-rendered .rst-table-wrapper {
539
+ overflow-x: auto;
540
+ margin: 2rem 0;
541
+ border: 1px solid color-mix(in srgb, var(--muted) 20%, transparent);
542
+ border-radius: 0.5rem;
543
+ }
544
+
545
+ .rst-rendered .rst-table-wrapper > table {
546
+ min-width: 100%;
547
+ margin: 0;
548
+ font-size: 0.875rem;
549
+ }
550
+
551
+ .rst-rendered math {
552
+ display: inline-block;
553
+ max-width: 100%;
554
+ overflow-x: auto;
555
+ vertical-align: middle;
556
+ }
557
+
558
+ .rst-rendered pre.literal-block,
559
+ .rst-rendered pre.code.literal-block {
560
+ overflow-x: auto;
561
+ border: 1px solid color-mix(in srgb, var(--muted) 28%, transparent);
562
+ border-radius: 0.875rem;
563
+ background: color-mix(in srgb, var(--heading) 7%, var(--background));
564
+ padding: 1rem 1.25rem;
565
+ color: var(--heading);
566
+ font-size: 0.95rem;
567
+ line-height: 1.7;
568
+ box-shadow: inset 0 1px 0 color-mix(in srgb, white 60%, transparent);
569
+ }
570
+
571
+ .dark .rst-rendered pre.literal-block,
572
+ .dark .rst-rendered pre.code.literal-block {
573
+ border-color: color-mix(in srgb, var(--muted) 22%, transparent);
574
+ background: color-mix(in srgb, var(--foreground) 9%, var(--background));
575
+ color: var(--foreground);
576
+ box-shadow: inset 0 1px 0 color-mix(in srgb, white 5%, transparent);
577
+ }
578
+
579
+ .rst-rendered pre.code.literal-block code {
580
+ border: 0;
581
+ background: transparent;
582
+ padding: 0;
583
+ color: inherit;
584
+ }
585
+
586
+ .rst-rendered pre.code.literal-block .keyword,
587
+ .rst-rendered pre.code.literal-block .name.function {
588
+ color: var(--accent);
589
+ font-weight: 600;
590
+ }
591
+
592
+ .rst-rendered pre.code.literal-block .literal.string,
593
+ .rst-rendered pre.code.literal-block .literal.string.double {
594
+ color: color-mix(in srgb, var(--accent) 68%, var(--foreground));
595
+ }
596
+
597
+ .rst-rendered a[role="doc-noteref"] {
598
+ font-size: 0.82em;
599
+ vertical-align: super;
600
+ }
601
+
602
+ .rst-rendered .footnote-list {
603
+ margin: 1.5rem 0 2rem;
604
+ border-top: 1px solid color-mix(in srgb, var(--muted) 24%, transparent);
605
+ padding-top: 1rem;
606
+ }
607
+
608
+ .rst-rendered .footnote {
609
+ margin: 0.75rem 0;
610
+ color: var(--muted);
611
+ font-size: 0.95rem;
612
+ }
613
+
614
+ .rst-rendered .footnote .label {
615
+ margin-right: 0.4rem;
616
+ font-weight: 600;
617
+ color: var(--heading);
618
+ }
@@ -7,22 +7,74 @@ import { siteConfig } from '../../../../../../site.config';
7
7
  import CoverImage from '@/components/CoverImage';
8
8
  import Link from 'next/link';
9
9
  import { t, resolveLocale, tWith } from '@/lib/i18n';
10
+ import { getSeriesListUrl } from '@/lib/urls';
11
+ import RedirectPage from '@/components/RedirectPage';
12
+ import { findSeriesByRedirectFrom, safeDecodeParam } from '@/lib/series-redirects';
10
13
 
11
14
  const PAGE_SIZE = siteConfig.pagination.series;
12
15
 
13
16
  export async function generateStaticParams() {
14
17
  const allSeries = getAllSeries();
18
+ const seriesBasePath = getSeriesListUrl();
19
+ const seen = new Set<string>();
20
+ const reservedSlugs = new Set(Object.keys(allSeries));
21
+ const claimedAliases = new Map<string, string>();
15
22
  const params: { slug: string; page: string }[] = [];
16
-
23
+ const pushParam = (slug: string, page: string) => {
24
+ const key = `${slug}:${page}`;
25
+ if (seen.has(key)) return;
26
+ seen.add(key);
27
+ params.push({ slug, page });
28
+ };
29
+
17
30
  Object.keys(allSeries).forEach(slug => {
18
31
  const posts = allSeries[slug];
19
32
  const totalPages = Math.ceil(posts.length / PAGE_SIZE);
20
33
  if (totalPages > 1) {
21
- for (let i = 2; i <= totalPages; i++) {
22
- params.push({ slug, page: i.toString() });
23
- }
34
+ for (let i = 2; i <= totalPages; i++) {
35
+ pushParam(slug, i.toString());
36
+ }
37
+ }
38
+
39
+ const data = getSeriesData(slug);
40
+ for (const from of data?.redirectFrom ?? []) {
41
+ const segments = from.split('/').filter(Boolean);
42
+ const expectedBase = seriesBasePath.replace(/^\/+|\/+$/g, '');
43
+ if (segments.length !== 2 || segments[0] !== expectedBase) continue;
44
+ const aliasSlug = segments[1];
45
+ if (aliasSlug === slug || totalPages <= 1) continue;
46
+ const claimedBy = claimedAliases.get(aliasSlug);
47
+ if (claimedBy && claimedBy !== slug) {
48
+ throw new Error(
49
+ `[amytis] series redirectFrom alias "${from}" is claimed by both "${claimedBy}" and "${slug}".`
50
+ );
51
+ }
52
+ if (!claimedBy && reservedSlugs.has(aliasSlug)) {
53
+ throw new Error(
54
+ `[amytis] series redirectFrom alias "${from}" for "${slug}" conflicts with an existing series slug.`
55
+ );
56
+ }
57
+ claimedAliases.set(aliasSlug, slug);
58
+ reservedSlugs.add(aliasSlug);
59
+ for (let i = 2; i <= totalPages; i++) {
60
+ pushParam(aliasSlug, i.toString());
61
+ }
24
62
  }
25
63
  });
64
+
65
+ if (process.env.NODE_ENV !== 'production') {
66
+ const encodedParams = params
67
+ .filter(param => encodeURIComponent(param.slug) !== param.slug)
68
+ .map(param => ({ ...param, slug: encodeURIComponent(param.slug) }))
69
+ .filter(param => {
70
+ const key = `${param.slug}:${param.page}`;
71
+ if (seen.has(key)) return false;
72
+ seen.add(key);
73
+ return true;
74
+ });
75
+ params.push(...encodedParams);
76
+ }
77
+
26
78
  if (params.length === 0) return [{ slug: '_', page: '2' }];
27
79
  return params;
28
80
  }
@@ -31,7 +83,17 @@ export const dynamicParams = false;
31
83
 
32
84
  export async function generateMetadata({ params }: { params: Promise<{ slug: string; page: string }> }): Promise<Metadata> {
33
85
  const { slug: rawSlug, page } = await params;
34
- const slug = decodeURIComponent(rawSlug);
86
+ const slug = safeDecodeParam(rawSlug);
87
+ const currentPath = `${getSeriesListUrl()}/${slug}`;
88
+ const redirect = findSeriesByRedirectFrom(currentPath);
89
+ if (redirect) {
90
+ const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
91
+ return {
92
+ title: redirect.data.title,
93
+ alternates: { canonical: `${siteUrl}${getSeriesListUrl()}/${redirect.slug}/page/${page}` },
94
+ };
95
+ }
96
+
35
97
  const seriesData = getSeriesData(slug);
36
98
  const title = seriesData?.title || slug;
37
99
  const allPosts = seriesData?.type === 'collection' ? getCollectionPosts(slug) : getSeriesPosts(slug);
@@ -43,8 +105,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
43
105
 
44
106
  export default async function SeriesPage({ params }: { params: Promise<{ slug: string; page: string }> }) {
45
107
  const { slug: rawSlug, page: pageStr } = await params;
46
- const slug = decodeURIComponent(rawSlug);
108
+ const slug = safeDecodeParam(rawSlug);
47
109
  const page = parseInt(pageStr);
110
+ const currentPath = `${getSeriesListUrl()}/${slug}`;
111
+ const redirect = findSeriesByRedirectFrom(currentPath);
112
+ if (redirect) {
113
+ return <RedirectPage to={`${getSeriesListUrl()}/${redirect.slug}/page/${page}`} />;
114
+ }
115
+
48
116
  const seriesData = getSeriesData(slug);
49
117
  const isCollection = seriesData?.type === 'collection';
50
118
  const allPosts = isCollection ? getCollectionPosts(slug) : getSeriesPosts(slug);
@@ -9,20 +9,10 @@ import Link from 'next/link';
9
9
  import { t, resolveLocale } from '@/lib/i18n';
10
10
  import { getPostUrl, getPostUrlInCollection } from '@/lib/urls';
11
11
  import RedirectPage from '@/components/RedirectPage';
12
+ import { findSeriesByRedirectFrom, safeDecodeParam } from '@/lib/series-redirects';
12
13
 
13
14
  const PAGE_SIZE = siteConfig.pagination.series;
14
15
 
15
- /** Returns the series whose index.mdx lists `path` in its redirectFrom array, or null. */
16
- function findSeriesByRedirectFrom(path: string) {
17
- for (const seriesSlug of Object.keys(getAllSeries())) {
18
- const data = getSeriesData(seriesSlug);
19
- if (data?.redirectFrom?.includes(path)) {
20
- return { slug: seriesSlug, data };
21
- }
22
- }
23
- return null;
24
- }
25
-
26
16
  export async function generateStaticParams() {
27
17
  const allSeries = getAllSeries();
28
18
  const slugs = new Set(Object.keys(allSeries));
@@ -38,6 +28,14 @@ export async function generateStaticParams() {
38
28
  }
39
29
  }
40
30
 
31
+ // Work around Next dev static-param checks for percent-encoded Unicode paths
32
+ // under `output: "export"` — dev server may receive encoded forms of Unicode slugs.
33
+ if (process.env.NODE_ENV !== 'production') {
34
+ for (const slug of [...slugs]) {
35
+ slugs.add(encodeURIComponent(slug));
36
+ }
37
+ }
38
+
41
39
  if (slugs.size === 0) return [{ slug: '_' }];
42
40
  return Array.from(slugs).map((slug) => ({ slug }));
43
41
  }
@@ -46,7 +44,7 @@ export const dynamicParams = false;
46
44
 
47
45
  export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
48
46
  const { slug: rawSlug } = await params;
49
- const slug = decodeURIComponent(rawSlug);
47
+ const slug = safeDecodeParam(rawSlug);
50
48
  const currentPath = `/series/${slug}`;
51
49
 
52
50
  const redirect = findSeriesByRedirectFrom(currentPath);
@@ -98,7 +96,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
98
96
 
99
97
  export default async function SeriesPage({ params }: { params: Promise<{ slug: string }> }) {
100
98
  const { slug: rawSlug } = await params;
101
- const slug = decodeURIComponent(rawSlug);
99
+ const slug = safeDecodeParam(rawSlug);
102
100
  const currentPath = `/series/${slug}`;
103
101
 
104
102
  const redirect = findSeriesByRedirectFrom(currentPath);
@@ -1,4 +1,4 @@
1
- import { getAllSeries, getSeriesData, resolveSeriesAuthors } from '@/lib/markdown';
1
+ import { getAllSeries, getSeriesData, getSeriesLatestPostDate, resolveSeriesAuthors } from '@/lib/markdown';
2
2
  import Link from 'next/link';
3
3
  import { siteConfig } from '../../../site.config';
4
4
  import { Metadata } from 'next';
@@ -20,8 +20,8 @@ export default function SeriesIndexPage() {
20
20
 
21
21
  // Sort by most recent post date (active series first)
22
22
  const seriesSlugs = Object.keys(allSeries).sort((a, b) => {
23
- const latestA = allSeries[a][0]?.date || '';
24
- const latestB = allSeries[b][0]?.date || '';
23
+ const latestA = getSeriesLatestPostDate(a);
24
+ const latestB = getSeriesLatestPostDate(b);
25
25
  return latestB.localeCompare(latestA);
26
26
  });
27
27
 
@@ -3,6 +3,7 @@ import ExportedImage from 'next-image-export-optimizer';
3
3
  import { getAuthorSlug } from '@/lib/markdown';
4
4
  import { siteConfig } from '../../site.config';
5
5
  import { t } from '@/lib/i18n';
6
+ import { shouldBypassImageOptimization } from '@/lib/image-utils';
6
7
 
7
8
  const isDev = process.env.NODE_ENV === 'development';
8
9
  const isExternal = (src: string) => src.startsWith('http') || src.startsWith('//');
@@ -16,6 +17,9 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
16
17
  const slug = getAuthorSlug(author);
17
18
  const profile = siteConfig.authors?.[author];
18
19
  const hasSocial = profile?.social && profile.social.length > 0;
20
+ const avatarBypassOptimization = Boolean(
21
+ profile?.avatar && (isDev || isExternal(profile.avatar) || shouldBypassImageOptimization(profile.avatar))
22
+ );
19
23
 
20
24
  return (
21
25
  <div
@@ -31,7 +35,8 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
31
35
  width={56}
32
36
  height={56}
33
37
  className="w-14 h-14 rounded-full object-cover flex-shrink-0 ring-2 ring-muted/20"
34
- unoptimized={isDev || isExternal(profile.avatar)}
38
+ unoptimized={avatarBypassOptimization}
39
+ placeholder={avatarBypassOptimization ? 'empty' : 'blur'}
35
40
  />
36
41
  ) : (
37
42
  <div className="w-14 h-14 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0 text-accent font-serif font-bold text-2xl select-none">
@@ -60,21 +65,25 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
60
65
  {/* Right — social images (e.g. QR codes) */}
61
66
  {hasSocial && (
62
67
  <div className="flex justify-center gap-5 flex-shrink-0 border-t border-muted/15 pt-4 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-6 sm:justify-start">
63
- {profile.social!.map((item, index) => (
64
- <figure key={index} className="flex flex-col items-center gap-1.5">
65
- <ExportedImage
66
- src={item.image}
67
- alt={item.description}
68
- width={72}
69
- height={72}
70
- className="w-[72px] h-[72px] object-contain rounded-lg bg-white p-0.5"
71
- unoptimized={isDev || isExternal(item.image)}
72
- />
73
- <figcaption className="text-[10px] font-sans text-muted text-center leading-tight max-w-[72px]">
74
- {item.description}
75
- </figcaption>
76
- </figure>
77
- ))}
68
+ {profile.social!.map((item, index) => {
69
+ const socialImageBypassOptimization = isDev || isExternal(item.image) || shouldBypassImageOptimization(item.image);
70
+ return (
71
+ <figure key={index} className="flex flex-col items-center gap-1.5">
72
+ <ExportedImage
73
+ src={item.image}
74
+ alt={item.description}
75
+ width={72}
76
+ height={72}
77
+ className="w-[72px] h-[72px] object-contain rounded-lg bg-white p-0.5"
78
+ unoptimized={socialImageBypassOptimization}
79
+ placeholder={socialImageBypassOptimization ? 'empty' : 'blur'}
80
+ />
81
+ <figcaption className="text-[10px] font-sans text-muted text-center leading-tight max-w-[72px]">
82
+ {item.description}
83
+ </figcaption>
84
+ </figure>
85
+ );
86
+ })}
78
87
  </div>
79
88
  )}
80
89
  </div>
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import ExportedImage from 'next-image-export-optimizer';
3
3
  import { siteConfig } from '../../site.config';
4
- import { getCdnImageUrl } from '@/lib/image-utils';
4
+ import { getCdnImageUrl, shouldBypassImageOptimization } from '@/lib/image-utils';
5
5
 
6
6
  // Each palette defines a gradient background and text color for light/dark modes
7
7
  const palettes = [
@@ -88,6 +88,8 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
88
88
  const imageSrc = getCdnImageUrl(src!, cdnBaseUrl);
89
89
  const isCdn = cdnBaseUrl && imageSrc !== src;
90
90
  const isDev = process.env.NODE_ENV === 'development';
91
+ const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
92
+ const useBlurPlaceholder = !(isDev || !!isCdn || shouldBypassOptimization);
91
93
 
92
94
  return (
93
95
  <ExportedImage
@@ -96,7 +98,8 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
96
98
  className={className}
97
99
  fill
98
100
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
99
- unoptimized={isDev || !!isCdn}
101
+ unoptimized={!useBlurPlaceholder}
102
+ placeholder={useBlurPlaceholder ? 'blur' : 'empty'}
100
103
  loading={loading}
101
104
  />
102
105
  );
@@ -24,6 +24,22 @@ describe("MarkdownRenderer", () => {
24
24
  // images as LCP candidates, avoiding "preloaded but not used" warnings
25
25
  expect(html).toContain('fetchPriority="low"');
26
26
  });
27
+
28
+ test("bypasses optimization for local avif images", () => {
29
+ const content = "![alt text](/images/background-new-wave.avif)";
30
+ const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
31
+ expect(html).toContain('src="/images/background-new-wave.avif"');
32
+ expect(html).not.toContain('nextImageExportOptimizer');
33
+ expect(html).not.toContain('background-image:url');
34
+ });
35
+
36
+ test("bypasses optimization for local webp images", () => {
37
+ const content = "![alt text](/images/already-optimized.webp)";
38
+ const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
39
+ expect(html).toContain('src="/images/already-optimized.webp"');
40
+ expect(html).not.toContain('nextImageExportOptimizer');
41
+ expect(html).not.toContain('background-image:url');
42
+ });
27
43
  });
28
44
 
29
45
  test("adds horizontal overflow containment while preserving code scrolling", () => {
@@ -14,6 +14,7 @@ import remarkWikilinks from '@/lib/remark-wikilinks';
14
14
  import ExportedImage from 'next-image-export-optimizer';
15
15
  import { PluggableList } from 'unified';
16
16
  import type { SlugRegistryEntry } from '@/lib/markdown';
17
+ import { shouldBypassImageOptimization } from '@/lib/image-utils';
17
18
 
18
19
 
19
20
  interface MarkdownRendererProps {
@@ -139,6 +140,7 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
139
140
  const isExternal = imageSrc?.startsWith('http') || imageSrc?.startsWith('//');
140
141
 
141
142
  if (!isExternal) {
143
+ const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
142
144
  return (
143
145
  <ExportedImage
144
146
  src={imageSrc || ''}
@@ -147,7 +149,8 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
147
149
  height={height ? Number(height) : 900}
148
150
  className="max-w-full h-auto rounded-lg my-4"
149
151
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 70vw"
150
- unoptimized={isDev}
152
+ unoptimized={isDev || shouldBypassOptimization}
153
+ placeholder={shouldBypassOptimization ? 'empty' : 'blur'}
151
154
  style={(!width || !height) ? { width: '100%', height: 'auto' } : undefined}
152
155
  />
153
156
  );