diff --git a/biome.json b/biome.json index 246c74e8..5ba6939e 100644 --- a/biome.json +++ b/biome.json @@ -119,6 +119,16 @@ } } } + }, + { + "includes": ["build/plugins/tests/**"], + "linter": { + "rules": { + "suspicious": { + "noTemplateCurlyInString": "off" + } + } + } } ], "files": { @@ -131,7 +141,6 @@ "!**/.next", "!**/.opencode", "!**/.github", - "!build", "!**/dist", "!**/examples", "!**/styles/vjs.css", diff --git a/build/plugins/copy-css-plugin.mjs b/build/plugins/copy-css-plugin.ts similarity index 75% rename from build/plugins/copy-css-plugin.mjs rename to build/plugins/copy-css-plugin.ts index 2b39bd24..cf048de5 100644 --- a/build/plugins/copy-css-plugin.mjs +++ b/build/plugins/copy-css-plugin.ts @@ -1,11 +1,14 @@ import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { resolveImports } from './resolve-css-imports.mjs'; +import { resolveImports } from './resolve-css-imports.ts'; +import type { BuildPlugin } from './types.ts'; -/** - * @param {{ skinsDir: string; outDir: string }} options - */ -export function copyCssPlugin(options) { +interface CopyCssPluginOptions { + skinsDir: string; + outDir: string; +} + +export function copyCssPlugin(options: CopyCssPluginOptions): BuildPlugin { const { skinsDir, outDir } = options; return { diff --git a/build/plugins/inline-css-plugin.mjs b/build/plugins/inline-css-plugin.ts similarity index 82% rename from build/plugins/inline-css-plugin.mjs rename to build/plugins/inline-css-plugin.ts index 143cb3be..f4b58493 100644 --- a/build/plugins/inline-css-plugin.mjs +++ b/build/plugins/inline-css-plugin.ts @@ -1,20 +1,25 @@ import { readFileSync } from 'node:fs'; import { dirname, relative, resolve } from 'node:path'; import { transform } from 'lightningcss'; -import { resolveImports } from './resolve-css-imports.mjs'; +import { resolveImports } from './resolve-css-imports.ts'; +import type { BuildPlugin } from './types.ts'; const INLINE_PREFIX = 'inline-css:'; const INLINE_SUFFIX = '?inline'; +interface InlineCssPluginOptions { + skinsDir: string; + rootDir?: string; + minify?: boolean; +} + /** * Rolldown/tsdown plugin that inlines `.css?inline` imports as JavaScript * modules exporting the resolved CSS string. Mirrors the Vite `?inline` * convention so the same source works in both Vite dev and tsdown builds. - * - * @param {{ skinsDir: string; rootDir?: string; minify?: boolean }} options */ -export function inlineCssPlugin(options) { +export function inlineCssPlugin(options: InlineCssPluginOptions): BuildPlugin { const { skinsDir, rootDir = process.cwd(), minify = true } = options; return { @@ -24,7 +29,7 @@ export function inlineCssPlugin(options) { if (!source.endsWith(INLINE_SUFFIX)) return null; const cssPath = source.slice(0, -INLINE_SUFFIX.length); - const abs = resolve(dirname(importer), cssPath); + const abs = resolve(dirname(importer!), cssPath); const rel = relative(rootDir, abs); // Use .js extension so rolldown doesn't apply its CSS loader. diff --git a/build/plugins/inline-template-plugin.mjs b/build/plugins/inline-template-plugin.ts similarity index 82% rename from build/plugins/inline-template-plugin.mjs rename to build/plugins/inline-template-plugin.ts index 86cd96f1..931f7c16 100644 --- a/build/plugins/inline-template-plugin.mjs +++ b/build/plugins/inline-template-plugin.ts @@ -1,8 +1,13 @@ import { transform } from 'lightningcss'; +import type { BuildPlugin } from './types.ts'; const HTML_MARKER = '/*html*/'; const CSS_MARKER = '/* css */'; +interface TemplatePluginOptions { + minify?: boolean; +} + /** * Rolldown/tsdown transform plugin that minifies tagged template literals * marked with {@link HTML_MARKER} or {@link CSS_MARKER}. @@ -12,10 +17,8 @@ const CSS_MARKER = '/* css */'; * * Only the static parts (quasis) are processed — `${...}` expression * interpolations are preserved as-is. - * - * @param {{ minify?: boolean }} options */ -export function inlineTemplatePlugin(options = {}) { +export function inlineTemplatePlugin(options: TemplatePluginOptions = {}): BuildPlugin { const { minify = true } = options; return { @@ -40,7 +43,7 @@ export function inlineTemplatePlugin(options = {}) { * Walk `code` looking for known markers followed by a backtick template * literal. For each match, minify the static parts and reassemble. */ -function processTemplates(code) { +function processTemplates(code: string): string { let out = ''; let pos = 0; @@ -49,8 +52,8 @@ function processTemplates(code) { const htmlIdx = code.indexOf(HTML_MARKER, pos); const cssIdx = code.indexOf(CSS_MARKER, pos); - let idx; - let marker; + let idx: number; + let marker: string; if (htmlIdx === -1 && cssIdx === -1) { out += code.slice(pos); @@ -68,7 +71,7 @@ function processTemplates(code) { // Skip marker + optional whitespace to find the opening backtick. let i = idx + marker.length; - while (i < code.length && isWhitespace(code[i])) i++; + while (i < code.length && isWhitespace(code[i]!)) i++; if (i >= code.length || code[i] !== '`') { // Not a tagged template literal — copy the marker verbatim. @@ -79,17 +82,14 @@ function processTemplates(code) { const { quasis, expressions, end } = parseTemplateLiteral(code, i); - const minified = - marker === HTML_MARKER - ? minifyHtmlQuasis(quasis) - : minifyCssQuasis(quasis); + const minified = marker === HTML_MARKER ? minifyHtmlQuasis(quasis) : minifyCssQuasis(quasis); // Reassemble — keep the marker for IDE syntax-highlighting. - out += marker + ' `'; + out += `${marker} \``; for (let q = 0; q < minified.length; q++) { out += minified[q]; if (q < expressions.length) { - out += '${' + expressions[q] + '}'; + out += `\${${expressions[q]}}`; } } out += '`'; @@ -104,7 +104,7 @@ function processTemplates(code) { // --------------------------------------------------------------------------- /** Minify the static parts of an HTML template literal. */ -function minifyHtmlQuasis(quasis) { +function minifyHtmlQuasis(quasis: string[]): string[] { return quasis.map((q, qIdx) => { let s = q; @@ -130,7 +130,7 @@ function minifyHtmlQuasis(quasis) { * while respecting quoted attribute values (so `> <` inside an attribute * like `title="a > ') { if (s[j] === '"' || s[j] === "'") { - const q = s[j++]; + const q = s[j++]!; while (j < s.length && s[j] !== q) j++; if (j < s.length) j++; // skip closing quote } else { @@ -182,23 +182,23 @@ const EXPR_PLACEHOLDER = '___EXPR_'; * Expressions are replaced with safe placeholder tokens before minification, * then restored afterward. */ -function minifyCssQuasis(quasis) { +function minifyCssQuasis(quasis: string[]): string[] { // Fast path: single quasi (no expressions) — minify directly. if (quasis.length === 1) { - return [minifyCss(quasis[0]).trim()]; + return [minifyCss(quasis[0]!).trim()]; } // Join quasis with numbered placeholders so lightningcss sees valid-ish CSS. - const joined = quasis.map((q, i) => (i < quasis.length - 1 ? q + EXPR_PLACEHOLDER + i + '___' : q)).join(''); + const joined = quasis.map((q, i) => (i < quasis.length - 1 ? `${q + EXPR_PLACEHOLDER + i}___` : q)).join(''); const minified = minifyCss(joined); // Split back on placeholders to recover individual quasis. - const result = []; + const result: string[] = []; let remaining = minified; for (let i = 0; i < quasis.length - 1; i++) { - const token = EXPR_PLACEHOLDER + i + '___'; + const token = `${EXPR_PLACEHOLDER + i}___`; const tokenIdx = remaining.indexOf(token); if (tokenIdx === -1) { @@ -216,7 +216,7 @@ function minifyCssQuasis(quasis) { return result; } -function minifyCss(css) { +function minifyCss(css: string): string { const { code } = transform({ filename: 'template.css', code: Buffer.from(css), @@ -230,22 +230,31 @@ function minifyCss(css) { // Template-literal parser (shared) // --------------------------------------------------------------------------- +interface ParsedExpression { + src: string; + _end: number; +} + +interface ParsedTemplate { + quasis: string[]; + expressions: string[]; + end: number; +} + /** * Parse a backtick template literal starting at `start` (the opening `` ` ``). * Returns the static quasis, the raw expression source strings, and the index * immediately after the closing backtick. */ -function parseTemplateLiteral(code, start) { - /** @type {string[]} */ - const quasis = []; - /** @type {string[]} */ - const expressions = []; +function parseTemplateLiteral(code: string, start: number): ParsedTemplate { + const quasis: string[] = []; + const expressions: ParsedExpression[] = []; let i = start + 1; // skip opening backtick let quasi = ''; while (i < code.length) { - const ch = code[i]; + const ch = code[i]!; // Escape sequence — preserve verbatim. if (ch === '\\' && i + 1 < code.length) { @@ -267,7 +276,7 @@ function parseTemplateLiteral(code, start) { quasi = ''; i += 2; // skip ${ expressions.push(collectExpression(code, i)); - i = expressions[expressions.length - 1]._end; + i = expressions[expressions.length - 1]!._end; continue; } @@ -282,17 +291,22 @@ function parseTemplateLiteral(code, start) { // Expression collector — tracks brace depth, skips strings & nested templates. // --------------------------------------------------------------------------- +interface SkipResult { + text: string; + end: number; +} + /** * Starting right after the `${`, collect everything up to the matching `}`. * Returns `{ src, _end }` where `_end` is the index after the closing `}`. */ -function collectExpression(code, start) { +function collectExpression(code: string, start: number): ParsedExpression { let i = start; let depth = 1; let src = ''; while (i < code.length && depth > 0) { - const ch = code[i]; + const ch = code[i]!; if (ch === '{') { depth++; @@ -324,23 +338,23 @@ function collectExpression(code, start) { } /** Skip a single- or double-quoted string starting at `start`. */ -function skipString(code, start) { - const quote = code[start]; +function skipString(code: string, start: number): SkipResult { + const quote = code[start]!; let text = quote; let i = start + 1; while (i < code.length && code[i] !== quote) { if (code[i] === '\\' && i + 1 < code.length) { - text += code[i] + code[i + 1]; + text += code[i]! + code[i + 1]!; i += 2; } else { - text += code[i]; + text += code[i]!; i++; } } if (i < code.length) { - text += code[i]; // closing quote + text += code[i]!; // closing quote i++; } @@ -348,13 +362,13 @@ function skipString(code, start) { } /** Skip a nested backtick template literal (with its own `${…}` blocks). */ -function skipNestedTemplate(code, start) { +function skipNestedTemplate(code: string, start: number): SkipResult { let text = '`'; let i = start + 1; let exprDepth = 0; while (i < code.length) { - const ch = code[i]; + const ch = code[i]!; if (ch === '\\' && i + 1 < code.length) { text += ch + code[i + 1]; @@ -380,6 +394,6 @@ function skipNestedTemplate(code, start) { return { text, end: i }; } -function isWhitespace(ch) { +function isWhitespace(ch: string): boolean { return ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r'; } diff --git a/build/plugins/resolve-css-imports.mjs b/build/plugins/resolve-css-imports.mjs deleted file mode 100644 index 23394b9c..00000000 --- a/build/plugins/resolve-css-imports.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; - -/** - * Resolves `@import` directives in CSS content, inlining referenced files. - * - * @param {string} content - The CSS content to resolve. - * @param {string} baseDir - The directory to resolve relative imports from. - * @param {string} skinsDir - The directory to resolve `@videojs/skins` imports from. - * @returns {string} The resolved CSS content. - */ -export function resolveImports(content, baseDir, skinsDir) { - return content.replace(/@import\s+['"]([^'"]+)['"]\s*;/g, (_, importPath) => { - let file; - - if (importPath.startsWith('@videojs/skins/')) { - file = resolve(skinsDir, importPath.replace('@videojs/skins/', '')); - } else if (importPath.startsWith('.')) { - file = resolve(baseDir, importPath); - } else { - return _; - } - - const nested = readFileSync(file, 'utf-8'); - return resolveImports(nested, dirname(file), skinsDir); - }); -} diff --git a/build/plugins/resolve-css-imports.ts b/build/plugins/resolve-css-imports.ts new file mode 100644 index 00000000..9e500933 --- /dev/null +++ b/build/plugins/resolve-css-imports.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +/** Resolves `@import` directives in CSS content, inlining referenced files. */ +export function resolveImports(content: string, baseDir: string, skinsDir: string): string { + return content.replace(/@import\s+['"]([^'"]+)['"]\s*;/g, (match, importPath: string) => { + let file: string; + + if (importPath.startsWith('@videojs/skins/')) { + file = resolve(skinsDir, importPath.replace('@videojs/skins/', '')); + } else if (importPath.startsWith('.')) { + file = resolve(baseDir, importPath); + } else { + return match; + } + + const nested = readFileSync(file, 'utf-8'); + return resolveImports(nested, dirname(file), skinsDir); + }); +} diff --git a/build/plugins/tests/inline-template-plugin.test.mjs b/build/plugins/tests/inline-template-plugin.test.ts similarity index 88% rename from build/plugins/tests/inline-template-plugin.test.mjs rename to build/plugins/tests/inline-template-plugin.test.ts index f85edff7..fef98552 100644 --- a/build/plugins/tests/inline-template-plugin.test.mjs +++ b/build/plugins/tests/inline-template-plugin.test.ts @@ -1,16 +1,16 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { inlineTemplatePlugin } from '../inline-template-plugin.mjs'; +import { inlineTemplatePlugin } from '../inline-template-plugin.ts'; // Helper: run the plugin's transform on a code string. -function transform(code) { +function transform(code: string): string { const plugin = inlineTemplatePlugin({ minify: true }); - const result = plugin.transform(code); + const result = plugin.transform?.(code, 'test.ts'); return result?.code ?? code; } // Helper: extract the minified template body (between the backticks). -function minifyHTML(template) { +function minifyHTML(template: string): string { const code = `/*html*/ \`${template}\``; const result = transform(code); const match = result.match(/`([\s\S]*)`/); @@ -70,10 +70,7 @@ describe('minifyHtmlQuasis', () => { }); it('strips comments between elements', () => { - assert.equal( - minifyHTML('
'), - '
' - ); + assert.equal(minifyHTML('
'), '
'); }); // ------- empty elements ------- @@ -100,10 +97,7 @@ describe('minifyHtmlQuasis', () => { `; - assert.equal( - minifyHTML(input), - '' - ); + assert.equal(minifyHTML(input), ''); }); it('preserves empty void-like elements', () => { @@ -113,24 +107,15 @@ describe('minifyHtmlQuasis', () => { // ------- attribute values with special characters ------- it('does not corrupt attribute values containing > <', () => { - assert.equal( - minifyHTML('
'), - '
' - ); + assert.equal(minifyHTML('
'), '
'); }); it('does not corrupt single-quoted attribute values containing > <', () => { - assert.equal( - minifyHTML("
"), - "
" - ); + assert.equal(minifyHTML("
"), "
"); }); it('handles attribute values with > followed by space and <', () => { - assert.equal( - minifyHTML('
'), - '
' - ); + assert.equal(minifyHTML('
'), '
'); }); // ------- text content ------- @@ -247,12 +232,12 @@ describe('minifyCssQuasis', () => { describe('edge cases', () => { it('returns null when minify is disabled', () => { const plugin = inlineTemplatePlugin({ minify: false }); - assert.equal(plugin.transform('/*html*/ `
`'), null); + assert.equal(plugin.transform?.('/*html*/ `
`', 'test.ts'), null); }); it('returns null when code contains no markers', () => { const plugin = inlineTemplatePlugin({ minify: true }); - assert.equal(plugin.transform('const x = 1;'), null); + assert.equal(plugin.transform?.('const x = 1;', 'test.ts'), null); }); it('returns null when marker is not followed by a template literal', () => { @@ -262,10 +247,7 @@ describe('edge cases', () => { }); it('handles multiple HTML templates in one file', () => { - const code = [ - 'const a = /*html*/ `
`;', - 'const b = /*html*/ ` `;', - ].join('\n'); + const code = ['const a = /*html*/ `
`;', 'const b = /*html*/ ` `;'].join('\n'); const result = transform(code); assert.match(result, /`
<\/div>`/); assert.match(result, /`<\/span>`/); diff --git a/build/plugins/types.ts b/build/plugins/types.ts new file mode 100644 index 00000000..9d284175 --- /dev/null +++ b/build/plugins/types.ts @@ -0,0 +1,17 @@ +/** + * Minimal rolldown plugin interface covering only the hooks used by our build + * plugins. The full `Plugin` type lives in `rolldown` which is a transitive + * dependency (via tsdown) and not directly resolvable from the repo root. + */ +export interface BuildPlugin { + name: string; + transform?: (this: void, code: string, id: string) => { code: string } | null; + resolveId?: ( + this: void, + source: string, + importer: string | undefined + ) => { id: string; moduleSideEffects: boolean } | null; + load?: (this: void, id: string) => { code: string; moduleSideEffects: boolean } | null; + buildStart?: (this: { addWatchFile: (file: string) => void }) => void; + writeBundle?: (this: void) => void; +} diff --git a/build/tsconfig.json b/build/tsconfig.json new file mode 100644 index 00000000..69a4e1d0 --- /dev/null +++ b/build/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["plugins/**/*.ts"] +} diff --git a/packages/html/tsdown.cdn.config.ts b/packages/html/tsdown.cdn.config.ts index a517f541..87108bc3 100644 --- a/packages/html/tsdown.cdn.config.ts +++ b/packages/html/tsdown.cdn.config.ts @@ -3,8 +3,8 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { UserConfig } from 'tsdown'; import { defineConfig } from 'tsdown'; -import { inlineCssPlugin } from '../../build/plugins/inline-css-plugin.mjs'; -import { inlineTemplatePlugin } from '../../build/plugins/inline-template-plugin.mjs'; +import { inlineCssPlugin } from '../../build/plugins/inline-css-plugin.ts'; +import { inlineTemplatePlugin } from '../../build/plugins/inline-template-plugin.ts'; type BuildMode = 'dev' | 'prod'; diff --git a/packages/html/tsdown.config.ts b/packages/html/tsdown.config.ts index a642b321..8a63b024 100644 --- a/packages/html/tsdown.config.ts +++ b/packages/html/tsdown.config.ts @@ -3,9 +3,9 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { UserConfig } from 'tsdown'; import { defineConfig } from 'tsdown'; -import { copyCssPlugin } from '../../build/plugins/copy-css-plugin.mjs'; -import { inlineCssPlugin } from '../../build/plugins/inline-css-plugin.mjs'; -import { inlineTemplatePlugin } from '../../build/plugins/inline-template-plugin.mjs'; +import { copyCssPlugin } from '../../build/plugins/copy-css-plugin.ts'; +import { inlineCssPlugin } from '../../build/plugins/inline-css-plugin.ts'; +import { inlineTemplatePlugin } from '../../build/plugins/inline-template-plugin.ts'; type BuildMode = 'dev' | 'default'; diff --git a/packages/react/tsdown.config.ts b/packages/react/tsdown.config.ts index fa1d6afa..26f43e44 100644 --- a/packages/react/tsdown.config.ts +++ b/packages/react/tsdown.config.ts @@ -2,7 +2,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { UserConfig } from 'tsdown'; import { defineConfig } from 'tsdown'; -import { copyCssPlugin } from '../../build/plugins/copy-css-plugin.mjs'; +import { copyCssPlugin } from '../../build/plugins/copy-css-plugin.ts'; type BuildMode = 'dev' | 'default'; diff --git a/site/scripts/build-ejected-skins.ts b/site/scripts/build-ejected-skins.ts index ab7e1ea4..7b241eee 100644 --- a/site/scripts/build-ejected-skins.ts +++ b/site/scripts/build-ejected-skins.ts @@ -14,7 +14,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; -import { resolveImports } from '../../build/plugins/resolve-css-imports.mjs'; +import { resolveImports } from '../../build/plugins/resolve-css-imports.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '../..');