diff --git a/.github/workflows/deploy-marketing.yml b/.github/workflows/deploy-marketing.yml index b53f09d..9a39119 100644 --- a/.github/workflows/deploy-marketing.yml +++ b/.github/workflows/deploy-marketing.yml @@ -41,6 +41,9 @@ jobs: - name: Build marketing run: pnpm --filter @singleton-sd/marketing... run build + - name: Smoke marketing tokens + run: pnpm --filter @singleton-sd/marketing run test:smoke + - name: Validate SWA config (dist) run: node scripts/validate-staticwebapp-config.mjs apps/marketing/dist/staticwebapp.config.json diff --git a/.github/workflows/preview-marketing.yml b/.github/workflows/preview-marketing.yml index 540a1f7..f8c54ae 100644 --- a/.github/workflows/preview-marketing.yml +++ b/.github/workflows/preview-marketing.yml @@ -42,6 +42,9 @@ jobs: - name: Build marketing run: pnpm --filter @singleton-sd/marketing... run build + - name: Smoke marketing tokens + run: pnpm --filter @singleton-sd/marketing run test:smoke + - name: Validate SWA config (dist) run: node scripts/validate-staticwebapp-config.mjs apps/marketing/dist/staticwebapp.config.json diff --git a/apps/marketing/package.json b/apps/marketing/package.json index f98b810..2f8335b 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -9,7 +9,8 @@ "build": "node ../../scripts/validate-staticwebapp-config.mjs public/staticwebapp.config.json && node ./scripts/copy-token-css.mjs && astro build", "preview": "astro preview", "lint": "node ../../scripts/validate-staticwebapp-config.mjs public/staticwebapp.config.json && astro check", - "test": "node --test ../../scripts/validate-staticwebapp-config.test.mjs src/lib/site.test.mjs && node ../../scripts/validate-staticwebapp-config.mjs public/staticwebapp.config.json && astro check" + "test": "node --test ../../scripts/validate-staticwebapp-config.test.mjs src/lib/site.test.mjs scripts/smoke-tokens.test.mjs && node ../../scripts/validate-staticwebapp-config.mjs public/staticwebapp.config.json && astro check", + "test:smoke": "node ./scripts/smoke-tokens.mjs" }, "dependencies": { "@astrojs/check": "^0.9.4", diff --git a/apps/marketing/scripts/smoke-tokens.mjs b/apps/marketing/scripts/smoke-tokens.mjs new file mode 100644 index 0000000..8d303c2 --- /dev/null +++ b/apps/marketing/scripts/smoke-tokens.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node +/** + * Smoke the marketing Astro build for published @singleton-sd/tokens. + * + * Asserts dist CSS uses --ssd-* (not legacy --fg-*) and HTML does not load + * token sheets from tokens.design.singletonsd.com. Also checks Decap vendor + * copies from copy-token-css.mjs. + * + * Usage: + * node ./scripts/smoke-tokens.mjs [distDir] + * Default distDir: apps/marketing/dist (Astro outDir). + */ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Keep in sync with copy-token-css.mjs destination filenames. */ +export const VENDOR_TOKEN_CSS = ['tokens-dark.css', 'tokens-root-dark.css']; + +export const REQUIRED_SSD_VARS = [ + '--ssd-color-text-default', + '--ssd-color-background-default', +]; + +const TOKEN_CDN = 'tokens.design.singletonsd.com'; +/** Matches --fg-* only when used as a CSS custom property (var() or declaration). */ +const LEGACY_FG_RE = /(?:var\s*\(|(?:^|[{;,\s]))(--fg-)/m; + +/** + * @param {string} root + * @param {(filePath: string) => boolean} predicate + * @param {string[]} [diagnostics] - receives unreadable-directory warnings + * @returns {string[]} + */ +export function listFiles(root, predicate, diagnostics) { + /** @type {string[]} */ + const found = []; + + /** + * @param {string} dir + */ + function walk(dir) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (err) { + const msg = `Could not read directory ${dir}: ${err instanceof Error ? err.message : err}`; + if (diagnostics) { + diagnostics.push(msg); + } else { + console.warn(`[smoke-tokens] ${msg}`); + } + return; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (entry.isFile() && predicate(full)) { + found.push(full); + } + } + } + + if (existsSync(root) && statSync(root).isDirectory()) { + walk(root); + } + return found.sort(); +} + +/** + * @param {string} filePath + * @param {string} distDir + * @returns {string} + */ +export function distRelative(filePath, distDir) { + return relative(distDir, filePath).split('\\').join('/'); +} + +/** + * @param {string} filePath + * @param {string} distDir + * @returns {boolean} + */ +export function isVendorCss(filePath, distDir) { + const rel = distRelative(filePath, distDir); + return rel.startsWith('admin/vendor/') && rel.endsWith('.css'); +} + +/** + * @param {string} css + * @param {string[]} requiredVars + * @returns {string[]} missing var names + */ +export function missingRequiredSsdVars(css, requiredVars = REQUIRED_SSD_VARS) { + return requiredVars.filter((name) => !css.includes(name)); +} + +/** + * @param {string} css + * @returns {boolean} + */ +export function cssHasLegacyFgVars(css) { + return LEGACY_FG_RE.test(css); +} + +/** + * @param {string} html + * @returns {boolean} + */ +export function htmlHasTokenCdnLink(html) { + return html.toLowerCase().includes(TOKEN_CDN); +} + +/** + * @param {string} distDir + * @returns {string[]} + */ +export function missingVendorTokenCss(distDir) { + const vendorDir = join(distDir, 'admin', 'vendor'); + return VENDOR_TOKEN_CSS.filter((name) => { + const filePath = join(vendorDir, name); + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + return true; + } + const css = readFileSync(filePath, 'utf8'); + return css.trim().length === 0 || !css.includes('--ssd-'); + }); +} + +/** + * Scan a marketing Astro dist folder. + * + * @param {string} distDir + * @throws {Error} when assertions fail or dist is missing + */ +export function smokeMarketingTokens(distDir) { + const dist = resolve(distDir); + if (!existsSync(dist) || !statSync(dist).isDirectory()) { + throw new Error( + `Marketing dist not found at ${dist}. Run \`pnpm --filter @singleton-sd/marketing run build\` first.`, + ); + } + + /** @type {string[]} */ + const errors = []; + + const cssFiles = listFiles(dist, (filePath) => filePath.endsWith('.css'), errors); + const htmlFiles = listFiles(dist, (filePath) => filePath.endsWith('.html'), errors); + + if (cssFiles.length === 0) { + errors.push(`No CSS files under ${dist}.`); + } + if (htmlFiles.length === 0) { + errors.push(`No HTML files under ${dist}.`); + } + + const siteCss = cssFiles + .filter((filePath) => !isVendorCss(filePath, dist)) + .map((filePath) => readFileSync(filePath, 'utf8')) + .join('\n'); + const missingSsd = missingRequiredSsdVars(siteCss); + if (missingSsd.length > 0) { + errors.push( + `Built site CSS is missing published token vars: ${missingSsd.join(', ')}.`, + ); + } + + for (const filePath of cssFiles) { + const css = readFileSync(filePath, 'utf8'); + if (cssHasLegacyFgVars(css)) { + errors.push( + `${distRelative(filePath, dist)} contains legacy --fg- custom properties.`, + ); + } + } + + for (const filePath of htmlFiles) { + const html = readFileSync(filePath, 'utf8'); + if (htmlHasTokenCdnLink(html)) { + errors.push( + `${distRelative(filePath, dist)} links token sheets from ${TOKEN_CDN}.`, + ); + } + } + + const missingVendor = missingVendorTokenCss(dist); + if (missingVendor.length > 0) { + errors.push( + `Missing or empty vendored token CSS under admin/vendor: ${missingVendor.join(', ')}.`, + ); + } + + if (errors.length > 0) { + throw new Error(`Marketing token smoke failed:\n${errors.map((line) => `- ${line}`).join('\n')}`); + } +} + +const isDirectRun = process.argv[1] + ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; + +if (isDirectRun) { + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const defaultDist = resolve(scriptDir, '../dist'); + const distDir = process.argv[2] ? resolve(process.argv[2]) : defaultDist; + + try { + smokeMarketingTokens(distDir); + console.log(`OK token smoke ${distDir}`); + process.exit(0); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/apps/marketing/scripts/smoke-tokens.test.mjs b/apps/marketing/scripts/smoke-tokens.test.mjs new file mode 100644 index 0000000..7aaccce --- /dev/null +++ b/apps/marketing/scripts/smoke-tokens.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { + cssHasLegacyFgVars, + htmlHasTokenCdnLink, + missingRequiredSsdVars, + smokeMarketingTokens, +} from './smoke-tokens.mjs'; + +/** + * @param {Record} files + * @returns {string} temp dist dir + */ +function writeDist(files) { + const root = mkdtempSync(join(tmpdir(), 'mkt-token-smoke-')); + for (const [rel, content] of Object.entries(files)) { + const full = join(root, rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + } + return root; +} + +const VALID_SITE_CSS = ` +:root { + --ssd-color-text-default: #111; + --ssd-color-background-default: #fff; +} +.text-fg { color: var(--ssd-color-text-default); } +`; + +const VALID_VENDOR_CSS = ` +[data-theme="dark"] { + --ssd-color-text-default: #eee; + --ssd-color-background-default: #111; +} +`; + +const VALID_HTML = ``; + +const VALID_ADMIN_HTML = ` + + + +`; + +/** + * @returns {Record} + */ +function validTree() { + return { + '_astro/index.css': VALID_SITE_CSS, + 'index.html': VALID_HTML, + 'admin/index.html': VALID_ADMIN_HTML, + 'admin/vendor/tokens-dark.css': VALID_VENDOR_CSS, + 'admin/vendor/tokens-root-dark.css': VALID_VENDOR_CSS, + }; +} + +test('missingRequiredSsdVars reports unpublished keys', () => { + assert.deepEqual(missingRequiredSsdVars('--ssd-color-text-default: #111;'), [ + '--ssd-color-background-default', + ]); + assert.deepEqual( + missingRequiredSsdVars( + '--ssd-color-text-default: #111; --ssd-color-background-default: #fff;', + ), + [], + ); +}); + +test('cssHasLegacyFgVars ignores product .text-fg and --pk-fg', () => { + assert.equal(cssHasLegacyFgVars('.text-fg { color: var(--ssd-color-text-default); }'), false); + assert.equal(cssHasLegacyFgVars('--pk-fg: var(--ssd-color-text-default);'), false); + assert.equal(cssHasLegacyFgVars('--fg-text: #111;'), true); +}); + +test('htmlHasTokenCdnLink detects token gallery host', () => { + assert.equal(htmlHasTokenCdnLink(''), false); + assert.equal( + htmlHasTokenCdnLink(''), + true, + ); +}); + +test('smokeMarketingTokens accepts a valid dist tree', () => { + const dist = writeDist(validTree()); + try { + assert.doesNotThrow(() => smokeMarketingTokens(dist)); + } finally { + rmSync(dist, { recursive: true, force: true }); + } +}); + +test('smokeMarketingTokens fails when dist is missing', () => { + assert.throws( + () => smokeMarketingTokens(join(tmpdir(), 'mkt-token-smoke-missing-dist')), + /Marketing dist not found/, + ); +}); + +test('smokeMarketingTokens fails when site CSS lacks --ssd- vars', () => { + const dist = writeDist({ + ...validTree(), + '_astro/index.css': '.text-fg { color: black; }', + }); + try { + assert.throws( + () => smokeMarketingTokens(dist), + /missing published token vars: --ssd-color-text-default, --ssd-color-background-default/, + ); + } finally { + rmSync(dist, { recursive: true, force: true }); + } +}); + +test('smokeMarketingTokens fails when CSS uses legacy --fg- vars', () => { + const dist = writeDist({ + ...validTree(), + '_astro/index.css': `${VALID_SITE_CSS}\n--fg-text: #111;`, + }); + try { + assert.throws(() => smokeMarketingTokens(dist), /legacy --fg- custom properties/); + } finally { + rmSync(dist, { recursive: true, force: true }); + } +}); + +test('smokeMarketingTokens fails when HTML loads the token CDN', () => { + const dist = writeDist({ + ...validTree(), + 'index.html': + '', + }); + try { + assert.throws(() => smokeMarketingTokens(dist), /tokens\.design\.singletonsd\.com/); + } finally { + rmSync(dist, { recursive: true, force: true }); + } +}); + +test('smokeMarketingTokens fails when vendored token CSS is missing', () => { + const files = validTree(); + delete files['admin/vendor/tokens-dark.css']; + const dist = writeDist(files); + try { + assert.throws( + () => smokeMarketingTokens(dist), + /Missing or empty vendored token CSS under admin\/vendor: tokens-dark\.css/, + ); + } finally { + rmSync(dist, { recursive: true, force: true }); + } +});