This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/sw/sdk/markdown.js
Jeffrey Smith abf71162b1
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
Feat v0.6.12 extension css isolation
Prefix enforcement prevents extension CSS from leaking into the kernel
or sibling extensions. All 12 in-tree packages migrated to .ext-{slug}-*
naming convention.

- Add data-ext attribute to extension mount container
- Add CSS linter (scripts/lint-package-css.sh) enforcing .ext-{slug} prefix
- Add kernel CSS contract doc (docs/EXTENSION-CSS.md)
- Migrate 12 packages: chat, dashboard, editor, git-board, hello-dashboard,
  icd-test-runner, notes, schedules, sdk-test-runner, tasks,
  team-activity-log, workflow-demo (CSS + JS in lockstep)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:54:51 +00:00

253 lines
8.5 KiB
JavaScript

// ==========================================
// Armature — SDK: Markdown Renderer
// ==========================================
// Unified markdown rendering via `marked` (v16, vendored).
// Delegates fenced code blocks to sw.renderers and
// sanitizes output with DOMPurify.
//
// Factory: createMarkdown(renderers)
// ==========================================
/**
* Create the markdown module.
*
* @param {object} renderers — sw.renderers instance
* @returns {object} markdown
*/
export function createMarkdown(renderers) {
let _marked = null;
let _purify = null;
let _markedLoading = null;
let _purifyLoading = null;
const BASE = () => window.__BASE__ || '';
// ── Lazy loaders ──────────────────────────
function _loadMarked() {
if (_marked) return Promise.resolve(_marked);
if (_markedLoading) return _markedLoading;
_markedLoading = new Promise((resolve, reject) => {
if (window.marked) {
_marked = window.marked;
_configure();
resolve(_marked);
return;
}
const script = document.createElement('script');
script.src = `${BASE()}/vendor/marked.min.js`;
script.onload = () => {
_marked = window.marked;
_configure();
resolve(_marked);
};
script.onerror = () => {
reject(new Error('[sw.markdown] Failed to load marked'));
};
document.head.appendChild(script);
});
return _markedLoading;
}
function _loadPurify() {
if (_purify) return Promise.resolve(_purify);
if (_purifyLoading) return _purifyLoading;
_purifyLoading = new Promise((resolve, reject) => {
if (window.DOMPurify) {
_purify = window.DOMPurify;
resolve(_purify);
return;
}
const script = document.createElement('script');
script.src = `${BASE()}/vendor/purify.min.js`;
script.onload = () => {
_purify = window.DOMPurify;
resolve(_purify);
};
script.onerror = () => {
reject(new Error('[sw.markdown] Failed to load DOMPurify'));
};
document.head.appendChild(script);
});
return _purifyLoading;
}
// ── Marked configuration ──────────────────
function _configure() {
if (!_marked) return;
_marked.use({
// Wikilink tokenizer extension: [[Page Name]] and [[Page Name|Display]]
extensions: [{
name: 'wikilink',
level: 'inline',
start(src) {
return src.indexOf('[[');
},
tokenizer(src) {
const match = src.match(/^\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/);
if (match) {
return {
type: 'wikilink',
raw: match[0],
target: match[1].trim(),
display: (match[2] || match[1]).trim(),
};
}
},
renderer(token) {
const target = _escapeAttr(token.target);
const display = _escapeHtml(token.display);
return `<a class="ext-notes-wikilink" data-wikilink="${target}" href="javascript:void(0)">${display}</a>`;
},
}],
// Custom code block renderer — delegates to sw.renderers
renderer: {
code({ text, lang }) {
if (lang && renderers) {
const container = document.createElement('div');
if (renderers.runBlockRenderers(lang, text, container)) {
return container.innerHTML;
}
}
// Default: standard <pre><code>
const langClass = lang ? ` class="language-${_escapeAttr(lang)}"` : '';
return `<pre><code${langClass}>${_escapeHtml(text)}</code></pre>\n`;
},
},
});
}
// ── DOMPurify config ──────────────────────
// Allow SVG elements (for mermaid) + data attributes (for all renderers)
const PURIFY_CONFIG = {
ADD_TAGS: [
'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line',
'polyline', 'polygon', 'text', 'tspan', 'defs', 'marker',
'foreignObject', 'use', 'clipPath', 'mask', 'pattern',
'linearGradient', 'radialGradient', 'stop', 'title', 'desc',
],
ADD_ATTR: [
'viewBox', 'd', 'fill', 'stroke', 'stroke-width', 'stroke-dasharray',
'xmlns', 'xmlns:xlink', 'transform', 'cx', 'cy', 'r', 'rx', 'ry',
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'width', 'height',
'preserveAspectRatio', 'points', 'font-size', 'font-family',
'text-anchor', 'dominant-baseline', 'dy', 'dx',
'marker-start', 'marker-mid', 'marker-end',
'refX', 'refY', 'markerWidth', 'markerHeight', 'orient',
'gradientTransform', 'gradientUnits', 'offset', 'stop-color', 'stop-opacity',
'clip-path', 'opacity', 'fill-opacity', 'stroke-opacity',
],
ALLOW_DATA_ATTR: true,
};
// ── Helpers ───────────────────────────────
function _escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ── Public API ────────────────────────────
return {
/**
* Render markdown source to HTML string.
* Loads marked lazily on first call.
*
* @param {string} src — markdown source
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true] — run DOMPurify on output
* @returns {Promise<string>} HTML
*/
async render(src, opts = {}) {
const sanitize = opts.sanitize !== false;
await _loadMarked();
let html = _marked.parse(src || '');
if (sanitize) {
await _loadPurify();
html = _purify.sanitize(html, PURIFY_CONFIG);
}
return html;
},
/**
* Synchronous render — only works after marked has been loaded.
* Falls back to escaping if marked isn't ready.
*
* @param {string} src
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true]
* @returns {string} HTML
*/
renderSync(src, opts = {}) {
const sanitize = opts.sanitize !== false;
if (!_marked) {
return _escapeHtml(src || '');
}
let html = _marked.parse(src || '');
if (sanitize && _purify) {
html = _purify.sanitize(html, PURIFY_CONFIG);
}
return html;
},
/**
* Render markdown into a DOM element, then run post renderers.
*
* @param {HTMLElement} container
* @param {string} src
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true]
*/
async renderToElement(container, src, opts = {}) {
const html = await this.render(src, opts);
container.innerHTML = html;
if (renderers) {
renderers.runPostRenderers(container);
}
},
/**
* Preload both marked and DOMPurify.
* Call early to avoid latency on first render.
*/
async preload() {
await Promise.all([_loadMarked(), _loadPurify()]);
},
/** True if marked is loaded and ready for sync use. */
get ready() {
return !!_marked;
},
};
}