import {
escapeHtml as esc,
localizeTemplate,
resolveLocale,
translateMessage,
viewerCatalog,
} from './i18n.mjs';
export { esc };
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 I18N_PLACEHOLDER = ' ';
function serializeScriptJson(value) {
return JSON.stringify(value)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
}
const TEMPLATE_PLACEHOLDERS = [
'',
'
[PROJECT NAME] Architecture Diagram',
'[PROJECT NAME] Architecture
',
GUIDED_VIEWS_PLACEHOLDER,
];
export function applyTemplate(template, {
title,
subtitle,
svg,
cards,
locale,
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 = serializeScriptJson(guidedViews);
const sourceEvidenceJson = serializeScriptJson(sourceEvidence);
const resolvedLocale = resolveLocale(locale);
const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) });
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
? `${esc(subtitle)}
`
: '';
const i18nData = ` `;
const localizedTemplate = localizeTemplate(template, resolvedLocale);
const templateWithI18n = localizedTemplate.includes(I18N_PLACEHOLDER)
? localizedTemplate.replace(I18N_PLACEHOLDER, () => i18nData)
: localizedTemplate.replace(GUIDED_VIEWS_PLACEHOLDER, () => `${i18nData}\n ${GUIDED_VIEWS_PLACEHOLDER}`);
return templateWithI18n
.replace(TEMPLATE_PLACEHOLDERS[0], () => ``)
.replace(TEMPLATE_PLACEHOLDERS[1], () => `${esc(translateMessage(resolvedLocale, 'page.title', { title }))}`)
.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.
// Code points that take two columns of advance width: East Asian Wide and
// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols
// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which
// render at the same square advance as the supplementary-plane emoji already
// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming:
// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram /
// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and
// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because
// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK
// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as
// ranges because V8 has no \p{East_Asian_Width=W} property escape.
const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\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;
// A variation selector (U+FE00-U+FE0F) carries no advance of its own: it
// re-presents the character before it. VS15 (U+FE0E) asks for text
// presentation, which renders narrow; VS16 (U+FE0F) asks for emoji
// presentation, which renders at the square emoji advance. So a base plus a
// selector is measured from the selector, not from the base -- otherwise
// widening the emoji-presentation bases above turns U+2B50 U+FE0F from two
// units into three while the glyph on screen stays one square, and leaves
// U+2708 U+FE0F at two only because its base happens to be narrow.
//
// A selector following a base that cannot take emoji presentation is
// malformed input; measuring it wide is the safe direction here, since
// over-measuring pads a box while under-measuring spills the label out of it.
const VARIATION_SELECTOR_FIRST = 0xfe00;
const VARIATION_SELECTOR_LAST = 0xfe0f;
const VARIATION_SELECTOR_TEXT = 0xfe0e;
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
export function textUnits(text) {
const chars = Array.from(String(text ?? ''));
let units = 0;
for (let i = 0; i < chars.length; i += 1) {
const codePoint = chars[i].codePointAt(0);
if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue;
const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1;
if (next === VARIATION_SELECTOR_EMOJI) units += 2;
else if (next === VARIATION_SELECTOR_TEXT) units += 1;
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
}
return units;
}