1
0
Fork 0
polaris-task-force/src/lib/wiki/templateHtml.ts
Z8MB1E 51f7c803e0 feat(wiki): add scoped template CSS and developer HTML templates
WikiTemplates gains two fields: an optional css code field injected on
pages that use the template (selectors auto-scoped to a hash-based
.ptf-tpl-* class so templates never collide with each other or the app
shell), and a developer-only html field rendered verbatim (sanitized)
in place of the markdown body when transcluded.

- templateCss.ts scopes/sanitizes CSS, incl. nested at-rules; pages
  and the editor live preview inject scoped styles and drop the Card
  chrome in favor of a full-width article
- templateHtml.ts sanitizes HTML and splits prepared markdown around
  expansion sentinels so raw HTML renders without rehype-raw
- templates.ts adds extractTemplateNames/extractPlaceholders/
  extractDirectiveNames; prepareMarkdown threads an htmlTemplates map
- markdown pipeline gains rehype-highlight for fenced code blocks
- styles.css adds wiki prose typography + infobox demo styling
- docs: demo article and Infobox template authoring guide
2026-09-06 05:32:48 -04:00

58 lines
1.9 KiB
TypeScript

/**
* Developer-authored template HTML handling.
*
* Pure module — safe to import client-side. HTML templates are restricted to
* the developer role at the field level; this module sanitizes them (defense
* in depth) and splits prepared markdown around sentinel markers emitted by
* template expansion so raw HTML can be rendered without rehype-raw.
*/
const HTML_SENTINEL_RE = /PTF-TEMPLATE-HTML:(.+?):END/g;
const HTML_DANGEROUS_TAGS_RE =
/<\s*\/?\s*(script|iframe|object|embed|link|meta|base)\b[\s\S]*?(?:\/>|<\/\s*\1\s*>|$)/gi;
const HTML_EVENT_ATTR_RE = /\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi;
const HTML_JS_URL_RE =
/\s(?:href|src|action|formaction)\s*=\s*("\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]*)/gi;
export function sanitizeTemplateHtml(html: string): string {
return html
.replace(HTML_DANGEROUS_TAGS_RE, "")
.replace(HTML_EVENT_ATTR_RE, "")
.replace(HTML_JS_URL_RE, "");
}
export interface PreparedSegment {
readonly type: "markdown" | "html";
readonly value: string;
readonly name?: string;
}
export function splitPreparedHtml(
prepared: string,
htmlTemplates: Record<string, string>,
): PreparedSegment[] {
if (
Object.keys(htmlTemplates).length === 0 ||
!prepared.includes("PTF-TEMPLATE-HTML:")
) {
return [{ type: "markdown", value: prepared }];
}
const segments: PreparedSegment[] = [];
let last = 0;
for (const match of prepared.matchAll(HTML_SENTINEL_RE)) {
const name = match[1].trim();
const html = htmlTemplates[name];
if (html === undefined) continue;
if (match.index > last) {
segments.push({ type: "markdown", value: prepared.slice(last, match.index) });
}
segments.push({ type: "html", value: sanitizeTemplateHtml(html), name });
last = match.index + match[0].length;
}
if (last < prepared.length) {
segments.push({ type: "markdown", value: prepared.slice(last) });
}
return segments;
}