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.
@@ -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 Heading = { depth: number; text: string; id: string; no?: string };
21
+ type TocProps = { headings: TocHeading[]; label?: string; lang?: UiLang };
19
22
 
20
- type TocNode = Heading & { children: TocNode[] };
21
-
22
- const nestHeadings = (headings: Heading[]) => {
23
- const roots: TocNode[] = [];
24
- const stack: TocNode[] = [];
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
- export function TableOfContents({
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(headings[0]?.id);
147
- const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
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 ?? visibleHeadings[0]?.id;
236
+ ?.id;
254
237
 
255
- if (current) setActiveId((previous) => (previous === current ? previous : current));
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, collapsedSections, mobileOpen]);
362
-
363
- const toggleSection = (id: string) => {
364
- setCollapsedSections((current) => {
365
- const next = new Set(current);
366
- if (next.has(id)) next.delete(id);
367
- else next.add(id);
368
- return next;
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 = collapsedSections.has(node.id);
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" hidden={collapsed}>
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
- <ol id={listId} ref={listRef} className="toc-list">
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 0 0 12px;
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 li.toc-depth-4,
1808
- .toc li.toc-depth-5 {
1809
- font-size: 11px;
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: 44px; padding-block: 8px; }
3872
- .toc .toc-section-toggle { min-width: 44px; min-height: 44px; }
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 li {
3875
- margin: 10px 0;
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
- <h2 id="ft"><Link href={postHref(feature)}>{feature.title}</Link></h2>
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
@@ -2,3 +2,8 @@ declare module "*.md?raw" {
2
2
  const source: string;
3
3
  export default source;
4
4
  }
5
+
6
+ declare module "*.mjs?raw" {
7
+ const source: string;
8
+ export default source;
9
+ }
@@ -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 h2{font:700 30px/1.35 var(--sans);color:var(--ink);margin:0;text-wrap:balance}
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:24px}
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}
@@ -5,7 +5,7 @@
5
5
  "author": "hedon",
6
6
  "authorUrl": "https://github.com/hedon954",
7
7
  "repo": "hedon954/garden",
8
- "version": "0.14.7",
8
+ "version": "0.14.8",
9
9
  "minAppVersion": "1.5.0",
10
10
  "minCoreVersion": "2.10.52",
11
11
  "platforms": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.14.7",
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",
@@ -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
+ }