hd-garden 0.14.12 → 0.14.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/Makefile +1 -1
  2. package/app/api/admin/distribution/route.ts +4 -2
  3. package/app/blog/[...path]/page.tsx +4 -2
  4. package/app/columns/[column]/[...path]/page.tsx +4 -2
  5. package/app/components/GardenMediaGuide.tsx +11 -23
  6. package/app/components/GardenPlayer.tsx +25 -0
  7. package/app/components/HandbookView.tsx +5 -3
  8. package/app/components/MarkdownArticle.tsx +7 -2
  9. package/app/components/PostReadingView.tsx +3 -3
  10. package/app/garden-modules.d.ts +10 -2
  11. package/app/globals.css +58 -1
  12. package/app/lib/content-body.ts +20 -0
  13. package/app/lib/garden-docs-meta.ts +2 -0
  14. package/app/lib/markdown-plugins.ts +5 -0
  15. package/app/lib/remark-video-player.d.mts +10 -0
  16. package/app/lib/remark-video-player.mjs +70 -0
  17. package/app/rss.xml/route.ts +3 -2
  18. package/bin/garden.mjs +18 -78
  19. package/docs/handbook/components/compose.md +3 -1
  20. package/docs/handbook/components/diagrams/chart.md +38 -0
  21. package/docs/handbook/components/diagrams/code-walkthrough.md +38 -0
  22. package/docs/handbook/components/diagrams/columns-join.md +38 -0
  23. package/docs/handbook/components/diagrams/compare.md +38 -0
  24. package/docs/handbook/components/diagrams/converter.md +38 -0
  25. package/docs/handbook/components/diagrams/facets.md +38 -0
  26. package/docs/handbook/components/diagrams/fan-out.md +38 -0
  27. package/docs/handbook/components/diagrams/icon-rail.md +38 -0
  28. package/docs/handbook/components/diagrams/inspect-grid.md +38 -0
  29. package/docs/handbook/components/diagrams/lanes.md +38 -0
  30. package/docs/handbook/components/diagrams/layers.md +38 -0
  31. package/docs/handbook/components/diagrams/other.md +37 -0
  32. package/docs/handbook/components/diagrams/plan.md +38 -0
  33. package/docs/handbook/components/diagrams/previews/chart.svg +7 -0
  34. package/docs/handbook/components/diagrams/previews/code-walkthrough.svg +5 -0
  35. package/docs/handbook/components/diagrams/previews/columns-join.svg +6 -0
  36. package/docs/handbook/components/diagrams/previews/compare.svg +5 -0
  37. package/docs/handbook/components/diagrams/previews/converter.svg +6 -0
  38. package/docs/handbook/components/diagrams/previews/facets.svg +6 -0
  39. package/docs/handbook/components/diagrams/previews/fan-out.svg +8 -0
  40. package/docs/handbook/components/diagrams/previews/icon-rail.svg +5 -0
  41. package/docs/handbook/components/diagrams/previews/inspect-grid.svg +5 -0
  42. package/docs/handbook/components/diagrams/previews/lanes.svg +5 -0
  43. package/docs/handbook/components/diagrams/previews/layers.svg +6 -0
  44. package/docs/handbook/components/diagrams/previews/other.svg +6 -0
  45. package/docs/handbook/components/diagrams/previews/plan.svg +5 -0
  46. package/docs/handbook/components/diagrams/previews/sequence.svg +7 -0
  47. package/docs/handbook/components/diagrams/previews/stepper.svg +5 -0
  48. package/docs/handbook/components/diagrams/previews/steps.svg +7 -0
  49. package/docs/handbook/components/diagrams/previews/tabs.svg +6 -0
  50. package/docs/handbook/components/diagrams/previews/timeline.svg +5 -0
  51. package/docs/handbook/components/diagrams/previews/trace.svg +7 -0
  52. package/docs/handbook/components/diagrams/previews/tunable.svg +4 -0
  53. package/docs/handbook/components/diagrams/previews/two-row.svg +4 -0
  54. package/docs/handbook/components/diagrams/sequence.md +38 -0
  55. package/docs/handbook/components/diagrams/stepper.md +38 -0
  56. package/docs/handbook/components/diagrams/steps.md +38 -0
  57. package/docs/handbook/components/diagrams/tabs.md +38 -0
  58. package/docs/handbook/components/diagrams/timeline.md +38 -0
  59. package/docs/handbook/components/diagrams/trace.md +38 -0
  60. package/docs/handbook/components/diagrams/tunable.md +38 -0
  61. package/docs/handbook/components/diagrams/two-row.md +38 -0
  62. package/docs/handbook/components/diagrams.md +100 -0
  63. package/docs/handbook/components/link-card.md +1 -1
  64. package/docs/handbook/components.md +1 -0
  65. package/docs/handbook/en/components/compose.md +3 -1
  66. package/docs/handbook/en/components/diagrams/chart.md +40 -0
  67. package/docs/handbook/en/components/diagrams/code-walkthrough.md +40 -0
  68. package/docs/handbook/en/components/diagrams/columns-join.md +40 -0
  69. package/docs/handbook/en/components/diagrams/compare.md +40 -0
  70. package/docs/handbook/en/components/diagrams/converter.md +40 -0
  71. package/docs/handbook/en/components/diagrams/facets.md +40 -0
  72. package/docs/handbook/en/components/diagrams/fan-out.md +40 -0
  73. package/docs/handbook/en/components/diagrams/icon-rail.md +40 -0
  74. package/docs/handbook/en/components/diagrams/inspect-grid.md +40 -0
  75. package/docs/handbook/en/components/diagrams/lanes.md +40 -0
  76. package/docs/handbook/en/components/diagrams/layers.md +40 -0
  77. package/docs/handbook/en/components/diagrams/other.md +39 -0
  78. package/docs/handbook/en/components/diagrams/plan.md +40 -0
  79. package/docs/handbook/en/components/diagrams/sequence.md +40 -0
  80. package/docs/handbook/en/components/diagrams/stepper.md +40 -0
  81. package/docs/handbook/en/components/diagrams/steps.md +40 -0
  82. package/docs/handbook/en/components/diagrams/tabs.md +40 -0
  83. package/docs/handbook/en/components/diagrams/timeline.md +40 -0
  84. package/docs/handbook/en/components/diagrams/trace.md +40 -0
  85. package/docs/handbook/en/components/diagrams/tunable.md +40 -0
  86. package/docs/handbook/en/components/diagrams/two-row.md +40 -0
  87. package/docs/handbook/en/components/diagrams.md +100 -0
  88. package/docs/handbook/en/components/link-card.md +1 -1
  89. package/docs/handbook/en/components.md +1 -0
  90. package/docs/handbook/en/start.md +1 -1
  91. package/docs/handbook/en/workflow.md +2 -2
  92. package/docs/handbook/en/writing.md +82 -1
  93. package/docs/handbook/start.md +1 -1
  94. package/docs/handbook/workflow.md +2 -2
  95. package/docs/handbook/writing.md +82 -1
  96. package/editors/dist/{garden-typora-0.14.12.zip → garden-typora-0.14.13.zip} +0 -0
  97. package/editors/dist/typora/main.js +175 -175
  98. package/editors/dist/typora/manifest.json +1 -1
  99. package/editors/dist/typora/style.css +44 -6
  100. package/editors/dist/vscode/{garden-ui-0.14.12.vsix → garden-ui-0.14.13.vsix} +0 -0
  101. package/package.json +1 -1
  102. package/scripts/build-content.mjs +23 -8
  103. package/scripts/dev-server.mjs +30 -2
  104. package/scripts/fetch-embed.mjs +39 -27
  105. package/scripts/handbook-diagrams.mjs +434 -0
  106. package/scripts/link-assets.mjs +6 -0
  107. package/scripts/vinext-runtime.mjs +80 -0
  108. package/templates/site/Makefile +1 -1
  109. package/templates/site/Makefile.en +1 -1
  110. package/ui/compose/compose.css +43 -5
  111. package/ui/garden/markdown-links.mjs +8 -5
  112. package/ui/garden/picker-placement.mjs +106 -2
  113. package/ui/garden/player.mjs +286 -0
  114. package/ui/garden/rehype-keep-punctuation.mjs +87 -18
  115. package/ui/preview/dist/garden-ui.css +43 -5
  116. package/ui/preview/dist/garden-ui.esm.js +4 -4
  117. package/ui/preview/dist/garden-ui.iife.js +4 -4
  118. package/vite.config.ts +84 -23
