managarten/packages/cards-core/src/render.ts
Till JS daa1ef0513 feat(cards): image / audio attachments on cards via mana-media
Cards can now carry image, audio, and video attachments uploaded to
mana-media (the existing CAS service that already powers picture,
photos, wardrobe, etc.).

Pipeline:
  • lib/media/upload.ts wraps POST /api/v1/media/upload (multipart,
    app=cards). Returns { id, url, kind } with the right variant URL
    per kind (medium for images, full file for audio/video). 25 MB
    cap matches the website-upload pattern.
  • mediaToFieldSnippet(): drops Markdown ![]() for images; raw
    <audio>/<video controls> for the others — the user can later
    tweak attributes by hand.
  • Deck-detail card editor gains a "📎 Anhang" button next to every
    text field (front/back/cloze). Pick → upload → snippet appended
    to the field's content. Loading + error states surfaced inline.

Render:
  • @mana/cards-core/render.ts whitelists `audio`, `source`, `video`
    plus the `controls`/`preload`/`src`/`type` attrs in DOMPurify so
    inline media survives sanitization. Markdown's <img> already
    passed through the default policy.

Wiring:
  • hooks.server.ts injects __PUBLIC_MANA_MEDIA_URL__.
  • compose adds PUBLIC_MANA_MEDIA_URL_CLIENT=https://media.mana.how
    to cards-web.

Phase 2 ideas: drag-drop directly into the textarea, paste-from-
clipboard for screenshots, mana-media auth scoping per user, Anki
import bringing media files along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:52:53 +02:00

34 lines
1.1 KiB
TypeScript

/**
* Markdown render helper for cards.
*
* Pipeline: marked (GFM) → DOMPurify. Used by the card face for basic /
* type-in / basic-reverse, and by the cloze post-processor.
*
* Cloze callers should pass `{ skipParagraph: true }` so a single-line
* fragment doesn't get wrapped in <p>.
*/
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
marked.setOptions({ gfm: true, breaks: true });
export interface RenderOptions {
skipParagraph?: boolean;
}
export function renderMarkdown(source: string, opts: RenderOptions = {}): string {
if (!source) return '';
const raw = marked.parse(source, { async: false }) as string;
let html = DOMPurify.sanitize(raw, {
// `mark` for cloze highlights; `audio`/`source`/`video` for media
// attachments inserted via the editor (the Markdown renderer
// passes inline HTML through, sanitizer is the gate).
ADD_TAGS: ['mark', 'audio', 'source', 'video'],
ADD_ATTR: ['class', 'controls', 'preload', 'src', 'type'],
});
if (opts.skipParagraph) {
html = html.replace(/^\s*<p>/, '').replace(/<\/p>\s*$/, '');
}
return html;
}