diff --git a/app/components/type/CommunityRule/CommunityRule.types.ts b/app/components/type/CommunityRule/CommunityRule.types.ts
index 0dc9e21..a1724c3 100644
--- a/app/components/type/CommunityRule/CommunityRule.types.ts
+++ b/app/components/type/CommunityRule/CommunityRule.types.ts
@@ -1,7 +1,12 @@
/** Labeled paragraph group (Figma “Text” stacks under Membership / Decision-making, etc.). */
export interface CommunityRuleLabeledBlock {
label: string;
+ /** With {@link imageUrl}, optional caption paragraphs only (not the uploaded file name). */
body: string;
+ /** Image URL (e.g. custom method upload). Rendered as `
` when set. */
+ imageUrl?: string;
+ /** Non-image attachment URL. Rendered as a link when set and {@link imageUrl} is absent. */
+ fileUrl?: string;
}
export interface CommunityRuleEntry {
diff --git a/app/components/type/TextBlock/TextBlock.view.tsx b/app/components/type/TextBlock/TextBlock.view.tsx
index dcc2a65..fb62b5e 100644
--- a/app/components/type/TextBlock/TextBlock.view.tsx
+++ b/app/components/type/TextBlock/TextBlock.view.tsx
@@ -54,12 +54,42 @@ function TextBlockView({
{title}
{hasRows
- ? rows!.map((row, i) => (
-
- ))
+ ? 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 */}
+

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}
+
+
+ ) : (
+
+ )}
+
+ );
+ })
: body.trim().length > 0 &&
}
diff --git a/lib/create/documentEntryGuards.ts b/lib/create/documentEntryGuards.ts
index b2bef26..38cb3a9 100644
--- a/lib/create/documentEntryGuards.ts
+++ b/lib/create/documentEntryGuards.ts
@@ -6,7 +6,10 @@ import type {
function isLabeledBlock(x: unknown): x is CommunityRuleLabeledBlock {
if (!x || typeof x !== "object") return false;
const o = x as Record;
- return typeof o.label === "string" && typeof o.body === "string";
+ if (typeof o.label !== "string" || typeof o.body !== "string") return false;
+ if (o.imageUrl !== undefined && typeof o.imageUrl !== "string") return false;
+ if (o.fileUrl !== undefined && typeof o.fileUrl !== "string") return false;
+ return true;
}
/** Shared by publish payload parsing and template body parsing — keep in sync. */
diff --git a/lib/create/ruleExport.ts b/lib/create/ruleExport.ts
index 7319c13..92d90dd 100644
--- a/lib/create/ruleExport.ts
+++ b/lib/create/ruleExport.ts
@@ -27,7 +27,19 @@ 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}`, "", b.body, "");
+ lines.push(`#### ${b.label}`, "");
+ const img = b.imageUrl?.trim();
+ const file = b.fileUrl?.trim();
+ if (img) {
+ lines.push(
+ ``,
+ "",
+ );
+ } else if (file) {
+ lines.push(`[${b.body.trim() || "file"}](${file})`, "");
+ } else {
+ lines.push(b.body, "");
+ }
}
} else {
const body = (entry.body ?? "").trim();
@@ -86,7 +98,17 @@ export function sectionsToCsv(
for (const ent of sec.entries) {
if (ent.blocks && ent.blocks.length > 0) {
for (const b of ent.blocks) {
- rows.push([sec.categoryName, ent.title, b.label, b.body]);
+ 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;
+ rows.push([sec.categoryName, ent.title, b.label, content]);
}
} else {
rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]);
@@ -136,7 +158,18 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string {
if (entry.blocks && entry.blocks.length > 0) {
for (const b of entry.blocks) {
inner += `${escapeHtml(b.label)}
`;
- inner += paragraphsHtml(b.body);
+ const img = b.imageUrl?.trim();
+ const file = b.fileUrl?.trim();
+ if (img) {
+ inner += `})
`;
+ if (b.body.trim().length > 0) {
+ inner += paragraphsHtml(b.body);
+ }
+ } else if (file) {
+ inner += `${escapeHtml(b.body.trim() || file)}
`;
+ } else {
+ inner += paragraphsHtml(b.body);
+ }
}
} else {
inner += paragraphsHtml(entry.body ?? "");
diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts
index ed05f4b..44600a2 100644
--- a/lib/create/ruleSectionsFromMethodSelections.ts
+++ b/lib/create/ruleSectionsFromMethodSelections.ts
@@ -7,6 +7,15 @@ import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import { templateCategoryToGroupKey } from "./templateReviewMapping";
+/** Uses filename extension and/or URL path so uploads render as `
` vs file link on read-only surfaces. */
+export function wizardUploadDisplaysAsImage(
+ fileName: string | null,
+ assetUrl: string | null,
+): boolean {
+ if (fileName && /\.(jpe?g|png|gif|webp)$/i.test(fileName)) return true;
+ 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
@@ -32,8 +41,23 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
case "upload": {
const name = nonEmptyTrimmed(b.fileName);
const url = nonEmptyTrimmed(b.assetUrl);
- const body = name ?? url;
- if (body) out.push({ label: b.blockTitle, body });
+ if (url) {
+ if (wizardUploadDisplaysAsImage(name, url)) {
+ out.push({
+ label: b.blockTitle,
+ body: "",
+ imageUrl: url,
+ });
+ } else {
+ out.push({
+ label: b.blockTitle,
+ body: name ?? url,
+ fileUrl: url,
+ });
+ }
+ } else if (name) {
+ out.push({ label: b.blockTitle, body: name });
+ }
break;
}
case "proportion":
diff --git a/tests/components/TextBlock.test.tsx b/tests/components/TextBlock.test.tsx
index d1124a6..fa52913 100644
--- a/tests/components/TextBlock.test.tsx
+++ b/tests/components/TextBlock.test.tsx
@@ -1,9 +1,11 @@
-import { describe } from "vitest";
+import { describe, it, expect } from "vitest";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
import TextBlock from "../../app/components/type/TextBlock";
+import { screen } from "@testing-library/react";
+import { renderWithProviders as render } from "../utils/test-utils";
type Props = React.ComponentProps;
@@ -23,4 +25,24 @@ const config: ComponentTestSuiteConfig = {
describe("TextBlock", () => {
componentTestSuite(config);
+
+ it("renders labeled row imageUrl as img", () => {
+ render(
+ ,
+ );
+ const img = screen.getByRole("img", { name: "Photo" });
+ expect(img).toHaveAttribute(
+ "src",
+ "/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
+ );
+ });
});
diff --git a/tests/unit/publishedDocumentToDisplaySections.test.ts b/tests/unit/publishedDocumentToDisplaySections.test.ts
index e258581..dd9dc28 100644
--- a/tests/unit/publishedDocumentToDisplaySections.test.ts
+++ b/tests/unit/publishedDocumentToDisplaySections.test.ts
@@ -208,4 +208,44 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
{ label: "Expectations", body: "Answer stored only on field blocks." },
]);
});
+
+ it("exposes custom upload image blocks with imageUrl for CommunityRule display", () => {
+ const customId = "b7c0a9f3-0000-4000-8000-000000000002";
+ const doc = {
+ sections: [],
+ methodSelections: {
+ communication: [
+ {
+ id: customId,
+ label: "Policy with photo",
+ sections: {
+ corePrinciple: "",
+ logisticsAdmin: "",
+ codeOfConduct: "",
+ },
+ },
+ ],
+ },
+ customMethodCardFieldBlocksById: {
+ [customId]: [
+ {
+ kind: "upload" as const,
+ id: "u1",
+ blockTitle: "Site photo",
+ fileName: "garden.jpg",
+ assetUrl: "/api/uploads/11111111-1111-4111-8111-111111111111",
+ },
+ ],
+ },
+ };
+ const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
+ const comm = out.find((s) => s.categoryName === "Communication");
+ expect(comm?.entries[0]?.blocks).toEqual([
+ {
+ label: "Site photo",
+ body: "",
+ imageUrl: "/api/uploads/11111111-1111-4111-8111-111111111111",
+ },
+ ]);
+ });
});