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
58 lines
1.9 KiB
TypeScript
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;
|
|
}
|