hd-garden 0.14.7 → 0.14.8
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/app/components/FeaturedTitle.tsx +23 -0
- package/app/components/MarkdownArticle.tsx +2 -0
- package/app/components/TableOfContents.tsx +43 -49
- package/app/globals.css +24 -10
- package/app/lib/i18n.ts +8 -0
- package/app/lib/toc-expansion.ts +49 -0
- package/app/page.tsx +2 -2
- package/app/raw.d.ts +5 -0
- package/app/styles/shell.css +3 -2
- package/editors/dist/{garden-typora-0.14.7.zip → garden-typora-0.14.8.zip} +0 -0
- package/editors/dist/typora/manifest.json +1 -1
- package/editors/dist/vscode/{garden-ui-0.14.7.vsix → garden-ui-0.14.8.vsix} +0 -0
- package/package.json +2 -2
- package/scripts/file-preview.mjs +3 -0
- package/ui/garden/feature-title-fit.mjs +95 -0
- package/ui/garden/inline-math.css +19 -0
- package/ui/garden/rehype-keep-punctuation.mjs +47 -0
- package/ui/preview/dist/garden-ui.esm.js +1 -1
- package/ui/preview/dist/garden-ui.iife.js +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useLayoutEffect, useRef } from "react";
|
|
5
|
+
import { mountFeatureTitle } from "../../ui/garden/feature-title-fit.mjs";
|
|
6
|
+
import runtimeSource from "../../ui/garden/feature-title-fit.mjs?raw";
|
|
7
|
+
|
|
8
|
+
// Preserve one authored runtime across SSR/browser builds; function.toString()
|
|
9
|
+
// would be changed independently by server and client optimizers.
|
|
10
|
+
const FEATURE_TITLE_BOOT = `(()=>{${runtimeSource.replace(/^export /gm, "")}\nmountFeatureTitle(document.currentScript.parentElement);})();`;
|
|
11
|
+
|
|
12
|
+
type FeaturedTitleProps = { title: string; href: string; description?: string };
|
|
13
|
+
|
|
14
|
+
export function FeaturedTitle({ title, href, description }: FeaturedTitleProps) {
|
|
15
|
+
const heading = useRef<HTMLHeadingElement>(null);
|
|
16
|
+
useLayoutEffect(() => mountFeatureTitle(heading.current?.parentElement), [title, href, description]);
|
|
17
|
+
return <>
|
|
18
|
+
<h2 id="ft" ref={heading} data-feature-title=""><Link href={href}>{title}</Link></h2>
|
|
19
|
+
{description ? <p data-feature-summary="">{description}</p> : null}
|
|
20
|
+
<style data-feature-title-rules="">{""}</style>
|
|
21
|
+
<script data-feature-title-boot="" dangerouslySetInnerHTML={{ __html: FEATURE_TITLE_BOOT }} />
|
|
22
|
+
</>;
|
|
23
|
+
}
|
|
@@ -15,6 +15,7 @@ import Link from "next/link";
|
|
|
15
15
|
import ReactMarkdown from "react-markdown";
|
|
16
16
|
import rehypeRaw from "rehype-raw";
|
|
17
17
|
import rehypeKatex from "rehype-katex";
|
|
18
|
+
import {rehypeKeepPunctuation} from "../../ui/garden/rehype-keep-punctuation.mjs";
|
|
18
19
|
import rehypeSlug from "rehype-slug";
|
|
19
20
|
import rehypeHighlight from "rehype-highlight";
|
|
20
21
|
import { rehypeHeadingAnchors } from "../../ui/garden/heading-anchors.mjs";
|
|
@@ -254,6 +255,7 @@ export function MarkdownArticle({ content, lang, fieldTables, refDemos, handbook
|
|
|
254
255
|
rehypeRaw,
|
|
255
256
|
rehypeGardenBlocks,
|
|
256
257
|
rehypeKatex,
|
|
258
|
+
rehypeKeepPunctuation,
|
|
257
259
|
rehypeSlug,
|
|
258
260
|
// Not rehype-autolink-headings "wrap": a heading with a link in it would nest <a> in <a> (React #418).
|
|
259
261
|
[rehypeHeadingAnchors, { className: ["h-anchor"] }],
|
|
@@ -4,39 +4,28 @@ import {
|
|
|
4
4
|
useCallback,
|
|
5
5
|
useEffect,
|
|
6
6
|
useId,
|
|
7
|
+
useMemo,
|
|
7
8
|
useRef,
|
|
8
9
|
useState,
|
|
9
10
|
useSyncExternalStore,
|
|
10
11
|
type MouseEvent,
|
|
11
12
|
type ReactNode,
|
|
12
13
|
} from "react";
|
|
14
|
+
import { usePathname } from "next/navigation";
|
|
15
|
+
import { nestHeadings, expandedTocSections, toggleTocSection, type TocHeading, type TocNode, type TocExpansion } from "../lib/toc-expansion";
|
|
13
16
|
import { createPortal } from "react-dom";
|
|
14
17
|
import { TOC_PANEL_ID, TOC_CONTROL_ID, TOC_CLOSED_ID } from "../lib/reading-navigation";
|
|
15
18
|
import { X } from "@phosphor-icons/react";
|
|
16
19
|
import { ui, type UiLang } from "../lib/i18n";
|
|
17
20
|
|
|
18
|
-
type
|
|
21
|
+
type TocProps = { headings: TocHeading[]; label?: string; lang?: UiLang };
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
for (const heading of headings) {
|
|
27
|
-
const node: TocNode = { ...heading, children: [] };
|
|
28
|
-
while (stack.at(-1) && stack.at(-1)!.depth >= heading.depth) {
|
|
29
|
-
stack.pop();
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const parent = stack.at(-1);
|
|
33
|
-
if (parent) parent.children.push(node);
|
|
34
|
-
else roots.push(node);
|
|
35
|
-
stack.push(node);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return roots;
|
|
39
|
-
};
|
|
23
|
+
// A route/content change starts a fresh reading session, including when React
|
|
24
|
+
// reuses the component during normal client navigation.
|
|
25
|
+
export function TableOfContents(props: TocProps) {
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
return <ContentsSession key={`${pathname}:${JSON.stringify(props.headings)}`} {...props} />;
|
|
28
|
+
}
|
|
40
29
|
|
|
41
30
|
const getCssScrollPaddingTop = () => {
|
|
42
31
|
const value = window.getComputedStyle(document.documentElement).scrollPaddingTop;
|
|
@@ -131,22 +120,15 @@ function subscribeControlHost(changed: () => void) {
|
|
|
131
120
|
return () => observer.disconnect();
|
|
132
121
|
}
|
|
133
122
|
|
|
134
|
-
|
|
123
|
+
function ContentsSession({
|
|
135
124
|
headings,
|
|
136
125
|
label,
|
|
137
126
|
lang,
|
|
138
|
-
}: {
|
|
139
|
-
headings: Heading[];
|
|
140
|
-
label?: string;
|
|
141
|
-
/** Defaults to the site language; the bilingual handbook passes its own. */
|
|
142
|
-
lang?: UiLang;
|
|
143
|
-
}) {
|
|
127
|
+
}: TocProps) {
|
|
144
128
|
const t = ui(lang);
|
|
145
129
|
label ??= t.toc;
|
|
146
|
-
const [activeId, setActiveId] = useState(
|
|
147
|
-
const [
|
|
148
|
-
() => new Set(),
|
|
149
|
-
);
|
|
130
|
+
const [activeId, setActiveId] = useState<string>();
|
|
131
|
+
const [expansion, setExpansion] = useState<TocExpansion>({ mode: "follow" });
|
|
150
132
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
151
133
|
const panelId = TOC_PANEL_ID;
|
|
152
134
|
const controlHost = useSyncExternalStore(subscribeControlHost, readControlHost, serverControlHost);
|
|
@@ -157,7 +139,8 @@ export function TableOfContents({
|
|
|
157
139
|
const mobileCloseRef = useRef<HTMLButtonElement>(null);
|
|
158
140
|
const restoreMobileTriggerFocusRef = useRef(false);
|
|
159
141
|
const anchorCleanupRef = useRef<(() => void) | undefined>(undefined);
|
|
160
|
-
const tree = nestHeadings(headings);
|
|
142
|
+
const tree = useMemo(() => nestHeadings(headings), [headings]);
|
|
143
|
+
const expanded = useMemo(() => expandedTocSections(tree, expansion, activeId), [tree, expansion, activeId]);
|
|
161
144
|
|
|
162
145
|
const closeMobileToc = useCallback((restoreFocus = false) => {
|
|
163
146
|
restoreMobileTriggerFocusRef.current = restoreFocus;
|
|
@@ -250,9 +233,9 @@ export function TableOfContents({
|
|
|
250
233
|
const current = reachedPageEnd
|
|
251
234
|
? visibleHeadings.at(-1)?.id
|
|
252
235
|
: visibleHeadings.filter((heading) => heading.top <= readingProbe).at(-1)
|
|
253
|
-
?.id
|
|
236
|
+
?.id;
|
|
254
237
|
|
|
255
|
-
|
|
238
|
+
setActiveId((previous) => (previous === current ? previous : current));
|
|
256
239
|
};
|
|
257
240
|
const scheduleUpdate = () => {
|
|
258
241
|
if (!frame) frame = window.requestAnimationFrame(updateActiveHeading);
|
|
@@ -341,7 +324,7 @@ export function TableOfContents({
|
|
|
341
324
|
const activeItem = list?.querySelector<HTMLElement>(
|
|
342
325
|
'[aria-current="location"]',
|
|
343
326
|
);
|
|
344
|
-
if (!list || !activeItem) return;
|
|
327
|
+
if (!list || !activeItem || !activeItem.getClientRects().length) return;
|
|
345
328
|
|
|
346
329
|
const edge = 10;
|
|
347
330
|
const listBounds = list.getBoundingClientRect();
|
|
@@ -358,16 +341,20 @@ export function TableOfContents({
|
|
|
358
341
|
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
359
342
|
list.scrollTo({ top: nextTop, behavior: reduceMotion ? "auto" : "smooth" });
|
|
360
343
|
}
|
|
361
|
-
}, [activeId,
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
344
|
+
}, [activeId, expanded, mobileOpen]);
|
|
345
|
+
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
const focused = document.activeElement;
|
|
348
|
+
if (!(focused instanceof HTMLElement) || !listRef.current?.contains(focused) || focused.getClientRects().length) return;
|
|
349
|
+
let section = focused.closest(".toc-section");
|
|
350
|
+
while (section) {
|
|
351
|
+
const link = section.querySelector<HTMLAnchorElement>(":scope > .toc-item-row > a");
|
|
352
|
+
if (link?.getClientRects().length) { link.focus({ preventScroll: true }); break; }
|
|
353
|
+
section = section.parentElement?.closest(".toc-section") ?? null;
|
|
354
|
+
}
|
|
355
|
+
}, [expanded]);
|
|
356
|
+
|
|
357
|
+
const toggleSection = (id: string) => setExpansion((current) => toggleTocSection(expandedTocSections(tree, current, activeId), id));
|
|
371
358
|
|
|
372
359
|
const navigateToHeading = (event: MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
373
360
|
event.preventDefault();
|
|
@@ -390,7 +377,7 @@ export function TableOfContents({
|
|
|
390
377
|
};
|
|
391
378
|
|
|
392
379
|
const renderNode = (node: TocNode, path: number[]): ReactNode => {
|
|
393
|
-
const collapsed =
|
|
380
|
+
const collapsed = !expanded.has(node.id);
|
|
394
381
|
const childListId = `${listId}-${path.join("-")}`;
|
|
395
382
|
|
|
396
383
|
return (
|
|
@@ -422,7 +409,7 @@ export function TableOfContents({
|
|
|
422
409
|
) : null}
|
|
423
410
|
</div>
|
|
424
411
|
{node.children.length > 0 ? (
|
|
425
|
-
<ol id={childListId} className="toc-sublist"
|
|
412
|
+
<ol id={childListId} className="toc-sublist" role="list" data-collapsed={collapsed}>
|
|
426
413
|
{node.children.map((child, index) => renderNode(child, [...path, index]))}
|
|
427
414
|
</ol>
|
|
428
415
|
) : null}
|
|
@@ -465,6 +452,8 @@ export function TableOfContents({
|
|
|
465
452
|
id={panelId}
|
|
466
453
|
aria-label={label}
|
|
467
454
|
data-mobile-open={mobileOpen}
|
|
455
|
+
data-toc-mode={expansion.mode}
|
|
456
|
+
data-toc-active={activeId || ""}
|
|
468
457
|
>
|
|
469
458
|
<div className="toc-header">
|
|
470
459
|
<p>{label}</p>
|
|
@@ -481,7 +470,12 @@ export function TableOfContents({
|
|
|
481
470
|
<X size={18} aria-hidden="true" />
|
|
482
471
|
</a>
|
|
483
472
|
</div>
|
|
484
|
-
|
|
473
|
+
{tree.some((node) => node.children.length > 0) ? <div className="toc-mode-controls" role="group" aria-label={t.tocDisplay}>
|
|
474
|
+
<button type="button" data-toc-follow="" aria-controls={listId} aria-pressed={expansion.mode === "follow"} onClick={() => setExpansion({ mode: "follow" })}>{t.tocFollow}</button>
|
|
475
|
+
<button type="button" data-toc-expand-all="" aria-controls={listId} aria-pressed={expansion.mode === "all"} onClick={() => setExpansion({ mode: "all" })}>{t.tocExpandAll}</button>
|
|
476
|
+
<span className="toc-mode-status" role="status">{expansion.mode === "manual" ? t.tocManual : ""}</span>
|
|
477
|
+
</div> : null}
|
|
478
|
+
<ol id={listId} ref={listRef} className="toc-list" role="list">
|
|
485
479
|
{tree.map((node, index) => renderNode(node, [index]))}
|
|
486
480
|
</ol>
|
|
487
481
|
</aside>
|
package/app/globals.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@import "../ui/react/ui.css";
|
|
3
3
|
@import "../ui/compose/compose.css";
|
|
4
|
+
@import "../ui/garden/inline-math.css";
|
|
4
5
|
@import "./admin-ui.css";
|
|
5
6
|
|
|
6
7
|
:root {
|
|
@@ -1754,10 +1755,13 @@ button {
|
|
|
1754
1755
|
|
|
1755
1756
|
.toc .toc-sublist {
|
|
1756
1757
|
margin: 0;
|
|
1757
|
-
padding: 0
|
|
1758
|
+
padding: 0;
|
|
1759
|
+
padding-inline-start: 1.25em;
|
|
1758
1760
|
list-style: none;
|
|
1759
1761
|
}
|
|
1760
1762
|
|
|
1763
|
+
.toc .toc-sublist[data-collapsed="true"] { display: none; }
|
|
1764
|
+
|
|
1761
1765
|
.toc-item-row {
|
|
1762
1766
|
display: flex;
|
|
1763
1767
|
align-items: flex-start;
|
|
@@ -1804,10 +1808,14 @@ button {
|
|
|
1804
1808
|
line-height: 1.6;
|
|
1805
1809
|
}
|
|
1806
1810
|
|
|
1807
|
-
.toc
|
|
1808
|
-
.toc
|
|
1809
|
-
|
|
1810
|
-
}
|
|
1811
|
+
.toc-list > .toc-section > .toc-item-row > a { font-weight: 600; }
|
|
1812
|
+
.toc-sublist > .toc-section > .toc-item-row > a { font-weight: 400; }
|
|
1813
|
+
.toc-mode-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; flex: 0 0 auto; margin-bottom: 10px; }
|
|
1814
|
+
.toc-mode-controls button { min-height: 32px; padding: 4px 0; border: 0; background: transparent; color: var(--muted); font: inherit; cursor: pointer; }
|
|
1815
|
+
.toc-mode-controls button[aria-pressed="true"] { color: var(--ink); font-weight: 600; text-decoration: underline; text-underline-offset: 4px; }
|
|
1816
|
+
.toc-mode-controls button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
|
1817
|
+
.toc-mode-status { color: var(--muted); }
|
|
1818
|
+
.toc-mode-status:empty { display: none; }
|
|
1811
1819
|
|
|
1812
1820
|
.toc a:hover {
|
|
1813
1821
|
color: var(--ink);
|
|
@@ -2557,6 +2565,7 @@ button {
|
|
|
2557
2565
|
overflow-wrap: normal;
|
|
2558
2566
|
}
|
|
2559
2567
|
|
|
2568
|
+
|
|
2560
2569
|
.markdown-body .footnotes {
|
|
2561
2570
|
margin-top: 64px;
|
|
2562
2571
|
padding-top: 22px;
|
|
@@ -3863,18 +3872,22 @@ html[data-theme="dark"] .search-footer {
|
|
|
3863
3872
|
panel, because the post layout's desktop rule overrides the panel's
|
|
3864
3873
|
padding on post pages but not on column pages. */
|
|
3865
3874
|
.toc .toc-header,
|
|
3875
|
+
.toc .toc-mode-controls,
|
|
3866
3876
|
.toc .toc-list {
|
|
3867
3877
|
padding-left: max(20px, env(safe-area-inset-left));
|
|
3868
3878
|
padding-right: max(20px, env(safe-area-inset-right));
|
|
3869
3879
|
}
|
|
3870
3880
|
|
|
3871
|
-
.toc .toc-item-row > a { display: flex; align-items: center; min-height:
|
|
3872
|
-
.toc .toc-section-toggle { min-width:
|
|
3881
|
+
.toc .toc-item-row > a { display: flex; align-items: center; min-height: 40px; padding-block: 4px; }
|
|
3882
|
+
.toc .toc-section-toggle { min-width: 40px; min-height: 40px; }
|
|
3883
|
+
.toc .toc-mode-controls button { min-height: 44px; }
|
|
3873
3884
|
|
|
3874
|
-
.toc
|
|
3875
|
-
margin:
|
|
3885
|
+
.toc .toc-list .toc-section {
|
|
3886
|
+
margin: 2px 0;
|
|
3876
3887
|
font-size: 13px;
|
|
3888
|
+
line-height: 1.5;
|
|
3877
3889
|
}
|
|
3890
|
+
.toc-list > .toc-section + .toc-section { margin-top: 6px; }
|
|
3878
3891
|
|
|
3879
3892
|
.post-reading-page {
|
|
3880
3893
|
width: min(var(--content), calc(100% - 48px));
|
|
@@ -5393,9 +5406,10 @@ html[data-theme="dark"] .search-footer {
|
|
|
5393
5406
|
/* Without JavaScript every sub-list stays open and the collapse toggles do
|
|
5394
5407
|
nothing, so hide them at every width. */
|
|
5395
5408
|
@media (scripting: none) {
|
|
5396
|
-
.toc-section-toggle {
|
|
5409
|
+
.toc-section-toggle, .toc-mode-controls {
|
|
5397
5410
|
display: none;
|
|
5398
5411
|
}
|
|
5412
|
+
.toc .toc-sublist[data-collapsed="true"] { display: block; }
|
|
5399
5413
|
}
|
|
5400
5414
|
|
|
5401
5415
|
/* No JavaScript: the drawer button cannot open anything, so swap in plain
|
package/app/lib/i18n.ts
CHANGED
|
@@ -38,6 +38,10 @@ const zh = {
|
|
|
38
38
|
columnNewestFirst: (title: string | undefined) => `${title},从新到旧`,
|
|
39
39
|
toc: "本篇目录",
|
|
40
40
|
tocShort: "目录",
|
|
41
|
+
tocDisplay: "目录展开方式",
|
|
42
|
+
tocFollow: "跟随阅读",
|
|
43
|
+
tocExpandAll: "全部展开",
|
|
44
|
+
tocManual: "手动调整",
|
|
41
45
|
tocPage: "本页目录",
|
|
42
46
|
open: "打开",
|
|
43
47
|
close: "关闭",
|
|
@@ -296,6 +300,10 @@ const en: typeof zh = {
|
|
|
296
300
|
columnNewestFirst: (title: string | undefined) => `${title}, newest first`,
|
|
297
301
|
toc: "Contents",
|
|
298
302
|
tocShort: "TOC",
|
|
303
|
+
tocDisplay: "Contents display",
|
|
304
|
+
tocFollow: "Follow reading",
|
|
305
|
+
tocExpandAll: "Expand all",
|
|
306
|
+
tocManual: "Manual selection",
|
|
299
307
|
tocPage: "On this page",
|
|
300
308
|
open: "Open ",
|
|
301
309
|
close: "Close ",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type TocHeading = { depth: number; text: string; id: string; no?: string };
|
|
2
|
+
export type TocNode = TocHeading & { children: TocNode[] };
|
|
3
|
+
export type TocExpansion =
|
|
4
|
+
| { mode: "follow" }
|
|
5
|
+
| { mode: "all" }
|
|
6
|
+
| { mode: "manual"; expanded: ReadonlySet<string> };
|
|
7
|
+
|
|
8
|
+
export function nestHeadings(headings: readonly TocHeading[]): TocNode[] {
|
|
9
|
+
const roots: TocNode[] = [], stack: TocNode[] = [];
|
|
10
|
+
for (const heading of headings) {
|
|
11
|
+
const node: TocNode = { ...heading, children: [] };
|
|
12
|
+
let parent = stack.at(-1);
|
|
13
|
+
while (parent && parent.depth >= node.depth) {
|
|
14
|
+
stack.pop();
|
|
15
|
+
parent = stack.at(-1);
|
|
16
|
+
}
|
|
17
|
+
if (parent) parent.children.push(node);
|
|
18
|
+
else roots.push(node);
|
|
19
|
+
stack.push(node);
|
|
20
|
+
}
|
|
21
|
+
return roots;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The nearest shallower heading is the parent even when heading ranks skip.
|
|
25
|
+
// Opening only this path exposes the current heading and its immediate children.
|
|
26
|
+
export function expandedTocSections(tree: readonly TocNode[], state: TocExpansion, activeId?: string): ReadonlySet<string> {
|
|
27
|
+
if (state.mode === "manual") return state.expanded;
|
|
28
|
+
const expanded = new Set<string>();
|
|
29
|
+
function visit(nodes: readonly TocNode[]): boolean {
|
|
30
|
+
let containsActive = false;
|
|
31
|
+
for (const node of nodes) {
|
|
32
|
+
const onPath = visit(node.children) || node.id === activeId;
|
|
33
|
+
if (node.children.length && (state.mode === "all" || onPath)) expanded.add(node.id);
|
|
34
|
+
containsActive ||= onPath;
|
|
35
|
+
}
|
|
36
|
+
return containsActive;
|
|
37
|
+
}
|
|
38
|
+
visit(tree);
|
|
39
|
+
return expanded;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// An individual disclosure is an explicit manual browsing choice. Freeze the
|
|
43
|
+
// current visible branches instead of fighting the next scroll-spy update.
|
|
44
|
+
export function toggleTocSection(expanded: ReadonlySet<string>, id: string): TocExpansion {
|
|
45
|
+
const next = new Set(expanded);
|
|
46
|
+
if (next.has(id)) next.delete(id);
|
|
47
|
+
else next.add(id);
|
|
48
|
+
return { mode: "manual", expanded: next };
|
|
49
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
+
import { FeaturedTitle } from "./components/FeaturedTitle";
|
|
2
3
|
import { CoverImage } from "./components/CoverImage";
|
|
3
4
|
import { fullListDate, homeListDate } from "./lib/a2-meta";
|
|
4
5
|
import { columnStatusLabel, siteLang, ui } from "./lib/i18n";
|
|
@@ -65,8 +66,7 @@ export default function Home() {
|
|
|
65
66
|
<span>{t.homeLatestBadge}</span>
|
|
66
67
|
<time className="num" dateTime={day(feature.date)}>{fullListDate(feature.date, siteLang)}</time>
|
|
67
68
|
</p>
|
|
68
|
-
<
|
|
69
|
-
{feature.description ? <p>{feature.description}</p> : null}
|
|
69
|
+
<FeaturedTitle title={feature.title} href={postHref(feature)} description={feature.description} />
|
|
70
70
|
{feature.tags?.length ? (
|
|
71
71
|
<p className="tags">{feature.tags.map((tag) => <span key={tag}>#{tag}</span>)}</p>
|
|
72
72
|
) : null}
|
package/app/raw.d.ts
CHANGED
package/app/styles/shell.css
CHANGED
|
@@ -166,7 +166,8 @@ html[data-theme="dark"] .sh-rail{color:var(--muted)}
|
|
|
166
166
|
.sh-feature .cover{border-radius:var(--r-lg);aspect-ratio:auto;display:block;overflow:hidden;background:none}
|
|
167
167
|
.sh-feature .cover img{display:block;width:100%;height:auto}
|
|
168
168
|
.sh-feature .kicker{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:var(--fs-small);color:var(--muted);margin:0 0 12px}
|
|
169
|
-
.sh-feature
|
|
169
|
+
.sh-feature>div{min-width:0}
|
|
170
|
+
.sh-feature h2{font:700 max(1.875rem,1.125em)/1.35 var(--sans);color:var(--ink);margin:0;text-wrap:balance;overflow-wrap:anywhere}
|
|
170
171
|
.sh-feature h2 a{text-decoration:none}
|
|
171
172
|
.sh-feature h2 a::after{content:"";position:absolute;inset:0;border-radius:var(--r-lg)}
|
|
172
173
|
.sh-feature:hover h2 a{color:var(--blue);text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:4px}
|
|
@@ -289,7 +290,7 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
|
|
|
289
290
|
.sh-intro{padding:44px 0 0}
|
|
290
291
|
.sh-intro p{font-size:var(--fs-body)}
|
|
291
292
|
.sh-feature{grid-template-columns:minmax(0,1fr);gap:20px;margin-top:36px}
|
|
292
|
-
.sh-feature h2{font-size:
|
|
293
|
+
.sh-feature h2{font-size:max(1.5rem,1.125em)}
|
|
293
294
|
.sh-section{margin-top:56px}
|
|
294
295
|
.sh-cols{grid-template-columns:minmax(0,1fr);gap:16px}
|
|
295
296
|
.sh-row,.sh-home-grid .sh-row{grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:16px 0 17px}
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hd-garden",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
|
|
6
6
|
"license": "MIT",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"start": "node bin/garden.mjs start",
|
|
67
67
|
"admin:local": "node --env-file=.env.admin.local bin/garden.mjs start --host 0.0.0.0 --port 3000",
|
|
68
68
|
"admin:cloudflare": "npm run build && wrangler deploy --config dist/server/wrangler.json",
|
|
69
|
-
"test": "npm run build && node --experimental-strip-types --test tests/*.test.mjs",
|
|
69
|
+
"test": "npm run build && node --experimental-strip-types --test --test-concurrency=2 tests/*.test.mjs",
|
|
70
70
|
"ui:build": "node scripts/build-ui-preview.mjs",
|
|
71
71
|
"editors:build": "node editors/vscode/build.mjs && node editors/obsidian/build.mjs && node editors/typora/build.mjs",
|
|
72
72
|
"editors:pack": "node scripts/pack-editors.mjs",
|
package/scripts/file-preview.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import rehypeHighlight from "rehype-highlight";
|
|
17
17
|
import rehypeKatex from "rehype-katex";
|
|
18
|
+
import { rehypeKeepPunctuation } from "../ui/garden/rehype-keep-punctuation.mjs";
|
|
18
19
|
import rehypeRaw from "rehype-raw";
|
|
19
20
|
import rehypeSlug from "rehype-slug";
|
|
20
21
|
import rehypeStringify from "rehype-stringify";
|
|
@@ -104,6 +105,7 @@ export function renderFilePreview(markdown, { idPrefix = "gf-", lang = "zh" } =
|
|
|
104
105
|
.use(remarkRehype, { allowDangerousHtml: true })
|
|
105
106
|
.use(rehypeRaw)
|
|
106
107
|
.use(rehypeKatex)
|
|
108
|
+
.use(rehypeKeepPunctuation)
|
|
107
109
|
.use(rehypeSlug, { prefix: idPrefix })
|
|
108
110
|
.use(rehypeHighlight)
|
|
109
111
|
.use(rehypePreviewSafety)
|
|
@@ -190,6 +192,7 @@ export function previewStyleSheet() {
|
|
|
190
192
|
`:not(pre)>code{font:400 14px/1 ${mono};padding:3px 6px;border-radius:5px}`,
|
|
191
193
|
".table-wrap{overflow-x:auto}table{border-collapse:collapse}th,td{border:1px solid;padding:6px 10px}",
|
|
192
194
|
"a{color:inherit}.katex-html{display:none}",
|
|
195
|
+
fs.readFileSync(new URL("../ui/garden/inline-math.css", import.meta.url), "utf8"),
|
|
193
196
|
theme(light),
|
|
194
197
|
...rules.flatMap((sheet) => sheet.light),
|
|
195
198
|
`@media (prefers-color-scheme:dark){${theme(dark)}${rules.flatMap((sheet) => sheet.dark).join("")}}`,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Largest real-font size that fits, or a readable minimum followed by wrapping. */
|
|
2
|
+
export function fitTitleSize({ width, minimum, maximum, measure }) {
|
|
3
|
+
if (!(width > 0 && minimum > 0 && maximum >= minimum)) return maximum;
|
|
4
|
+
if (measure(maximum) <= width) return maximum;
|
|
5
|
+
if (measure(minimum) > width) return minimum;
|
|
6
|
+
let low = minimum, high = maximum;
|
|
7
|
+
for (let i = 0; i < 12; i++) {
|
|
8
|
+
const middle = (low + high) / 2;
|
|
9
|
+
if (measure(middle) <= width) low = middle;
|
|
10
|
+
else high = middle;
|
|
11
|
+
}
|
|
12
|
+
return Math.max(minimum, Math.floor(low * 64) / 64);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Browser-only: inline boot and layout effect. CSSOM leaves SSR markup intact. */
|
|
16
|
+
export function mountFeatureTitle(root, chooseSize = fitTitleSize) {
|
|
17
|
+
if (!root) return;
|
|
18
|
+
const key = Symbol.for('garden.feature-title-fit');
|
|
19
|
+
if (root[key]) { root[key].refresh(); return root[key].dispose; }
|
|
20
|
+
const heading = root.querySelector('[data-feature-title]');
|
|
21
|
+
const sheet = root.querySelector('style[data-feature-title-rules]')?.sheet;
|
|
22
|
+
if (!heading?.id || !sheet || !window.ResizeObserver || !window.CSS?.escape) return;
|
|
23
|
+
const index = sheet.insertRule(`#${CSS.escape(heading.id)}[data-feature-title]{}`, sheet.cssRules.length);
|
|
24
|
+
const rule = sheet.cssRules[index].style;
|
|
25
|
+
let frame = 0, disposed = false, signature = '', previous, readingFloor = 0;
|
|
26
|
+
const fit = () => {
|
|
27
|
+
if (disposed || !heading.isConnected) return;
|
|
28
|
+
const prior = rule.fontSize;
|
|
29
|
+
rule.removeProperty('font-size');
|
|
30
|
+
const style = getComputedStyle(heading), parent = getComputedStyle(root);
|
|
31
|
+
const summary = root.querySelector('[data-feature-summary]');
|
|
32
|
+
const body = parseFloat(getComputedStyle(summary || root).fontSize);
|
|
33
|
+
const parentSize = parseFloat(parent.fontSize), rootSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
34
|
+
const dpr = window.devicePixelRatio, scale = window.visualViewport?.scale || 1;
|
|
35
|
+
const width = heading.getBoundingClientRect().width;
|
|
36
|
+
const text = heading.textContent;
|
|
37
|
+
const state = { text, parentSize, rootSize, body, dpr, scale };
|
|
38
|
+
if (previous?.text !== text) readingFloor = 0;
|
|
39
|
+
if (previous?.text === text) {
|
|
40
|
+
// VisualViewport floats can jitter during initial mobile layout. Only an
|
|
41
|
+
// actual scale/font increase should preserve the reader's chosen size.
|
|
42
|
+
if (dpr < previous.dpr-.001 || scale < previous.scale-.001 || body < previous.body-.01 || rootSize < previous.rootSize-.01) readingFloor = 0;
|
|
43
|
+
if (dpr > previous.dpr+.001 || scale > previous.scale+.001 || body > previous.body+.01 || rootSize > previous.rootSize+.01) {
|
|
44
|
+
const rootGrowth = Math.max(1, rootSize / previous.rootSize);
|
|
45
|
+
readingFloor = Math.max(readingFloor, previous.size * rootGrowth / parentSize, previous.size / previous.parentSize);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const minimum = Math.max(body * 1.125, readingFloor * parentSize);
|
|
49
|
+
const maximum = Math.max(parseFloat(style.fontSize), minimum);
|
|
50
|
+
const nextSignature = JSON.stringify([heading.textContent,width,body,parentSize,rootSize,minimum,maximum,
|
|
51
|
+
style.fontFamily,style.fontWeight,style.fontStyle,style.fontStretch,style.letterSpacing,style.fontFeatureSettings,
|
|
52
|
+
style.fontVariationSettings,style.fontSizeAdjust,style.fontKerning,style.wordSpacing,style.fontOpticalSizing,style.textTransform,dpr,scale]);
|
|
53
|
+
if (!(width > 0 && parentSize > 0 && body > 0) || nextSignature === signature) {
|
|
54
|
+
if (prior) rule.fontSize = prior;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const probe = document.createElement('span');
|
|
58
|
+
probe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;white-space:nowrap;width:max-content;max-width:none;inset:0 auto auto 0;';
|
|
59
|
+
for (const property of ['font','fontKerning','fontFeatureSettings','fontVariationSettings','fontStretch','fontSizeAdjust','fontOpticalSizing','letterSpacing','wordSpacing','textTransform','direction']) probe.style[property] = style[property];
|
|
60
|
+
probe.textContent = heading.textContent;
|
|
61
|
+
root.append(probe);
|
|
62
|
+
let size;
|
|
63
|
+
try {
|
|
64
|
+
size = chooseSize({ width: Math.max(0,width-.5), minimum, maximum,
|
|
65
|
+
measure(value) { probe.style.fontSize = `${value}px`; return probe.getBoundingClientRect().width; } });
|
|
66
|
+
} finally { probe.remove(); }
|
|
67
|
+
rule.fontSize = `${size / parentSize}em`;
|
|
68
|
+
signature = nextSignature;
|
|
69
|
+
previous = { ...state, size };
|
|
70
|
+
};
|
|
71
|
+
const refresh = () => { if (!disposed && !frame) frame = requestAnimationFrame(() => { frame = 0; fit(); }); };
|
|
72
|
+
fit();
|
|
73
|
+
const resize = new ResizeObserver(refresh);
|
|
74
|
+
resize.observe(root);
|
|
75
|
+
const mutations = new MutationObserver(refresh);
|
|
76
|
+
mutations.observe(heading, { childList: true, subtree: true, characterData: true });
|
|
77
|
+
for (const element of [document.documentElement, document.body]) mutations.observe(element, { attributes:true, attributeFilter:['class','style','lang','data-theme'] });
|
|
78
|
+
window.addEventListener('resize',refresh);
|
|
79
|
+
window.visualViewport?.addEventListener('resize',refresh);
|
|
80
|
+
const fontsChanged = () => { signature = ''; refresh(); };
|
|
81
|
+
document.fonts?.addEventListener('loadingdone',fontsChanged);
|
|
82
|
+
document.fonts?.ready.then(fontsChanged);
|
|
83
|
+
const dispose = () => {
|
|
84
|
+
disposed = true;
|
|
85
|
+
cancelAnimationFrame(frame);
|
|
86
|
+
resize.disconnect(); mutations.disconnect();
|
|
87
|
+
window.removeEventListener('resize',refresh);
|
|
88
|
+
window.visualViewport?.removeEventListener('resize',refresh);
|
|
89
|
+
document.fonts?.removeEventListener('loadingdone',fontsChanged);
|
|
90
|
+
if (sheet.cssRules[index]?.style === rule) sheet.deleteRule(index);
|
|
91
|
+
delete root[key];
|
|
92
|
+
};
|
|
93
|
+
root[key] = { refresh, dispose };
|
|
94
|
+
return dispose;
|
|
95
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* Closing punctuation after an inline formula cannot use CSS line-break
|
|
2
|
+
classes: the formula is an atomic inline-block. Keep the two together
|
|
3
|
+
without making the whole paragraph nowrap. */
|
|
4
|
+
:is(.g-inline-math, .g-keep-punct) {
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: baseline;
|
|
7
|
+
max-width: 100%;
|
|
8
|
+
vertical-align: baseline;
|
|
9
|
+
/* Contain an unbreakable formula locally, not by clipping the page. Padding
|
|
10
|
+
keeps accents and descenders clear of the horizontal scroll viewport. */
|
|
11
|
+
padding-block: .15em;
|
|
12
|
+
overflow-x: auto;
|
|
13
|
+
overflow-y: hidden;
|
|
14
|
+
overflow-wrap: normal;
|
|
15
|
+
}
|
|
16
|
+
.g-inline-math > .katex { min-width: 0; }
|
|
17
|
+
.g-keep-punct { white-space: nowrap; }
|
|
18
|
+
.g-keep-punct > .katex { flex: none; }
|
|
19
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Keep closing punctuation with the preceding inline formula.
|
|
2
|
+
* KaTeX renders as an atomic inline-block, so CSS line-break / overflow-wrap
|
|
3
|
+
* rules cannot see the formula and the following punctuation as one cluster.
|
|
4
|
+
* Keep KaTeX markup/MathML intact; the outer viewport contains an oversized
|
|
5
|
+
* atomic formula whether or not it has following punctuation.
|
|
6
|
+
*/
|
|
7
|
+
export const CLOSING_PUNCTUATION = /^[,.;:!?,。、;:!?))\]}]}〉》」』】〕〗〙〛’”'"…]+/u;
|
|
8
|
+
|
|
9
|
+
function classNames(node) {
|
|
10
|
+
const value = node.properties?.className;
|
|
11
|
+
return new Set(Array.isArray(value) ? value.map(String) : String(value ?? "").split(/\s+/).filter(Boolean));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isInlineKatex(node) {
|
|
15
|
+
return node.type === "element" && classNames(node).has("katex") && !classNames(node).has("katex-display");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function keepClosingPunctuation(tree) {
|
|
19
|
+
const walk = (parent) => {
|
|
20
|
+
if (!Array.isArray(parent?.children) || isInlineKatex(parent) ||
|
|
21
|
+
["katex-display", "g-keep-punct", "g-inline-math"].some(name => classNames(parent).has(name))) return;
|
|
22
|
+
const next = [];
|
|
23
|
+
for (let index = 0; index < parent.children.length; index += 1) {
|
|
24
|
+
const child = parent.children[index];
|
|
25
|
+
walk(child);
|
|
26
|
+
const following = parent.children[index + 1];
|
|
27
|
+
const match = isInlineKatex(child) && following?.type === "text" ? following.value.match(CLOSING_PUNCTUATION) : null;
|
|
28
|
+
if (!isInlineKatex(child)) { next.push(child); continue; }
|
|
29
|
+
next.push({
|
|
30
|
+
type: "element",
|
|
31
|
+
tagName: "span",
|
|
32
|
+
properties: { className: [match ? "g-keep-punct" : "g-inline-math"] },
|
|
33
|
+
children: match ? [child, { type: "text", value: match[0] }] : [child],
|
|
34
|
+
});
|
|
35
|
+
if (!match) continue;
|
|
36
|
+
const rest = following.value.slice(match[0].length);
|
|
37
|
+
if (rest) next.push({ type: "text", value: rest });
|
|
38
|
+
index += 1;
|
|
39
|
+
}
|
|
40
|
+
parent.children = next;
|
|
41
|
+
};
|
|
42
|
+
walk(tree);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function rehypeKeepPunctuation() {
|
|
46
|
+
return keepClosingPunctuation;
|
|
47
|
+
}
|