@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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +16 -0
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +69 -41
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ARCHITECTURE.md +22 -3
- package/docs/CONTRIBUTING.md +11 -0
- package/eslint.config.mjs +2 -0
- package/next.config.ts +2 -2
- package/package.json +22 -16
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/render-rst.py +719 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -0
- package/src/app/globals.css +165 -0
- package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/components/AuthorCard.tsx +25 -16
- package/src/components/CoverImage.tsx +5 -2
- package/src/components/MarkdownRenderer.test.tsx +16 -0
- package/src/components/MarkdownRenderer.tsx +4 -1
- package/src/components/RstRenderer.test.tsx +93 -0
- package/src/components/RstRenderer.tsx +122 -0
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +140 -2
- package/src/lib/markdown.ts +731 -210
- package/src/lib/rehype-image-metadata.ts +2 -2
- package/src/lib/rst-renderer.test.ts +355 -0
- package/src/lib/rst-renderer.ts +617 -0
- package/src/lib/rst.test.ts +140 -0
- package/src/lib/rst.ts +470 -0
- package/src/lib/series-redirects.ts +42 -0
- package/tests/integration/feed-utils.test.ts +13 -0
- package/tests/integration/reading-time-headings.test.ts +5 -9
- package/tests/integration/series-draft.test.ts +16 -2
- package/tests/integration/series.test.ts +93 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- 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
|
|
71
|
-
// under `output: "export"` — dev server may receive
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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 =
|
|
172
|
-
const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path ===
|
|
173
|
-
const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths,
|
|
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' }];
|
package/src/app/globals.css
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
99
|
+
const slug = safeDecodeParam(rawSlug);
|
|
102
100
|
const currentPath = `/series/${slug}`;
|
|
103
101
|
|
|
104
102
|
const redirect = findSeriesByRedirectFrom(currentPath);
|
package/src/app/series/page.tsx
CHANGED
|
@@ -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 =
|
|
24
|
-
const latestB =
|
|
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={
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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={
|
|
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 = "";
|
|
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 = "";
|
|
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
|
);
|