diff --git a/app/components/type/TextBlock/TextBlock.view.tsx b/app/components/type/TextBlock/TextBlock.view.tsx index fb62b5e..b752e19 100644 --- a/app/components/type/TextBlock/TextBlock.view.tsx +++ b/app/components/type/TextBlock/TextBlock.view.tsx @@ -3,6 +3,8 @@ import { memo } from "react"; import type { TextBlockProps } from "./TextBlock.types"; +type TextRow = NonNullable[number]; + /** * Figma: Utility / **Community Rule / Text Block** (22001:29793). * Title + body paragraphs and/or labeled rows (12px between stacks, 8px label→body). @@ -38,6 +40,45 @@ function ParagraphGroup({ text }: { text: string }) { ); } +function LabeledRowView({ row }: { row: TextRow }) { + const imageSrc = row.imageUrl?.trim(); + const fileHref = row.fileUrl?.trim(); + const caption = row.body.trim(); + const hasCaption = caption.length > 0; + const alt = hasCaption ? caption : row.label; + const linkText = hasCaption ? caption : fileHref; + + return ( +
+

{row.label}

+ {imageSrc ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */} + {alt} + {hasCaption ? : null} + + ) : fileHref ? ( +

+ + {linkText} + +

+ ) : ( + + )} +
+ ); +} + function TextBlockView({ title, body = "", @@ -54,42 +95,7 @@ function TextBlockView({

{title}

{hasRows - ? rows!.map((row, i) => { - const imageSrc = row.imageUrl?.trim(); - const fileHref = row.fileUrl?.trim(); - const caption = row.body.trim(); - return ( -
-

{row.label}

- {imageSrc ? ( - <> - {/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */} - {caption.length 0 ? caption : row.label} - className="max-h-[240px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain" - /> - {caption.length > 0 ? ( - - ) : null} - - ) : fileHref ? ( -

- - {caption.length > 0 ? caption : fileHref} - -

- ) : ( - - )} -
- ); - }) + ? rows!.map((row, i) => ) : body.trim().length > 0 && }
diff --git a/lib/create/ruleExport.ts b/lib/create/ruleExport.ts index 92d90dd..359dbb8 100644 --- a/lib/create/ruleExport.ts +++ b/lib/create/ruleExport.ts @@ -2,6 +2,7 @@ import { jsPDF } from "jspdf"; import type { CommunityRuleEntry, + CommunityRuleLabeledBlock, CommunityRuleSection, } from "../../app/components/type/CommunityRule/CommunityRule.types"; import type { StoredLastPublishedRule } from "./lastPublishedRule"; @@ -23,20 +24,30 @@ export function exportFilenameBase(rule: StoredLastPublishedRule): string { return fromTitle.length > 0 ? fromTitle : `rule-${rule.id.slice(0, 8)}`; } +function labeledBlockMedia(b: CommunityRuleLabeledBlock): { + img?: string; + file?: string; + bodyTrim: string; +} { + const imgRaw = b.imageUrl?.trim(); + const fileRaw = b.fileUrl?.trim(); + return { + img: imgRaw && imgRaw.length > 0 ? imgRaw : undefined, + file: fileRaw && fileRaw.length > 0 ? fileRaw : undefined, + bodyTrim: b.body.trim(), + }; +} + function entryToMarkdown(entry: CommunityRuleEntry): string { const lines: string[] = [`### ${entry.title}`, ""]; if (entry.blocks && entry.blocks.length > 0) { for (const b of entry.blocks) { lines.push(`#### ${b.label}`, ""); - const img = b.imageUrl?.trim(); - const file = b.fileUrl?.trim(); + const { img, file, bodyTrim } = labeledBlockMedia(b); if (img) { - lines.push( - `![${b.body.trim() || b.label}](${img})`, - "", - ); + lines.push(`![${bodyTrim || b.label}](${img})`, ""); } else if (file) { - lines.push(`[${b.body.trim() || "file"}](${file})`, ""); + lines.push(`[${bodyTrim || "file"}](${file})`, ""); } else { lines.push(b.body, ""); } @@ -98,16 +109,14 @@ export function sectionsToCsv( for (const ent of sec.entries) { if (ent.blocks && ent.blocks.length > 0) { for (const b of ent.blocks) { - const img = b.imageUrl?.trim(); - const file = b.fileUrl?.trim(); - const content = - img != null && img.length > 0 - ? b.body.trim().length > 0 - ? `${b.body}\n${img}` - : img - : file != null && file.length > 0 - ? `${b.body}\n${file}` - : b.body; + const { img, file, bodyTrim } = labeledBlockMedia(b); + const content = img + ? bodyTrim.length > 0 + ? `${b.body}\n${img}` + : img + : file + ? `${b.body}\n${file}` + : b.body; rows.push([sec.categoryName, ent.title, b.label, content]); } } else { @@ -158,15 +167,14 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string { if (entry.blocks && entry.blocks.length > 0) { for (const b of entry.blocks) { inner += `

${escapeHtml(b.label)}

`; - const img = b.imageUrl?.trim(); - const file = b.fileUrl?.trim(); + const { img, file, bodyTrim } = labeledBlockMedia(b); if (img) { - inner += `

${escapeHtml(b.body.trim() || b.label)}

`; - if (b.body.trim().length > 0) { + inner += `

${escapeHtml(bodyTrim || b.label)}

`; + if (bodyTrim.length > 0) { inner += paragraphsHtml(b.body); } } else if (file) { - inner += `

${escapeHtml(b.body.trim() || file)}

`; + inner += `

${escapeHtml(bodyTrim || file)}

`; } else { inner += paragraphsHtml(b.body); } diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts index 44600a2..f64ede8 100644 --- a/lib/create/ruleSectionsFromMethodSelections.ts +++ b/lib/create/ruleSectionsFromMethodSelections.ts @@ -16,6 +16,7 @@ export function wizardUploadDisplaysAsImage( if (assetUrl && /\.(jpe?g|png|gif|webp)(\?|#|$)/i.test(assetUrl)) return true; return false; } + /** * Serialize wizard-authored field blocks into Community Rule labeled rows for * read-only surfaces (completed step, exported views). Matches how those blocks diff --git a/tests/unit/lib/ruleExport.test.ts b/tests/unit/lib/ruleExport.test.ts index 51aa338..77f6315 100644 --- a/tests/unit/lib/ruleExport.test.ts +++ b/tests/unit/lib/ruleExport.test.ts @@ -95,6 +95,45 @@ describe("ruleExport", () => { expect(csv).toContain(",Title,,My Rule"); }); + it("sectionsToMarkdown, sectionsToCsv, and printable HTML include imageUrl and fileUrl blocks", () => { + const sections: CommunityRuleSection[] = [ + { + categoryName: "Values", + entries: [ + { + title: "Entry", + body: "", + blocks: [ + { + label: "Photo", + body: "Caption", + imageUrl: "https://cdn.example.com/pic.jpg", + }, + { + label: "Handbook", + body: "Download", + fileUrl: "https://cdn.example.com/guidance.pdf", + }, + ], + }, + ], + }, + ]; + const md = sectionsToMarkdown("Rule", null, sections); + expect(md).toContain("![Caption](https://cdn.example.com/pic.jpg)"); + expect(md).toContain("[Download](https://cdn.example.com/guidance.pdf)"); + + const csv = sectionsToCsv("Rule", null, sections); + expect(csv).toContain("Caption\nhttps://cdn.example.com/pic.jpg"); + expect(csv).toContain("Download\nhttps://cdn.example.com/guidance.pdf"); + + const html = buildPrintableRuleHtmlDocument("Rule", null, sections); + expect(html).toContain('src="https://cdn.example.com/pic.jpg"'); + expect(html).toContain("Caption"); + expect(html).toContain('href="https://cdn.example.com/guidance.pdf"'); + expect(html).toContain("Download"); + }); + it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => { const sections: CommunityRuleSection[] = [ {