SEV-329: @switchev/web-shared package (formatters, markdown, waitForImage) #1

Merged
bastian merged 1 commits from sev-329/web-shared-package into main 2026-06-09 09:44:30 -04:00
6 changed files with 154 additions and 4 deletions
Showing only changes of commit 137086109c - Show all commits
+28 -4
View File
@@ -1,8 +1,32 @@
{ {
"name": "switchev-shared", "name": "@switchev/web-shared",
"version": "0.1.0", "version": "0.1.0",
"private": true, "description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends",
"description": "Shared constants, validation schemas, and type definitions for SwitchEV",
"type": "module", "type": "module",
"main": "index.js" "exports": {
".": "./src/index.js",
"./format": "./src/format.js",
"./markdown": "./src/markdown.js",
"./wait-image": "./src/wait-image.js"
},
"files": [
"src"
],
"sideEffects": [
"./src/markdown.js"
],
"peerDependencies": {
"dompurify": ">=3",
"marked": ">=12"
},
"scripts": {
"test": "node --test"
},
"publishConfig": {
"registry": "https://git.debyl.io/api/packages/SwitchEV/npm/"
},
"repository": {
"type": "git",
"url": "git+https://git.debyl.io/SwitchEV/shared.git"
}
} }
+39
View File
@@ -0,0 +1,39 @@
// SEV-329: shared display formatters. These were copy-pasted (and subtly
// divergent — SEV-310) across public/vendor/admin. Single source of truth here.
/**
* Friendly label for an internal slug: replaces '-' and '_' with spaces and
* Title-Cases each word. e.g. "contract_pending" → "Contract Pending".
*/
export function formatLabel(val) {
if (!val) return '';
return String(val).replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Friendly status label (alias of formatLabel, named for intent at call sites). */
export const formatStatus = formatLabel;
const TIMELINE_LABELS = {
'4_to_6_months': '46 months',
'6_to_12_months': '612 months',
'12_to_24_months': '1224 months',
flexible: 'Flexible',
};
/** Friendly conversion timeline. Falls back to formatLabel for unknown slugs. */
export function formatTimeline(val) {
return TIMELINE_LABELS[val] || formatLabel(val);
}
const BUDGET_LABELS = {
'under-10k': 'Under $10,000',
'10k-25k': '$10,000 $25,000',
'25k-50k': '$25,000 $50,000',
'50k-100k': '$50,000 $100,000',
'100k-plus': '$100,000+',
};
/** Friendly budget-range label for the wizard's slug values. */
export function formatBudget(val) {
return BUDGET_LABELS[val] || val || '';
}
+26
View File
@@ -0,0 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatLabel, formatStatus, formatTimeline, formatBudget } from './format.js';
test('formatLabel replaces _ and - and title-cases', () => {
assert.equal(formatLabel('contract_pending'), 'Contract Pending');
assert.equal(formatLabel('in-progress'), 'In Progress');
assert.equal(formatLabel(''), '');
assert.equal(formatLabel(null), '');
});
test('formatStatus is formatLabel', () => {
assert.equal(formatStatus('matched'), 'Matched');
});
test('formatTimeline maps known slugs, falls back otherwise', () => {
assert.equal(formatTimeline('6_to_12_months'), '612 months');
assert.equal(formatTimeline('flexible'), 'Flexible');
assert.equal(formatTimeline('something_else'), 'Something Else');
});
test('formatBudget maps known ranges', () => {
assert.equal(formatBudget('25k-50k'), '$25,000 $50,000');
assert.equal(formatBudget('100k-plus'), '$100,000+');
assert.equal(formatBudget(''), '');
});
+5
View File
@@ -0,0 +1,5 @@
// @switchev/web-shared — shared browser helpers for the public/vendor/admin
// SwitchEV frontends. SEV-329.
export { formatLabel, formatStatus, formatTimeline, formatBudget } from './format.js';
export { renderMarkdown } from './markdown.js';
export { waitForImage } from './wait-image.js';
+33
View File
@@ -0,0 +1,33 @@
// SEV-329: shared safe-markdown renderer. marked parses the source; DOMPurify
// enforces a strict allowlist (no scripts, inline handlers, <style>, data: URIs)
// and links get rel="noopener noreferrer" + target="_blank". marked + dompurify
// are peer dependencies (every consuming site already ships them).
import { marked } from 'marked';
import DOMPurify from 'dompurify';
marked.setOptions({ breaks: true, gfm: true });
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && node.hasAttribute('href')) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
const ALLOWED_TAGS = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'em', 'b', 'i', 'u', 's', 'del',
'ul', 'ol', 'li', 'blockquote', 'a', 'code', 'pre',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr',
];
const ALLOWED_ATTR = ['href', 'target', 'rel', 'colspan', 'rowspan'];
/** Render markdown to sanitized HTML. Returns '' on empty/invalid input. */
export function renderMarkdown(src) {
if (!src) return '';
try {
return DOMPurify.sanitize(marked.parse(String(src)), { ALLOWED_TAGS, ALLOWED_ATTR });
} catch (e) {
console.warn('renderMarkdown failed:', e);
return '';
}
}
+23
View File
@@ -0,0 +1,23 @@
// SEV-329: shared waitForImage (was byte-identical in public-site + vendor-site).
//
// SEV-306: uploads quarantine into a private incoming/ prefix and the
// image-processor re-encodes them out to the public URL asynchronously (and
// DELETES anything it can't decode). So the public URL 404s briefly after upload
// and never appears for a rejected file. waitForImage polls (via Image() to
// dodge CORS) until the sanitized object loads ('ready') or times out
// ('rejected'). Callers gate display on this so users never see a broken <img>.
export function waitForImage(url, { timeoutMs = 30000, intervalMs = 1500 } = {}) {
return new Promise((resolve) => {
const start = Date.now();
const attempt = () => {
const img = new Image();
img.onload = () => resolve('ready');
img.onerror = () => {
if (Date.now() - start >= timeoutMs) resolve('rejected');
else setTimeout(attempt, intervalMs);
};
img.src = url + (url.includes('?') ? '&' : '?') + '_cb=' + Date.now();
};
attempt();
});
}