import assert from 'node:assert/strict'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import valueParser from 'postcss-value-parser'; import vueTemplateCompiler from 'vue-template-compiler'; const roots = ['pages', 'components']; const extraFiles = ['App.vue', 'uni.scss']; const styleExtensions = new Set(['.css', '.scss']); function listFiles(dir) { const result = []; for (const entry of readdirSync(dir)) { const fullPath = path.join(dir, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { result.push(...listFiles(fullPath)); } else { result.push(fullPath); } } return result; } function stripCssCommentsPreserveOffsets(source) { let output = ''; let quote = null; for (let i = 0; i < source.length; i += 1) { const char = source[i]; const next = source[i + 1]; if (quote) { output += char; if (char === '\\') { i += 1; output += source[i] || ''; } else if (char === quote) { quote = null; } continue; } if (char === '"' || char === "'") { quote = char; output += char; continue; } if (char === '/' && next === '*') { output += ' '; i += 2; while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { output += source[i] === '\n' ? '\n' : ' '; i += 1; } output += ' '; i += 1; continue; } if (char === '/' && next === '/') { output += ' '; i += 2; while (i < source.length && source[i] !== '\n') { output += ' '; i += 1; } if (source[i] === '\n') { output += '\n'; } continue; } output += char; } return output; } function lineForOffset(source, offset) { return source.slice(0, offset).split('\n').length; } function collectRootStaticStyleUrls(filePath, styleSource, sourceOffset = 0, originalSource = styleSource) { const stripped = stripCssCommentsPreserveOffsets(styleSource); const findings = []; valueParser(stripped).walk((node) => { if (node.type !== 'function' || node.value.toLowerCase() !== 'url') { return; } const url = valueParser.stringify(node.nodes).trim().replace(/^['"]|['"]$/g, ''); if (!url.startsWith('/static/')) { return; } findings.push({ filePath, line: lineForOffset(originalSource, sourceOffset + node.sourceIndex), url, }); }); return findings; } function styleSourcesForFile(filePath) { const source = readFileSync(filePath, 'utf8'); if (filePath.endsWith('.vue')) { const descriptor = vueTemplateCompiler.parseComponent(source); return descriptor.styles.map((style) => ({ source: style.content, sourceOffset: style.start, originalSource: source, })); } return [{ source, sourceOffset: 0, originalSource: source }]; } test('style url() references do not use H5-root /static paths', () => { const files = [ ...roots.flatMap((root) => listFiles(root)), ...extraFiles, ].filter((filePath) => filePath.endsWith('.vue') || styleExtensions.has(path.extname(filePath))); const findings = files.flatMap((filePath) => styleSourcesForFile(filePath).flatMap(({ source, sourceOffset, originalSource }) => collectRootStaticStyleUrls(filePath, source, sourceOffset, originalSource) ) ); assert.deepEqual( findings, [], `style url() must use relative static paths so H5 /h5 deployments do not request site-root assets:\n${ findings.map((item) => `${item.filePath}:${item.line} ${item.url}`).join('\n') }` ); });