| 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 |