返回 CodeWhale
doc-article.tsx
根目录 / web / app / [locale] / docs / _components / doc-article.tsx
1 import Link from "next/link";
2 import { Fragment, type ReactNode } from "react";
3 import { getDocsShell } from "@/lib/i18n/dictionaries";
4 import type { DocsBlock, DocsPageDict } from "@/lib/i18n/dictionaries/types";
5
6 /**
7 * <DocArticle> — the one renderer for every task page under /docs.
8 *
9 * A page answers three questions in order: what you can do here and why
10 * (title + lede), how (sections of prose, commands, tables and steps), and
11 * what to do next (closing links). All copy comes from a `DocsPageDict`; the
12 * page file only picks the dictionary and supplies metadata.
13 *
14 * Inline syntax inside prose strings is deliberately tiny:
15 * `code` → <code class="inline">
16 * [label](/path) → a link; a leading "/" is made locale-relative
17 * Nothing else is interpreted, so dictionary text cannot inject markup.
18 */
19
20 /** Links are underlined so they never rely on color alone (WCAG 1.4.1). */
21 const LINK = "docs-inline-link underline underline-offset-2 hover:text-indigo";
22
23 const INLINE = /(`[^`]+`|\[[^\]]+\]\([^)\s]+\))/g;
24
25 export function renderInline(text: string, locale: string): ReactNode {
26 return text.split(INLINE).map((part, i) => {
27 if (part.startsWith("`") && part.endsWith("`") && part.length > 1) {
28 return (
29 <code key={i} className="inline">
30 {part.slice(1, -1)}
31 </code>
32 );
33 }
34 const link = part.match(/^\[([^\]]+)\]\(([^)\s]+)\)$/);
35 if (link) {
36 const [, label, href] = link;
37 if (href.startsWith("/")) {
38 return (
39 <Link key={i} href={`/${locale}${href}`} className={LINK}>
40 {label}
41 </Link>
42 );
43 }
44 return (
45 <a key={i} href={href} className={LINK} target="_blank" rel="noreferrer">
46 {label}
47 </a>
48 );
49 }
50 return <Fragment key={i}>{part}</Fragment>;
51 });
52 }
53
54 function Block({
55 block,
56 locale,
57 body,
58 noteLabel,
59 }: {
60 block: DocsBlock;
61 locale: string;
62 body: string;
63 noteLabel: string;
64 }) {
65 if ("p" in block) {
66 return <p className={`${body} mt-3`}>{renderInline(block.p, locale)}</p>;
67 }
68 if ("code" in block) {
69 return (
70 <pre tabIndex={0} className="code-block mt-4" aria-label={block.lang}>
71 <code>{block.code}</code>
72 </pre>
73 );
74 }
75 if ("rows" in block) {
76 return (
77 <dl className="docs-ref-rows mt-4">
78 {block.rows.map(([term, detail]) => (
79 <div key={term}>
80 <dt>{block.codeTerms ? <code className="inline">{term}</code> : renderInline(term, locale)}</dt>
81 <dd>{renderInline(detail, locale)}</dd>
82 </div>
83 ))}
84 </dl>
85 );
86 }
87 if ("steps" in block) {
88 return (
89 <ol className={`${body} docs-steps mt-3 list-decimal space-y-2 pl-6`}>
90 {block.steps.map((step) => (
91 <li key={step}>{renderInline(step, locale)}</li>
92 ))}
93 </ol>
94 );
95 }
96 if ("list" in block) {
97 return (
98 <ul className={`${body} docs-list mt-3 list-disc space-y-2 pl-6`}>
99 {block.list.map((item) => (
100 <li key={item}>{renderInline(item, locale)}</li>
101 ))}
102 </ul>
103 );
104 }
105 return (
106 <p className={`${body} docs-note hairline-l mt-4 pl-4`}>
107 <strong>{noteLabel}</strong>{" "}
108 {renderInline(block.note, locale)}
109 </p>
110 );
111 }
112
113 export function DocArticle({
114 t,
115 locale,
116 extra,
117 }: {
118 t: DocsPageDict;
119 locale: string;
120 /** Page-owned content rendered at the end of the section with this id. */
121 extra?: Record<string, ReactNode>;
122 }) {
123 const shell = getDocsShell(locale);
124 return (
125 <div className="docs-article space-y-10">
126 <header id="overview" className="scroll-mt-32">
127 <h1 className="font-display text-3xl mb-1">{t.title}</h1>
128 <p className={`${t.bodyClassName} docs-lede mt-3`}>{renderInline(t.lede, locale)}</p>
129 {t.sections.length > 2 && (
130 <nav className="docs-toc mt-5" aria-label={shell.onThisPage}>
131 <ol className="flex flex-wrap gap-x-5 gap-y-1 text-sm">
132 {t.sections.map((section) => (
133 <li key={section.id}>
134 <a href={`#${section.id}`} className={LINK}>
135 {section.title}
136 </a>
137 </li>
138 ))}
139 </ol>
140 </nav>
141 )}
142 </header>
143
144 {t.sections.map((section) => (
145 <section key={section.id} id={section.id} className="scroll-mt-32">
146 <h2 className="font-display text-2xl mb-1">{section.title}</h2>
147 {section.blocks.map((block, i) => (
148 <Block
149 key={i}
150 block={block}
151 locale={locale}
152 body={t.bodyClassName}
153 noteLabel={shell.noteLabel}
154 />
155 ))}
156 {extra?.[section.id]}
157 </section>
158 ))}
159
160 {t.next.length > 0 && (
161 <section id="next" className="scroll-mt-32">
162 <h2 className="font-display text-2xl mb-1">{shell.nextHeading}</h2>
163 <div className="hairline-t mt-4">
164 {t.next.map((item) => (
165 <div key={item.href} className="py-4 hairline-b">
166 <h3 className="font-display text-xl">
167 <Link href={`/${locale}${item.href}`} className={LINK}>
168 {item.label}
169 </Link>
170 </h3>
171 <p className={`${t.bodyClassName} mt-1 text-sm`}>{renderInline(item.note, locale)}</p>
172 </div>
173 ))}
174 </div>
175 </section>
176 )}
177
178 {/* Maintainer pointer: kept out of the rendered copy. */}
179 <div hidden data-source-note={t.sourceNote} />
180 </div>
181 );
182 }
183
183 lines Plain Text