package/Makefile CHANGED
@@ -13,7 +13,7 @@ export GARDEN_EMBED_URL := $(value URL)
13
13
  export GARDEN_CHECK_FILE := $(value FILE)
14
14
 
15
15
  help:
16
- @printf "%s\n" "Garden 常用命令:" " make new 创建文章草稿(无需安装 npm 依赖)" ' make new TITLE="文章标题" SLUG=my-post DIR=writing' ' make embed URL="https://example.com" 拉取外部网页摘要,打印可粘贴的 embed 围栏' " make skill 安装可交互图表 skill" " make dev 首次准备并本地预览" " make check 全量严格预检后 lint/测试;FILE=路径 只限制预检" " make check diff 只检查 Git 改动的 Markdown,不构建/跑全套测试" " make build 构建公开站点" " make update 检查、提交并触发自动发布" " make update MESSAGE=\"新增一篇文章\" 自定义提交说明"
16
+ @printf "%s\n" "Garden 常用命令:" " make new 创建文章草稿(无需安装 npm 依赖)" ' make new TITLE="文章标题" SLUG=my-post DIR=writing' ' make embed URL="https://www.youtube.com/watch?v=..." 打印可粘贴的规范化视频链接或网页地址' " make skill 安装可交互图表 skill" " make dev 首次准备并本地预览" " make check 全量严格预检后 lint/测试;FILE=路径 只限制预检" " make check diff 只检查 Git 改动的 Markdown,不构建/跑全套测试" " make build 构建公开站点" " make update 检查、提交并触发自动发布" " make update MESSAGE=\"新增一篇文章\" 自定义提交说明"
17
17
 
