SEV-329: @switchev/web-shared package (formatters, markdown, waitForImage) #1
+28
-4
@@ -1,8 +1,32 @@
|
||||
{
|
||||
"name": "switchev-shared",
|
||||
"name": "@switchev/web-shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Shared constants, validation schemas, and type definitions for SwitchEV",
|
||||
"description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': '4–6 months',
|
||||
'6_to_12_months': '6–12 months',
|
||||
'12_to_24_months': '12–24 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 || '';
|
||||
}
|
||||
@@ -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'), '6–12 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(''), '');
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user