feat: introduce archify skill for generating architecture diagrams
- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
escapeHtml as esc,
|
||||
localizeTemplate,
|
||||
resolveLocale,
|
||||
translateMessage,
|
||||
viewerCatalog,
|
||||
} from './i18n.mjs';
|
||||
|
||||
export { esc };
|
||||
|
||||
export function renderDefinitions() {
|
||||
return ` <!-- Definitions -->
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-default" />
|
||||
</marker>
|
||||
<marker id="arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
|
||||
</marker>
|
||||
<marker id="arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-security" />
|
||||
</marker>
|
||||
<marker id="arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
|
||||
</marker>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>`;
|
||||
}
|
||||
|
||||
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: `<rect x="2" y="3" width="12" height="10" rx="2"/>
|
||||
<path d="M2 6.5h12"/>
|
||||
<circle cx="4.1" cy="4.8" r=".7" class="sigil-fill"/>
|
||||
<circle cx="6.3" cy="4.8" r=".7" class="sigil-fill"/>`,
|
||||
backend: `<path d="M6 3 3 8l3 5M10 3l3 5-3 5"/>`,
|
||||
database: `<ellipse cx="8" cy="4" rx="5" ry="2"/>
|
||||
<path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>`,
|
||||
cloud: `<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>`,
|
||||
security: `<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/>
|
||||
<path d="m5.8 8 1.5 1.5 3-3"/>`,
|
||||
messagebus: `<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>
|
||||
<circle cx="5" cy="4.5" r="1" class="sigil-fill"/>
|
||||
<circle cx="10.5" cy="8" r="1" class="sigil-fill"/>
|
||||
<circle cx="7" cy="11.5" r="1" class="sigil-fill"/>`,
|
||||
external: `<rect x="2.5" y="5" width="8.5" height="8" rx="1.5"/>
|
||||
<path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>`,
|
||||
start: `<circle cx="8" cy="8" r="5"/>
|
||||
<path d="m7 5.4 3.6 2.6L7 10.6Z" class="sigil-fill"/>`,
|
||||
active: `<path d="M2 8h3l1.5-3.5L9 12l1.6-4H14"/>`,
|
||||
waiting: `<path d="M4 2.5h8M4 13.5h8M5 3c0 2.8 2 3.2 3 5-1 1.8-3 2.2-3 5M11 3c0 2.8-2 3.2-3 5 1 1.8 3 2.2 3 5"/>`,
|
||||
success: `<circle cx="8" cy="8" r="5.3"/>
|
||||
<path d="m5.2 8 1.8 1.8 3.8-4"/>`,
|
||||
failure: `<circle cx="8" cy="8" r="5.3"/>
|
||||
<path d="m5.7 5.7 4.6 4.6m0-4.6-4.6 4.6"/>`,
|
||||
neutral: `<rect x="3" y="3" width="10" height="10" rx="2"/>
|
||||
<circle cx="8" cy="8" r="1.2" class="sigil-fill"/>`,
|
||||
};
|
||||
|
||||
// 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 `<g aria-hidden="true" data-semantic-sigil="${esc(normalized)}" class="semantic-sigil s-${tone}" transform="translate(${x} ${y}) scale(${scale})">
|
||||
${SIGIL_SHAPE[normalized]}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
export function renderCards(cards) {
|
||||
const list = Array.isArray(cards) ? cards : [];
|
||||
return ` <!-- Info Cards -->
|
||||
<div class="cards">
|
||||
${list.map((card) => ` <div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot ${esc(card.dot)}"></div>
|
||||
<h3>${esc(card.title)}</h3>
|
||||
</div>
|
||||
<ul>
|
||||
${card.items.map((item) => ` <li>• ${esc(item)}</li>`).join('\n')}
|
||||
</ul>
|
||||
</div>`).join('\n\n')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const SVG_SLOT_RE = / <!-- ARCHIFY:SVG_SLOT_START -->[\s\S]*? <!-- ARCHIFY:SVG_SLOT_END -->/;
|
||||
const CARDS_SLOT_RE = / <!-- ARCHIFY:CARDS_SLOT_START -->[\s\S]*? <!-- ARCHIFY:CARDS_SLOT_END -->/;
|
||||
const SUBTITLE_SLOT_RE = /^([ \t]*)<p class="subtitle">\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
|
||||
const GUIDED_VIEWS_PLACEHOLDER = '<!-- ARCHIFY:GUIDED_VIEWS_DATA -->';
|
||||
const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
|
||||
const I18N_PLACEHOLDER = ' <!-- ARCHIFY:I18N_DATA -->';
|
||||
|
||||
function serializeScriptJson(value) {
|
||||
return JSON.stringify(value)
|
||||
.replaceAll('<', '\\u003c')
|
||||
.replaceAll('>', '\\u003e')
|
||||
.replaceAll('&', '\\u0026');
|
||||
}
|
||||
|
||||
const TEMPLATE_PLACEHOLDERS = [
|
||||
'<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
|
||||
'<title>[PROJECT NAME] Architecture Diagram</title>',
|
||||
'<h1>[PROJECT NAME] Architecture</h1>',
|
||||
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()
|
||||
? `<p class="subtitle">${esc(subtitle)}</p>`
|
||||
: '';
|
||||
const i18nData = ` <script id="archify-i18n-data" type="application/json">${i18nJson}</script>`;
|
||||
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], () => `<html lang="${esc(resolvedLocale)}" data-theme="dark" data-preset="${esc(visualPreset)}">`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(translateMessage(resolvedLocale, 'page.title', { title }))}</title>`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[2], () => `<h1>${esc(title)}</h1>`)
|
||||
.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, () => `<script id="archify-guided-views-data" type="application/json">${guidedViewsJson}</script>`)
|
||||
.replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
|
||||
? ` <script id="archify-source-evidence-data" type="application/json">${sourceEvidenceJson}</script>`
|
||||
: '');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user