render-row.ts (8491B)
1 // Pure renderers shared by the catalog island (client) and catalog.astro 2 // (server-rendered first page): chips, badges, one technique row. No DOM, no 3 // side effects — the same function builds the SSR'd rows and the fetched ones, 4 // which is what lets the island adopt the server's markup without a re-render. 5 6 import { toolRoute, type Technique } from './techniques'; 7 import { PLATFORM_META, CAPABILITY_META, SOURCE_META } from './taxonomy'; 8 import { escapeHtml as esc, highlightCommand } from './highlight'; 9 import { url } from './url'; 10 import { FACET_KEYS, facetValues, searchText, type CatalogFilters } from './techniques'; 11 12 /** Rows per page, server and client alike. */ 13 export const PAGE = 40; 14 15 /** 16 * The fields the list view needs — exactly what public/data/index-<hash>.json 17 * carries (scripts/build-index.mjs keeps an identical list; a test pins them 18 * together). Everything the text query scans is here, so search behaves the 19 * same as over the full record; detection, references, paths and the rest 20 * live on the tool page the row links to. 21 */ 22 export const LIST_FIELDS = [ 23 'id', 'toolId', 'toolName', 'name', 'source', 'platform', 'capability', 'nativeCategory', 24 'command', 'description', 'usecase', 'mitre', 'privilege', 'added', 25 'context', 'requires', 'services', 'environment', 'aliases', 'availability', 'verification', 26 'reviewedAt', 'compatibility', 'expected', 'troubleshooting', 'sideEffects', 'restore', 'template', 'references', 'detection', 27 ] as const; 28 export type ListTechnique = Pick<Technique, (typeof LIST_FIELDS)[number]>; 29 30 export function toListTechnique(t: Technique): ListTechnique { 31 const out: Partial<Record<(typeof LIST_FIELDS)[number], unknown>> = {}; 32 for (const k of LIST_FIELDS) if (t[k] !== undefined) out[k] = t[k]; 33 return out as ListTechnique; 34 } 35 36 // ---- chips ----------------------------------------------------------------- 37 export function chip(kind: string, value: string, label: string, accent: string, active: boolean, count: number): string { 38 return `<button type="button" class="cat-chip" role="checkbox" aria-checked="${active}" data-facet="${kind}" data-value="${esc(value)}" style="--acc: var(--${accent});"> 39 <span class="cat-chip__lbl">${esc(label)}</span><span class="cat-chip__n">${count}</span></button>`; 40 } 41 42 export function facetGroup(title: string, kind: string, values: readonly string[], meta: (v: string) => string, active: string[], counts: Record<string, number>): string { 43 const chips = values.filter((v) => counts[v]).map((v) => chip(kind, v, v, meta(v), active.includes(v), counts[v] || 0)).join(''); 44 return `<section class="cat-facet"><p class="cat-facet__head eyebrow"><span class="eyebrow__mark">^:</span><span>${esc(title)}</span></p><div class="cat-facet__chips">${chips}</div></section>`; 45 } 46 47 export type FacetCounts = Partial<Record<(typeof FACET_KEYS)[number], Record<string, number>>>; 48 export function totalFacetCounts(rows: Technique[]): FacetCounts { 49 return Object.fromEntries(FACET_KEYS.map(k => { 50 const counts: Record<string, number> = {}; 51 for (const t of rows) for (const v of facetValues(t, k)) counts[v] = (counts[v] || 0) + 1; 52 return [k, counts]; 53 })); 54 } 55 56 /** The whole facet rail: three groups, chip counts are dataset totals. */ 57 export function renderFacets(active: Pick<CatalogFilters, 'platform' | 'capability' | 'source'> & Partial<CatalogFilters>, counts: FacetCounts): string { 58 const titles = { platform: 'Operating system / target', capability: 'Capability', source: 'Collection', context: 'Execution context', requires: 'Required access', services: 'Service', environment: 'Environment', availability: 'Availability', verification: 'Evidence' }; 59 return FACET_KEYS.map(k => facetGroup(titles[k], k, Object.keys(counts[k] || {}).sort(), () => k === 'source' ? 'rose' : 'foam', active[k] || [], counts[k] || {})).join(''); 60 } 61 62 // ---- badges ---------------------------------------------------------------- 63 export const badge = (label: string, accent: string, extra = '') => 64 `<span class="tbadge ${extra}" style="--acc: var(--${accent});">${esc(label)}</span>`; 65 66 // ---- row ------------------------------------------------------------------- 67 /** A DOM id for the detail panel so the expander can name what it controls. */ 68 export const panelId = (id: string) => `td-${id.replace(/[^A-Za-z0-9_-]+/g, '-')}`; 69 70 export function renderRow(t: ListTechnique, variants: ListTechnique[] = []): string { 71 const caps = t.capability.map((c) => badge(c, CAPABILITY_META[c]?.accent || 'foam')).join(''); 72 const plats = t.platform.map((p) => badge(p, PLATFORM_META[p]?.accent || 'foam', 'tbadge--soft')).join(''); 73 const src = SOURCE_META[t.source]; 74 const srcBadge = badge(src.label, src.accent, 'tbadge--src'); 75 const newBadge = t.added && t.source !== 'DAEMON' ? `<span class="tbadge tbadge--new" style="--acc: var(--love);">DÆMON</span>` : ''; 76 const label = t.name && t.name !== t.toolName ? `<span class="trow__sub">${esc(t.name)}</span>` : ''; 77 const route = url(toolRoute(t.toolId)); 78 const pid = panelId(t.id); 79 80 const mitre = t.mitre?.length ? `<div class="tdetail__row"><span class="tdetail__k">MITRE</span><span class="tdetail__v">${t.mitre.map((m) => `<a href="https://attack.mitre.org/techniques/${esc(m.replace('.', '/'))}/" target="_blank" rel="noopener" class="tchip-link">${esc(m)}</a>`).join(' ')}</span></div>` : ''; 81 const priv = t.privilege ? `<div class="tdetail__row"><span class="tdetail__k">Context</span><span class="tdetail__v">${esc(t.privilege)}</span></div>` : ''; 82 const desc = t.description ? `<p class="tdetail__desc">${esc(t.description)}</p>` : ''; 83 const use = t.usecase ? `<p class="tdetail__use"><span class="tdetail__k">Use</span> ${esc(t.usecase)}</p>` : ''; 84 const more = `<a class="tdetail__more" href="${route}#${esc(t.id)}">Full details on the ${esc(t.toolName)} page — detection, references, paths →</a>`; 85 86 return `<article class="trow" data-id="${esc(t.id)}"> 87 <div class="trow__head"> 88 <button class="trow__inspect" type="button" data-inspect aria-label="Details for ${esc(t.toolName)}" aria-controls="catalog-inspector">↗</button> 89 <span class="trow__name"><a href="${route}" class="trow__tool">${esc(t.toolName)}</a>${label}</span> 90 <span class="trow__badges">${srcBadge}${newBadge}${caps}${plats}</span> 91 </div> 92 ${variants.length > 1 ? `<label class="trow__variants">${variants.length} matching variants <select data-variant aria-label="Technique variant for ${esc(t.toolName)}">${variants.map(v => `<option value="${esc(v.id)}" ${v.id === t.id ? 'selected' : ''}>${esc(v.name || v.capability.join(', '))} · ${esc(v.context || v.privilege || 'see prerequisites')}</option>`).join('')}</select></label>` : ''} 93 <div class="trow__cmdbar" data-cmdbar> 94 <pre class="trow__cmd"><code>${highlightCommand(t.command)}</code></pre> 95 <button class="trow__copy" type="button" data-copy aria-label="Copy command">copy</button> 96 </div> 97 <div class="trow__actions"><span>${esc(t.context || t.privilege || t.environment?.join(', ') || 'See prerequisites')}</span><button data-save type="button" aria-pressed="false">Save</button><button data-inspect type="button" aria-controls="catalog-inspector">${t.template ? 'Configure & details' : 'Details & sources'}</button></div> 98 <div class="trow__detail" id="${pid}" hidden> 99 ${desc}${use} 100 <div class="tdetail__grid">${priv}${mitre}</div> 101 ${more} 102 </div> 103 </article>`; 104 } 105 106 export const EMPTY_HTML = `<div class="cat-empty"><p class="eyebrow" style="--acc: var(--love);"><span class="eyebrow__mark">^:</span><span>No matches</span></p><p>Nothing matches this filter. Loosen a facet or clear the search.</p></div>`; 107 108 /** The count line and the "Show more" label, so server and client agree. */ 109 export function countLabel(shown: number, total: number, activeFacets: number, addedOnly: boolean): string { 110 return `<strong>${shown.toLocaleString()}</strong> of ${total.toLocaleString()} techniques${activeFacets ? ` · ${activeFacets} filter${activeFacets === 1 ? '' : 's'}` : ''}${addedOnly ? ' · DÆMON authored' : ''}`; 111 } 112 export function moreLabel(remaining: number): string { 113 return `Show ${Math.min(remaining, PAGE)} more · ${remaining} remaining`; 114 } 115 116 /** 117 * The same fields lib/techniques.ts#matchesQuery scans, lowercased once per 118 * record at boot instead of once per record per keystroke. 119 */ 120 export function haystack(t: ListTechnique): string { 121 return searchText(t as Technique); 122 }