Skip to content
File

Blob: src/sites/static-renderer/node-mappings.tsx

typescript238 lines
1import { Fragment, type ReactNode } from "react";
2import { normalizeCalloutKind } from "@/shared/editor/schema/callout-model";
3import { resolveLanguage } from "@/shared/editor/schema/code-block-model";
4import { CalloutPresentation } from "@/shared/editor/presentation/callout";
5import { highlightCodeToSegments } from "@/shared/editor/highlight/code-highlight-runtime";
6import { CodeBlockPresentation, getCodeLanguageLabel } from "@/shared/editor/presentation/code-block";
7import { PageMentionPresentation } from "@/shared/editor/presentation/page-mention";
8import { readOutlineLevel, type OutlineLevel } from "@/shared/editor/model/outline";
9import type { SitesImageProps } from "@/shared/sites/island-schemas";
10import { readOptionalSitesReactRenderContext } from "@/sites/react-render-context";
11import { CopyCodeButtonIslandHost } from "@/sites/island-hosts/copy-code-button";
12import { SitesImageIslandHost } from "@/sites/island-hosts/sites-image";
13import { readOptionalString, readPositiveNumber } from "./attrs";
14import { normalizeImageBrightness } from "@/shared/editor/schema/image-model";
15import type { StaticNodeMappingProps } from "./types";
16 
17type HeadingTag = `h${OutlineLevel}`;
18type ParagraphTextAlign = "left" | "center" | "right" | "justify";
19 
20const HEADING_TAGS: Record<OutlineLevel, HeadingTag> = {
21 1: "h1",
22 2: "h2",
23 3: "h3",
24 4: "h4",
25 5: "h5",
26 6: "h6",
27};
28 
29export function createBlandSitesStaticNodeMappings() {
30 const renderContext = readOptionalSitesReactRenderContext();
31 const pageContext = renderContext?.kind === "page" ? renderContext : null;
32 let headingIndex = 0;
33 
34 return {
35 heading: ({ node, children }: StaticNodeMappingProps) => {
36 const level = readOutlineLevel(node.attrs.level);
37 const Tag = readHeadingTag(level);
38 const bid = readOptionalString(node.attrs.bid);
39 const anchorId = pageContext ? readHeadingAnchorId(pageContext.headingAnchorIds, headingIndex++) : undefined;
40 const textAlign = readParagraphTextAlign(node.attrs.textAlign);
41 const style = textAlign ? { textAlign } : undefined;
42 return (
43 <Tag id={anchorId} data-bid={bid ?? undefined} style={style}>
44 {children}
45 </Tag>
46 );
47 },
48 paragraph: ({ node, children }: StaticNodeMappingProps) => {
49 const bid = readOptionalString(node.attrs.bid);
50 const textAlign = readParagraphTextAlign(node.attrs.textAlign);
51 const style = textAlign ? { textAlign } : undefined;
52 return (
53 <p data-bid={bid ?? undefined} style={style}>
54 {node.childCount === 0 ? <br /> : children}
55 </p>
56 );
57 },
58 taskItem: ({ node, children }: StaticNodeMappingProps) => {
59 const checked = readBooleanTaskChecked(node.attrs.checked);
60 return (
61 <li data-type="taskItem" data-checked={checked ? "true" : "false"}>
62 <label>
63 <input
64 type="checkbox"
65 defaultChecked={checked}
66 disabled
67 aria-label={getTaskItemCheckboxLabel(node.textContent, checked)}
68 />
69 <span />
70 </label>
71 <div>{children}</div>
72 </li>
73 );
74 },
75 details: ({ node, children }: StaticNodeMappingProps) => {
76 const open = node.attrs.open === true || node.attrs.open === "true";
77 const bid = readOptionalString(node.attrs.bid);
78 return (
79 <details className="tiptap-details" data-bid={bid ?? undefined} open={open}>
80 {children}
81 </details>
82 );
83 },
84 detailsSummary: ({ node, children }: StaticNodeMappingProps) => {
85 const placeholder = readOptionalString(node.attrs.placeholder) ?? "Summary";
86 return (
87 <summary className="tiptap-details-summary" data-placeholder={placeholder}>
88 {node.childCount === 0 ? <br /> : children}
89 </summary>
90 );
91 },
92 detailsContent: ({ children }: StaticNodeMappingProps) => (
93 <div className="tiptap-details-content" data-type="detailsContent">
94 {children}
95 </div>
96 ),
97 callout: ({ node, children }: StaticNodeMappingProps) => (
98 <CalloutPresentation
99 bid={readOptionalString(node.attrs.bid)}
100 kind={normalizeCalloutKind(node.attrs.kind)}
101 readOnly
102 >
103 {children}
104 </CalloutPresentation>
105 ),
106 codeBlock: ({ node }: StaticNodeMappingProps) => {
107 const language = resolveLanguage(node.attrs.language);
108 return (
109 <CodeBlockPresentation
110 bid={readOptionalString(node.attrs.bid)}
111 language={language}
112 controls={<StaticCodeBlockControls language={language} />}
113 >
114 {renderHighlightedCode(language, node.textContent)}
115 </CodeBlockPresentation>
116 );
117 },
118 image: ({ node }: StaticNodeMappingProps) => {
119 const imageProps = readImageProps(node);
120 if (!imageProps) return null;
121 return <SitesImageIslandHost {...imageProps} />;
122 },
123 pageMention: ({ node }: StaticNodeMappingProps) => {
124 const pageId = readOptionalString(node.attrs.pageId);
125 const mention = pageId && pageContext ? pageContext.resolvePageMention(pageId) : null;
126 return (
127 <PageMentionPresentation
128 pageId={pageId}
129 label={mention?.label}
130 href={mention?.href}
131 icon={mention?.icon}
132 kind={mention?.kind}
133 ariaLabel={mention?.ariaLabel}
134 />
135 );
136 },
137 tableRow: ({ node, children }: StaticNodeMappingProps) => {
138 const height = readPositiveNumber(node.attrs.height);
139 const style = height ? { height: `${height}px` } : undefined;
140 return <tr style={style}>{children}</tr>;
141 },
142 tableCell: ({ node, children }: StaticNodeMappingProps) => {
143 const colSpan = readPositiveNumber(node.attrs.colspan) ?? 1;
144 const rowSpan = readPositiveNumber(node.attrs.rowspan) ?? 1;
145 const colwidth = Array.isArray(node.attrs.colwidth) ? (node.attrs.colwidth as number[]) : null;
146 return (
147 <td colSpan={colSpan} rowSpan={rowSpan} data-colwidth={colwidth ? colwidth.join(",") : undefined}>
148 {children}
149 </td>
150 );
151 },
152 tableHeader: ({ node, children }: StaticNodeMappingProps) => {
153 const colSpan = readPositiveNumber(node.attrs.colspan) ?? 1;
154 const rowSpan = readPositiveNumber(node.attrs.rowspan) ?? 1;
155 const colwidth = Array.isArray(node.attrs.colwidth) ? (node.attrs.colwidth as number[]) : null;
156 return (
157 <th colSpan={colSpan} rowSpan={rowSpan} data-colwidth={colwidth ? colwidth.join(",") : undefined}>
158 {children}
159 </th>
160 );
161 },
162 };
163}
164 
165function readHeadingTag(level: OutlineLevel): HeadingTag {
166 return HEADING_TAGS[level];
167}
168 
169function readHeadingAnchorId(anchorIds: readonly (string | null)[], index: number): string | undefined {
170 const anchorId = anchorIds[index];
171 return typeof anchorId === "string" ? anchorId : undefined;
172}
173 
174function readParagraphTextAlign(value: unknown): ParagraphTextAlign | null {
175 switch (value) {
176 case "left":
177 case "center":
178 case "right":
179 case "justify":
180 return value;
181 default:
182 return null;
183 }
184}
185 
186function readImageProps(node: StaticNodeMappingProps["node"]): SitesImageProps | null {
187 const src = readOptionalString(node.attrs.src);
188 if (!src) return null;
189 const align = node.attrs.align === "center" || node.attrs.align === "right" ? node.attrs.align : "left";
190 return {
191 bid: readOptionalString(node.attrs.bid),
192 src,
193 alt: readOptionalString(node.attrs.alt),
194 title: readOptionalString(node.attrs.title),
195 align,
196 width: readPositiveNumber(node.attrs.width),
197 naturalWidth: readPositiveNumber(node.attrs.naturalWidth),
198 naturalHeight: readPositiveNumber(node.attrs.naturalHeight),
199 brightness: normalizeImageBrightness(node.attrs.brightness),
200 };
201}
202 
203function readBooleanTaskChecked(value: unknown): boolean {
204 return value === true || value === "true" || value === "";
205}
206 
207function getTaskItemCheckboxLabel(textContent: string, checked: boolean): string {
208 const label = textContent.trim() || "empty task item";
209 return `Task item checkbox for ${label}${checked ? ", checked" : ", unchecked"}`;
210}
211 
212function StaticCodeBlockControls({ language }: { language: string }) {
213 const displayName = getCodeLanguageLabel(language);
214 
215 return (
216 <div className="tiptap-code-block-controls" contentEditable={false}>
217 <CopyCodeButtonIslandHost />
218 <button type="button" className="tiptap-code-block-lang-btn" aria-label={`Language: ${displayName}`} disabled>
219 {displayName}
220 </button>
221 </div>
222 );
223}
224 
225function renderHighlightedCode(language: string, code: string): ReactNode[] {
226 return highlightCodeToSegments(language, code).map((segment, index) => {
227 if (segment.classes.length === 0) {
228 return <Fragment key={index}>{segment.text}</Fragment>;
229 }
230 
231 return (
232 <span key={index} className={segment.classes.join(" ")}>
233 {segment.text}
234 </span>
235 );
236 });
237}