diff --git a/app/lib/generate-claude-md.ts b/app/lib/generate-claude-md.ts index bc0a5dd..1d803fa 100644 --- a/app/lib/generate-claude-md.ts +++ b/app/lib/generate-claude-md.ts @@ -57,6 +57,14 @@ For login, the passphrase is hashed with HMAC-SHA256 and compared against the st Both upload-cover and update-storyline sign messages with the OWS wallet. +**Illustration workflow (for plot files):** +1. Upload image via \`POST /api/publish/upload-plot-image\` → get \`{ cid, url }\` +2. Insert markdown in the plot content: \`![Scene description](url)\` +3. Verify the image renders correctly in Preview before publishing +4. Publish the plot — content is stored on IPFS with an on-chain keccak256 hash + +**WARNING: Content is immutable after publish.** Once published, plot content (including image references) cannot be edited, removed, or changed. Always verify illustrations in Preview before publishing. + ## Stories | Endpoint | Method | Purpose | diff --git a/app/web/components/PreviewPanel.tsx b/app/web/components/PreviewPanel.tsx index 1944158..192191b 100644 --- a/app/web/components/PreviewPanel.tsx +++ b/app/web/components/PreviewPanel.tsx @@ -2,9 +2,48 @@ import { useState, useEffect, useCallback, useRef } from "react"; import ReactMarkdown from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; -import rehypeSanitize from "rehype-sanitize"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { GENRES, LANGUAGES } from "../../../lib/genres"; +/** Custom sanitizer matching plotlink.xyz — allows img with src, alt, title */ +const sanitizeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + img: ["src", "alt", "title"], + }, +}; + +const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; + +/** Find all markdown image references in content */ +function findImageRefs(text: string): Array<{ full: string; alt: string; url: string }> { + const results: Array<{ full: string; alt: string; url: string }> = []; + const re = /!\[([^\]]*)\]\(([^)]+)\)/g; + let m; + while ((m = re.exec(text)) !== null) { + results.push({ full: m[0], alt: m[1], url: m[2] }); + } + return results; +} + +/** Validate image references for publishing */ +function validateImageRefs(text: string): { count: number; warnings: string[] } { + const refs = findImageRefs(text); + const warnings: string[] = []; + for (const ref of refs) { + if (!ref.url.startsWith(IPFS_GATEWAY)) { + warnings.push(`Non-IPFS image URL: ${ref.url.length > 60 ? ref.url.slice(0, 60) + "..." : ref.url}`); + } + } + // Check for malformed image markdown (missing closing bracket/paren) + const malformed = text.match(/!\[[^\]]*\]\([^)]*$|!\[[^\]]*$(?!\])/gm); + if (malformed) { + warnings.push("Malformed image markdown detected — check brackets and parentheses"); + } + return { count: refs.length, warnings }; +} + interface PreviewPanelProps { storyName: string | null; fileName: string | null; @@ -360,6 +399,10 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis // Don't show over-limit warning for already-published files const overLimit = !isPublished && charLimit !== null && charCount > charLimit; + // Pre-publish image validation for pending content + const publishContent = fileData?.content ?? ""; + const imageValidation = !isPublished ? validateImageRefs(publishContent) : { count: 0, warnings: [] }; + return (
{/* Header with file path + tabs */} @@ -422,7 +465,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
{fileData.content} @@ -750,7 +793,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis )}
+ {imageValidation.warnings.length > 0 && ( +
+ {imageValidation.warnings.map((w, i) => ( + {w} + ))} +
+ )} {(isGenesis) && (