Merge pull request 'SEV-329: @switchev/web-shared package (formatters, markdown, waitForImage)' (#1) from sev-329/web-shared-package into main
This commit was merged in pull request #1.
This commit is contained in:
+28
-4
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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