const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
export function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
}
export function renderDefinitions() {
return `
`;
}
const SIGIL_TONE = {
frontend: 'frontend',
start: 'frontend',
backend: 'backend',
active: 'backend',
database: 'database',
success: 'database',
cloud: 'cloud',
waiting: 'cloud',
security: 'security',
failure: 'security',
messagebus: 'messagebus',
external: 'external',
neutral: 'external',
};
const SIGIL_SHAPE = {
frontend: `
`,
backend: ``,
database: `
`,
cloud: ``,
security: `
`,
messagebus: `
`,
external: `
`,
start: `
`,
active: ``,
waiting: ``,
success: `
`,
failure: `
`,
neutral: `
`,
};
// A quiet, renderer-owned role stamp. It is authored SVG content rather than a
// viewer overlay, so it survives canonical export while adding no focus target,
// accessible name, layout box, or interaction state of its own.
export function renderSemanticSigil(kind, { x, y, size = 11 } = {}) {
const normalized = Object.hasOwn(SIGIL_SHAPE, kind) ? kind : 'neutral';
const tone = SIGIL_TONE[normalized] || 'external';
const scale = size / 16;
return `
${SIGIL_SHAPE[normalized]}
`;
}
export function renderCards(cards) {
const list = Array.isArray(cards) ? cards : [];
return `
${list.map((card) => `
${card.items.map((item) => ` - • ${esc(item)}
`).join('\n')}
`).join('\n\n')}
`;
}
const SVG_SLOT_RE = / [\s\S]*? /;
const CARDS_SLOT_RE = / [\s\S]*? /;
const SUBTITLE_SLOT_RE = /^([ \t]*)\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
const GUIDED_VIEWS_PLACEHOLDER = '';
const SOURCE_EVIDENCE_PLACEHOLDER = ' ';
const TEMPLATE_PLACEHOLDERS = [
'',
'
[PROJECT NAME] Architecture Diagram',
'[PROJECT NAME] Architecture
',
GUIDED_VIEWS_PLACEHOLDER,
];
export function applyTemplate(template, { title, subtitle, svg, cards, visualPreset = 'classic', guidedViews = [], sourceEvidence = null }) {
if (!SVG_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel');
}
if (!CARDS_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel');
}
if (!SUBTITLE_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing subtitle placeholder');
}
for (const ph of TEMPLATE_PLACEHOLDERS) {
if (!template.includes(ph)) {
throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`);
}
}
// Keep existing custom templates compatible when evidence is not requested.
// Silently dropping verified evidence would be misleading, so the new slot
// becomes mandatory only for the opt-in evidence path.
if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) {
throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`);
}
// Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels,
// or rendered SVG must not be interpreted as a replacement pattern.
const guidedViewsJson = JSON.stringify(guidedViews)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
const sourceEvidenceJson = JSON.stringify(sourceEvidence)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
? `${esc(subtitle)}
`
: '';
return template
.replace(TEMPLATE_PLACEHOLDERS[0], () => ``)
.replace(TEMPLATE_PLACEHOLDERS[1], () => `${esc(title)} Diagram`)
.replace(TEMPLATE_PLACEHOLDERS[2], () => `${esc(title)}
`)
.replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle
? `${indent}${renderedSubtitle}${newline}`
: '')
.replace(SVG_SLOT_RE, () => svg)
.replace(CARDS_SLOT_RE, () => cards)
.replace(GUIDED_VIEWS_PLACEHOLDER, () => ``)
.replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
? ` `
: '');
}
// CJK and other wide/fullwidth glyphs render at roughly twice the advance
// width of ASCII in the monospace stacks the template uses. Keep halfwidth
// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges
// also cover vertical punctuation and supplementary East Asian scripts that
// literal glyph ranges made difficult to audit.
const FULLWIDTH_RE = /[\u1100-\u115F\u2329-\u232A\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
export function textUnits(text) {
let units = 0;
for (const ch of String(text ?? '')) units += FULLWIDTH_RE.test(ch) ? 2 : 1;
return units;
}