18
18
  new:
19
19
  @node scripts/new-post.mjs
@@ -1,5 +1,6 @@
1
1
  import { requireAdminApi, requireAdminMutation } from "../../../lib/admin-auth";
2
2
  import { findColumnEntry, findPost } from "../../../lib/content";
3
+ import { postWithBody } from "../../../lib/content-body";
3
4
  import { absoluteUrl, getSiteUrl } from "../../../lib/site";
4
5
  import { contentForDistribution } from "../../../../ui/core/transform.mjs";
5
6
 
@@ -42,8 +43,9 @@ export async function POST(request: Request) {
42
43
  const entry = kind === "post" ? findPost(path) : undefined;
43
44
  const column = typeof payload.column === "string" ? payload.column : "";
44
45
  const columnEntry = kind === "column" ? findColumnEntry(column, path) : undefined;
45
- const source = entry ?? columnEntry;
46
- if (!source) return Response.json({ error: "未找到对应内容。" }, { status: 404 });
46
+ const found = entry ?? columnEntry;
47
+ if (!found) return Response.json({ error: "未找到对应内容。" }, { status: 404 });
48
+ const source = postWithBody(found);
47
49
 
48
50
  const publicPath = kind === "post" ? `/blog/${source.path}` : `/columns/${source.column}/${source.path}`;
49
51
  const siteUrl = await getSiteUrl();
@@ -6,6 +6,7 @@ import {
6
6
  postHref,
7
7
  posts,
8
8
  } from "../../lib/content";
9
+ import { postWithBody } from "../../lib/content-body";
9
10
  import { PostReadingView } from "../../components/PostReadingView";
10
11
  import { adjacentByDate } from "../../../ui/garden/adjacent.mjs";
11
12
  import {
@@ -59,11 +60,12 @@ export default async function PostPage({
59
60
  params: Promise<{ path: string[] }>;
60
61
  }) {
61
62
  const { path } = await params;
62
- const post = findPost(path.join("/"));
63
- if (!post) {
63
+ const found = findPost(path.join("/"));
64
+ if (!found) {
64
65
  if (isStaticPlaceholder(path)) return null;
65
66
  notFound();
66
67
  }
68
+ const post = postWithBody(found);
67
69
  const baseUrl = await getSiteUrl();
68
70
  const pathname = postHref(post);
69
71
  const canonicalUrl = absoluteUrl(pathname, baseUrl);
@@ -7,6 +7,7 @@ import {
7
7
  columnHref,
8
8
  columnEntryPath,
9
9
  } from "../../../lib/content";
10
+ import { postWithBody } from "../../../lib/content-body";
10
11
  import { PostReadingView } from "../../../components/PostReadingView";
11
12
  import {
12
13
  absoluteUrl,
@@ -69,11 +70,12 @@ export default async function ColumnEntryPage({
69
70
  params: Promise<{ column: string; path: string[] }>;
70
71
  }) {
71
72
  const { column, path } = await params;
72
- const entry = findColumnEntry(column, path.join("/"));
73
- if (!entry) {
73
+ const found = findColumnEntry(column, path.join("/"));
74
+ if (!found) {
74
75
  if (isStaticPlaceholder(column)) return null;
75
76
  notFound();
76
77
  }
78
+ const entry = postWithBody(found);
77
79
  const series = columns.filter((item) => item.column === column);
78
80
  const listed = [...series].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
79
81
  const currentIndex = series.findIndex((item) => item.path === entry.path);
@@ -1,28 +1,17 @@
1
- const embedTypes = [
1
+ const mediaTypes = [
2
2
  {
3
3
  title: "YouTube / Bilibili",
4
- description: "贴上分享链接,页面里就能播放,也能回到原站。",
5
- },
6
- {
7
- title: "X / Twitter",
8
- description: "显示成一张带出处的卡片。",
9
- },
10
- {
11
- title: "微信公众号",
12
- description: "留下标题、摘要和原文链接。微信不允许嵌进页面时,卡片仍然能点开。",
4
+ description: "把可播放的视频链接单独占一行,页面里就能播放,也能回到原站。",
13
5
  },
14
6
  {
15
7
  title: "其他网站",
16
- description: "自己写标题、摘要和封面。",
8
+ description: "单独一行的网址显示为链接卡片;句子里的链接在后面紧跟 {card} 才会变成卡片。",
17
9
  },
18
10
  ];
19
11
 
20
- const embedSource = `\`\`\`embed
21
- url: https://www.youtube.com/watch?v=VIDEO_ID
22
- title: 这段视频讲了什么
23
- description: 给读者一个值得点开的理由。
24
- image: https://example.com/cover.jpg
25
- \`\`\``;
12
+ const mediaSource = `https://www.youtube.com/watch?v=VIDEO_ID
13
+
14
+ https://www.bilibili.com/video/BV1xx411c7mD`;
26
15
 
27
16
  export function GardenMediaGuide() {
28
17
  return (
@@ -30,9 +19,8 @@ export function GardenMediaGuide() {
30
19
  <header>
31
20
  <h2 id="garden-media-title">视频和链接</h2>
32
21
  <p>
33
- 自己的图片、音频和视频用 Markdown。别人的页面用
34
- <code> embed </code>
35
- 围栏。能播的直接播,不能播的显示成一张卡片。
22
+ 自己的图片、音频和视频用 Markdown 或 <code>audio</code> / <code>video</code> 标签。
23
+ YouTube / Bilibili 只要把链接单独一行。其他网站用链接卡片,不要写任意 iframe。
36
24
  </p>
37
25
  </header>
38
26
 
@@ -40,11 +28,11 @@ export function GardenMediaGuide() {
40
28
  <div>
41
29
  <span>content/posts/example.md</span>
42
30
  </div>
43
- <pre><code>{embedSource}</code></pre>
31
+ <pre><code>{mediaSource}</code></pre>
44
32
  </div>
45
33
 
46
34
  <ul className="garden-media-providers">
47
- {embedTypes.map(({ title, description }) => (
35
+ {mediaTypes.map(({ title, description }) => (
48
36
  <li key={title}>
49
37
  <strong>{title}</strong>
50
38
  <p>{description}</p>
@@ -53,7 +41,7 @@ export function GardenMediaGuide() {
53
41
  </ul>
54
42
 
55
43
  <p className="garden-media-boundary">
56
- 摘要写在文章里。原站打不开,文章还在。需要时用 <code>make embed URL=...</code> 拉一次,看过再贴进去。
44
+ 需要规范化短链或核对标题时,用 <code>npx garden embed URL</code> 打印一行可粘贴的地址,看过再贴进文章。
57
45
  </p>
58
46
  </section>
59
47
  );
@@ -0,0 +1,25 @@
1
+ import { resolvePlayer } from "../../ui/garden/player.mjs";
2
+
3
+ export function GardenPlayer({ href, title }: { href: string; title?: string }) {
4
+ const player = resolvePlayer(href);
5
+ if (!player.ok) return null;
6
+ const open = player.provider === "youtube" ? "在 YouTube 打开" : "在 Bilibili 打开";
7
+ const frameTitle = title?.trim() || `${player.label} video`;
8
+ return (
9
+ <figure className={`g-player g-player-${player.provider}`}>
10
+ <div className="g-player-frame">
11
+ <iframe
12
+ src={player.playerUrl}
13
+ title={frameTitle}
14
+ loading="lazy"
15
+ allow="fullscreen; picture-in-picture; encrypted-media"
16
+ referrerPolicy="strict-origin-when-cross-origin"
17
+ allowFullScreen
18
+ />
19
+ </div>
20
+ <figcaption>
21
+ <a className="ext" href={player.pageUrl} target="_blank" rel="noopener noreferrer">{open}</a>
22
+ </figcaption>
23
+ </figure>
24
+ );
25
+ }
@@ -9,6 +9,7 @@ import { HandbookLangRedirect } from "./HandbookLangRedirect";
9
9
  import { MarkdownArticle } from "./MarkdownArticle";
10
10
  import { TableOfContents } from "./TableOfContents";
11
11
  import { extractHeadings, handbookDocs, handbookIndexDoc, hasHandbookPage, type GardenDocEntry } from "../lib/content";
12
+ import { docWithBody } from "../lib/content-body";
12
13
  import { pageAlternates } from "../lib/page-alternates";
13
14
  import { absoluteUrl, getSiteUrl } from "../lib/site";
14
15
  import { siteHref } from "../lib/site-href";
@@ -143,7 +144,7 @@ export async function handbookDocMetadata(lang: HandbookLang, doc: GardenDocEntr
143
144
  export function HandbookOverview({ lang }: { lang: HandbookLang }) {
144
145
  const t = HANDBOOK_UI[lang];
145
146
  const docs = handbookDocs(lang);
146
- const indexDoc = handbookIndexDoc(lang);
147
+ const indexDoc = docWithBody(handbookIndexDoc(lang), lang);
147
148
  const navItems = toHandbookNavItems(docs);
148
149
  const firstDoc = orderedHandbookDocs(docs)[0] ?? null;
149
150
  const content = indexDoc?.content ?? "";
@@ -197,13 +198,14 @@ export function HandbookOverview({ lang }: { lang: HandbookLang }) {
197
198
 
198
199
  export function HandbookDoc({ lang, doc }: { lang: HandbookLang; doc: GardenDocEntry }) {
199
200
  const t = HANDBOOK_UI[lang];
201
+ const resolved = docWithBody(doc, lang);
200
202
  const docs = handbookDocs(lang);
201
203
  const ordered = orderedHandbookDocs(docs);
202
204
  const navItems = toHandbookNavItems(docs);
203
205
  const index = ordered.findIndex((item) => item.path === doc.path);
204
206
  const prev = index > 0 ? ordered[index - 1] : null;
205
207
  const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null;
206
- const headings = extractHeadings(doc.content);
208
+ const headings = extractHeadings(resolved.content);
207
209
  const sectionMeta = HANDBOOK_SECTIONS.find((sec) => sec.id === doc.section);
208
210
  const componentBadge = COMPONENT_BADGE[doc.path];
209
211
 
@@ -229,7 +231,7 @@ export function HandbookDoc({ lang, doc }: { lang: HandbookLang; doc: GardenDocE
229
231
  <ArticleMeasure />
230
232
 
231
233
  <div className="e-content">
232
- <MarkdownArticle content={doc.content} lang={lang} fieldTables handbook refDemos={isReferencePath(doc.path)} />
234
+ <MarkdownArticle content={resolved.content} lang={lang} fieldTables handbook refDemos={isReferencePath(doc.path)} />
233
235
  </div>
234
236
 
235
237
  <ArticleReferences references={doc.references} lang={lang} />
@@ -38,6 +38,8 @@ import { LinkCardRefresh } from "./LinkCardRefresh";
38
38
  import { linkHost } from "../../scripts/link-meta-core.mjs";
39
39
  import { FaviconProvider, SourceMark } from "./garden/patterns";
40
40
  import { rehypeFieldTables } from "../lib/rehype-field-tables.mjs";
41
+ import { GardenPlayer } from "./GardenPlayer";
42
+ import { resolvePlayer } from "../../ui/garden/player.mjs";
41
43
  import { cardDate } from "../../ui/garden/card-refresh.mjs";
42
44
  import { siteHref } from "../lib/site-href";
43
45
  import { codeLanguageLabel, fenceLanguage } from "../../ui/garden/code-language.mjs";
@@ -271,8 +273,11 @@ export function MarkdownArticle({ content, lang, fieldTables, refDemos, handbook
271
273
  const only = nodes.length === 1 && isValidElement<{ href?: string; children?: ReactNode }>(nodes[0]) ? nodes[0] : null;
272
274
  const href = only?.props.href;
273
275
  const text = only ? headingText(only.props.children).trim() : "";
274
- // Bare http(s) URL alone in a paragraph → link card (same path for every jump link).
275
- if (href && text === href && /^https?:\/\//iu.test(href)) return <LinkCard href={href} />;
276
+ // Bare http(s) URL alone in a paragraph → player for YouTube / Bilibili, otherwise a link card.
277
+ if (href && text === href && /^https?:\/\//iu.test(href)) {
278
+ if (resolvePlayer(href).ok) return <GardenPlayer href={href} />;
279
+ return <LinkCard href={href} />;
280
+ }
276
281
  return <p {...domProps}>{children}</p>;
277
282
  },
278
283
  a: ({ href: written, children, ...props }) => {
@@ -53,8 +53,8 @@ export function PostReadingView({
53
53
  }) {
54
54
  const displayTitle = title ?? post.title;
55
55
  const numbering = (post as ContentEntry & { numbering?: boolean }).numbering;
56
- const headings = extractHeadings(post.content, { numbering });
57
- const wordCount = estimateWordCount(post.content);
56
+ const headings = extractHeadings(post.content ?? "", { numbering });
57
+ const wordCount = estimateWordCount(post.content ?? "");
58
58
  const t = ui();
59
59
 
60
60
  return (
@@ -116,7 +116,7 @@ export function PostReadingView({
116
116
  <ArticleMeasure />
117
117
  <div className="e-content">
118
118
  {post.description ? <p className="lead quote-plain p-summary">{post.description}</p> : null}
119
- <MarkdownArticle content={post.content} numbering={numbering} />
119
+ <MarkdownArticle content={post.content ?? ""} numbering={numbering} />
120
120
  </div>
121
121
  <ArticleReferences references={post.references} />
122
122
  <Webmentions target={canonicalUrl} endpoint={webmentionApiEndpoint()} />
@@ -14,7 +14,7 @@ declare module "@garden/generated-content" {
14
14
  readingTime?: string;
15
15
  kind: "post" | "column";
16
16
  sourcePath: string;
17
- content: string;
17
+ content?: string;
18
18
  column?: string;
19
19
  columnTitle?: string;
20
20
  columnDescription?: string;
@@ -42,7 +42,7 @@ declare module "@garden/generated-content" {
42
42
  readingTime?: string;
43
43
  references?: { title: string; url?: string; domain?: string }[];
44
44
  sourcePath: string;
45
- content: string;
45
+ content?: string;
46
46
  lang?: "zh" | "en";
47
47
  };
48
48
  export const gardenDocs: GardenDocEntry[];
@@ -64,6 +64,14 @@ declare module "@garden/generated-content" {
64
64
  export const imageSizes: Record<string, { width: number; height: number }>;
65
65
  }
66
66
 
67
+ declare module "@garden/generated-post-bodies" {
68
+ export const postBodies: Record<string, string>;
69
+ }
70
+
71
+ declare module "@garden/generated-handbook-bodies" {
72
+ export const handbookBodies: Record<string, string>;
73
+ }
74
+
67
75
  declare module "@garden/site-config" {
68
76
  export const searchIndexUrl: string;
69
77
  /** The full-text index file and the hash its contents are cached under. */
package/app/globals.css CHANGED
@@ -2141,7 +2141,8 @@ button {
2141
2141
  -webkit-line-clamp: 2;
2142
2142
  }
2143
2143
 
2144
- .external-embed-error {
2144
+ .external-embed-error,
2145
+ .g-player-error {
2145
2146
  margin: 32px 0;
2146
2147
  padding: 18px;
2147
2148
  border-left: 3px solid var(--accent);
@@ -2150,6 +2151,62 @@ button {
2150
2151
  font-size: 12px;
2151
2152
  }
2152
2153
 
2154
+ .markdown-body .g-player,
2155
+ .markdown-body .g-player-fallback {
2156
+ width: 100%;
2157
+ max-width: 100%;
2158
+ margin: 36px 0;
2159
+ background: transparent;
2160
+ border: 0;
2161
+ box-shadow: none;
2162
+ }
2163
+
2164
+ /* The video uses the article radius token --r (12px). Standalone body
2165
+ images have no border-radius; covers use --r-lg; figure-card photos
2166
+ use 8px inside a padded card shell we are not copying. */
2167
+ .g-player-frame {
2168
+ width: 100%;
2169
+ max-width: 100%;
2170
+ aspect-ratio: 16 / 9;
2171
+ border-radius: var(--r);
2172
+ overflow: hidden;
2173
+ background: #111;
2174
+ }
2175
+
2176
+ @supports (overflow-clip-margin: 4px) {
2177
+ .g-player-frame {
2178
+ overflow: clip;
2179
+ overflow-clip-margin: 4px;
2180
+ }
2181
+ }
2182
+
2183
+ .g-player-frame:focus-within {
2184
+ outline: 2px solid var(--blue);
2185
+ outline-offset: 2px;
2186
+ }
2187
+
2188
+ .g-player-frame iframe {
2189
+ width: 100%;
2190
+ max-width: 100%;
2191
+ height: 100%;
2192
+ display: block;
2193
+ border: 0;
2194
+ border-radius: inherit;
2195
+ }
2196
+
2197
+ /* Same typography as .markdown-body .fig figcaption: a quiet line, not a bar. */
2198
+ .markdown-body .g-player figcaption {
2199
+ margin-top: 12px;
2200
+ font-size: var(--fs-small);
2201
+ line-height: 1.6;
2202
+ color: var(--muted);
2203
+ text-align: center;
2204
+ }
2205
+
2206
+ .markdown-body .g-player figcaption a {
2207
+ color: var(--muted);
2208
+ }
2209
+
2153
2210
  .markdown-body .garden-chart {
2154
2211
  margin: 36px 0;
2155
2212
  border: 1px solid var(--line-strong);
@@ -0,0 +1,20 @@
1
+ import { handbookBodies } from "@garden/generated-handbook-bodies";
2
+ import { postBodies } from "@garden/generated-post-bodies";
3
+ import type { ContentEntry, GardenDocEntry } from "./content";
4
+
5
+ export function postWithBody<T extends Pick<ContentEntry, "path">>(entry: T): T & { content: string } {
6
+ return { ...entry, content: postBodies[entry.path] ?? "" };
7
+ }
8
+
9
+ export function docWithBody<T extends Pick<GardenDocEntry, "path">>(
10
+ doc: T,
11
+ lang: "zh" | "en",
12
+ ): T & { content: string };
13
+ export function docWithBody(
14
+ doc: GardenDocEntry | null | undefined,
15
+ lang: "zh" | "en",
16
+ ): (GardenDocEntry & { content: string }) | null | undefined;
17
+ export function docWithBody(doc: GardenDocEntry | null | undefined, lang: "zh" | "en") {
18
+ if (!doc) return doc;
19
+ return { ...doc, content: handbookBodies[`${lang}:${doc.path}`] ?? "" };
20
+ }
@@ -69,6 +69,7 @@ export const HANDBOOK_SECTIONS: HandbookSectionMeta[] = [
69
69
  { id: "getting-started", title: "快速上手", titleEn: "Getting Started" },
70
70
  { id: "writing-and-reading", title: "写作与排版", titleEn: "Writing & Layout" },
71
71
  { id: "components", title: "内置组件", titleEn: "Built-in Components" },
72
+ { id: "diagrams", title: "推荐图示", titleEn: "Recommended figures" },
72
73
  { id: "workflow", title: "检查与补全", titleEn: "Checks & Shortcuts" },
73
74
  { id: "reference", title: "组件参考", titleEn: "Component reference" },
74
75
  ];
@@ -129,6 +130,7 @@ export const NAV_SHORT_TITLE: Record<string, string> = {
129
130
  "components/link-card": "链接卡片",
130
131
  "components/source": "来源胶囊",
131
132
  "components/image": "本地图片",
133
+ "components/diagrams": "推荐图示",
132
134
  workflow: "语法检查与快捷输入",
133
135
  reference: "总览",
134
136
  };
@@ -1,6 +1,11 @@
1
1
  import type { PluggableList } from "unified";
2
2
  import { markdownSyntaxPlugins } from "../../ui/garden/markdown-syntax.mjs";
3
3
  import { remarkGarden } from "./remark-garden.mjs";
4
+ import { remarkVideoPlayer } from "./remark-video-player.mjs";
4
5
 
5
6
  // Keep article rendering and heading extraction on the same Markdown syntax.
7
+ // The spread + remarkGarden list already fails PluggableList checking on HEAD.
8
+ // Adding another plugin in that literal would report a new slot error even when
9
+ // the plugin itself is a Plugin; append it as a typed Pluggable instead.
6
10
  export const markdownPlugins: PluggableList = [...markdownSyntaxPlugins, remarkGarden];
11
+ markdownPlugins.push(remarkVideoPlayer);
@@ -0,0 +1,10 @@
1
+ import type { Root } from "mdast";
2
+ import type { Plugin } from "unified";
3
+
4
+ /**
5
+ * Remark plugin: a paragraph that is only a YouTube or Bilibili video URL
6
+ * becomes an in-article player. This is the attacher itself (Plugin), not a
7
+ * factory that returns a Plugin.
8
+ */
9
+ declare const remarkVideoPlayer: Plugin<[], Root>;
10
+ export { remarkVideoPlayer };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A paragraph whose only content is one YouTube / Bilibili video link becomes
3
+ * an in-article player. `{card}` keeps a link card. In-sentence links, lists
4
+ * of several links, and code stay unchanged. Historical ```embed fences with
5
+ * a playable URL still render, so older drafts are not left as dead code.
6
+ */
7
+ import { toString } from "mdast-util-to-string";
8
+ import { renderLegacyEmbed, renderPlayerHtml, resolvePlayer } from "../../ui/garden/player.mjs";
9
+
10
+ const CARD_MARKER = "{card}";
11
+
12
+ const meaningful = (children) =>
13
+ (children ?? []).filter((child) => !(child.type === "text" && !child.value.trim()) && child.type !== "break");
14
+
15
+ function htmlNode(value) {
16
+ return { type: "html", value };
17
+ }
18
+
19
+ function asksForCard(node) {
20
+ const children = node.children ?? [];
21
+ for (let index = 0; index < children.length - 1; index += 1) {
22
+ const link = children[index];
23
+ const next = children[index + 1];
24
+ if (link.type === "link" && next.type === "text" && next.value.startsWith(CARD_MARKER)) return true;
25
+ }
26
+ return false;
27
+ }
28
+
29
+ function exclusiveLink(node) {
30
+ const rest = meaningful(node.children);
31
+ return rest.length === 1 && rest[0].type === "link" ? rest[0] : null;
32
+ }
33
+
34
+ function replaceParagraph(parent, index, node) {
35
+ if (asksForCard(node)) return false;
36
+ const link = exclusiveLink(node);
37
+ if (!link) return false;
38
+ const player = resolvePlayer(link.url);
39
+ if (!player.ok) return false;
40
+ const title = toString(link).trim();
41
+ parent.children[index] = htmlNode(renderPlayerHtml(player, {
42
+ title: title && title !== link.url ? title : undefined,
43
+ }));
44
+ return true;
45
+ }
46
+
47
+ function walk(parent) {
48
+ const children = parent.children ?? [];
49
+ for (let index = 0; index < children.length; index += 1) {
50
+ const node = children[index];
51
+ if (node.type === "inlineCode" || node.type === "html") continue;
52
+ if (node.type === "code") {
53
+ if (String(node.lang ?? "").toLowerCase() === "embed") {
54
+ children[index] = htmlNode(renderLegacyEmbed(node.value));
55
+ }
56
+ continue;
57
+ }
58
+ if (node.type === "paragraph") {
59
+ replaceParagraph(parent, index, node);
60
+ continue;
61
+ }
62
+ if (node.children) walk(node);
63
+ }
64
+ }
65
+
66
+ export function remarkVideoPlayer() {
67
+ return (tree) => {
68
+ walk(tree);
69
+ };
70
+ }
@@ -1,5 +1,6 @@
1
1
  import { marked } from "marked";
2
2
  import { columnHref, columns, postHref, posts } from "../lib/content";
3
+ import { postWithBody } from "../lib/content-body";
3
4
  import { configuredSiteUrl, siteDescription, siteName } from "../lib/site";
4
5
  import { contentForFeed } from "../../ui/core/transform.mjs";
5
6
  import { withTrailingSlash } from "../../ui/garden/site-href.mjs";
@@ -20,9 +21,9 @@ const cdata = (value: string) => value.replaceAll("]]>", "]]]]><![CDATA[>");
20
21
  export async function GET() {
21
22
  const origin = configuredSiteUrl();
22
23
  const entries = [
23
- ...posts.map((post) => ({ ...post, path: postHref(post) })),
24
+ ...posts.map((post) => ({ ...postWithBody(post), path: postHref(post) })),
24
25
  ...columns.map((entry) => ({
25
- ...entry,
26
+ ...postWithBody(entry),
26
27
  path: columnHref(entry),
27
28
  })),
28
29
  ].sort((a, b) => Date.parse(b.date) - Date.parse(a.date));