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
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>
253 lines
8.5 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function _escapeAttr(str) {
|
|
if (!str) return '';
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
// ── 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;
|
|
},
|
|
};
|
|
}
|