diff --git a/npm/ui/media/package.json b/npm/ui/media/package.json new file mode 100644 index 000000000..e5b308eff --- /dev/null +++ b/npm/ui/media/package.json @@ -0,0 +1,67 @@ +{ + "name": "@vizeui/media", + "version": "0.298.0", + "description": "Accessible, type-safe media foundations for Vize applications", + "keywords": [ + "accessibility", + "audio", + "media", + "pdf", + "video" + ], + "homepage": "https://github.com/ubugeeei-prod/vize", + "bugs": { + "url": "https://github.com/ubugeeei-prod/vize/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ubugeeei-prod/vize.git", + "directory": "npm/ui/media" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./pdf": { + "types": "./dist/pdf.d.mts", + "import": "./dist/pdf.mjs", + "default": "./dist/pdf.mjs" + }, + "./source": { + "types": "./dist/source.d.mts", + "import": "./dist/source.mjs", + "default": "./dist/source.mjs" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vp pack", + "dev": "vp pack --watch", + "pretest": "vp pack && pnpm check:size", + "test": "vp exec node --test 'src/**/*.test.ts'", + "check": "vp check src scripts vite.config.ts", + "check:fix": "vp check --fix src scripts vite.config.ts", + "check:size": "node scripts/check-size.mjs", + "fmt": "vp fmt --write src scripts vite.config.ts" + }, + "devDependencies": { + "@types/node": "catalog:typescript", + "typescript": "catalog:typescript", + "vite-plus": "catalog:vite-stack" + }, + "engines": { + "node": ">=24" + } +} diff --git a/npm/ui/media/scripts/check-size.mjs b/npm/ui/media/scripts/check-size.mjs new file mode 100644 index 000000000..2de4b7c1c --- /dev/null +++ b/npm/ui/media/scripts/check-size.mjs @@ -0,0 +1,49 @@ +import { readFile } from "node:fs/promises"; +import { posix } from "node:path"; +import { gzipSync } from "node:zlib"; + +const distributionDirectory = new URL("../dist/", import.meta.url); +const staticImportPattern = /\b(?:import|export)\s+(?:[^"']*?\s+from\s+)?["'](\.\.?\/[^"']+)["']/g; +const budgets = new Map([ + ["index.mjs", 1_850], + ["pdf.mjs", 1_850], + ["source.mjs", 1_400], +]); + +async function collectStaticDependencies(file, collected = new Map()) { + if (collected.has(file)) return collected; + + const source = await readFile(new URL(file, distributionDirectory)); + collected.set(file, source); + + for (const match of source.toString().matchAll(staticImportPattern)) { + const dependency = posix.normalize(posix.join(posix.dirname(file), match[1])); + if (dependency === ".." || dependency.startsWith("../")) { + throw new Error(`Output dependency escapes dist: ${dependency}`); + } + await collectStaticDependencies(dependency, collected); + } + + return collected; +} + +for (const [entry, maximumGzipBytes] of budgets) { + const files = await collectStaticDependencies(entry); + const gzipBytes = gzipSync( + [...files.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([file, source]) => `/* ${file} */\n${source}`) + .join("\n"), + ).byteLength; + + console.log( + JSON.stringify({ + entry: `@vizeui/media/${entry.replace(/\.mjs$/, "")}`, + files: files.size, + gzipBytes, + maximumGzipBytes, + }), + ); + + if (gzipBytes > maximumGzipBytes) process.exitCode = 1; +} diff --git a/npm/ui/media/src/index.ts b/npm/ui/media/src/index.ts new file mode 100644 index 000000000..035bff8f6 --- /dev/null +++ b/npm/ui/media/src/index.ts @@ -0,0 +1,4 @@ +export { createPDFSource } from "./pdf-source.ts"; +export type { CreatePDFSourceOptions } from "./pdf-source.ts"; +export { normalizeMediaSource } from "./media-source.ts"; +export type { MediaSourceKind, NormalizeMediaSourceOptions } from "./media-source.ts"; diff --git a/npm/ui/media/src/media-source.test.ts b/npm/ui/media/src/media-source.test.ts new file mode 100644 index 000000000..4e7174a78 --- /dev/null +++ b/npm/ui/media/src/media-source.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { readFile, stat } from "node:fs/promises"; +import test from "node:test"; +import { createPDFSource } from "./pdf-source.ts"; +import { normalizeMediaSource } from "./media-source.ts"; +import type { MediaSourceKind } from "./media-source.ts"; + +void test("accepts relative, encrypted, and object media sources", () => { + for (const [source, kind] of [ + ["/audio/intro.mp3", "audio"], + ["../video/intro.mp4", "video"], + ["//media.example.test/poster.webp", "image"], + ["https://media.example.test/subtitles.vtt", "track"], + ["blob:session-id", "stream"], + ] as const) { + assert.equal(normalizeMediaSource(` ${source} `, { kind }), source); + } +}); + +void test("requires an explicit opt-in for unencrypted remote sources", () => { + const source = "http://localhost:4173/video.mp4"; + assert.throws( + () => normalizeMediaSource(source, { kind: "video" }), + /\[VIZE_UI_MEDIA_DISALLOWED_SOURCE\]/, + ); + assert.equal(normalizeMediaSource(source, { kind: "video", allowInsecure: true }), source); +}); + +void test("accepts only category-matched inline data", () => { + const sources = [ + ["data:audio/ogg;base64,T2dnUw==", "audio"], + ["data:image/png;base64,iVBORw0KGgo=", "image"], + ["data:video/mp4;base64,AAAA", "video"], + ["data:application/pdf;base64,JVBERi0xLjQ=", "pdf"], + ["data:text/vtt;charset=utf-8,WEBVTT%0A%0A", "track"], + ["data:text/vtt;base64,V0VCVlRU", "track"], + ] as const; + + for (const [source, kind] of sources) { + assert.equal(normalizeMediaSource(source, { kind }), source); + } + + assert.throws( + () => normalizeMediaSource(sources[1][0], { kind: "video" }), + /\[VIZE_UI_MEDIA_DISALLOWED_SOURCE\]/, + ); + assert.throws( + () => normalizeMediaSource("data:video/mp4;base64,AAAA", { kind: "stream" }), + /\[VIZE_UI_MEDIA_DISALLOWED_SOURCE\]/, + ); +}); + +void test("rejects malformed and script-capable sources", () => { + for (const source of [ + "", + "javascript:alert(1)", + "file:///private/report.pdf", + "https://media.example.test/video.mp4\nscript", + "data:video/mp4;base64,not-base64", + ]) { + assert.throws( + () => normalizeMediaSource(source, { kind: "video" }), + /\[VIZE_UI_MEDIA_(?:INVALID|DISALLOWED)_SOURCE\]/, + ); + } + + assert.throws( + () => normalizeMediaSource("data:text/vtt,WEBVTT%QQ", { kind: "track" }), + /\[VIZE_UI_MEDIA_DISALLOWED_SOURCE\]/, + ); + assert.throws( + () => normalizeMediaSource(42 as unknown as string, { kind: "video" }), + /\[VIZE_UI_MEDIA_INVALID_SOURCE\]/, + ); + assert.throws( + () => normalizeMediaSource("/media", { kind: "unknown" as MediaSourceKind }), + /\[VIZE_UI_MEDIA_INVALID_KIND\]/, + ); +}); + +void test("sets and replaces PDF page fragments", () => { + assert.equal(createPDFSource("/report.pdf", { page: 3 }), "/report.pdf#page=3"); + assert.equal( + createPDFSource("/report.pdf#zoom=page-width&page=2&view=Fit", { page: 7 }), + "/report.pdf#zoom=page-width&view=Fit&page=7", + ); + assert.equal(createPDFSource("/report.pdf#page=2"), "/report.pdf#page=2"); + + for (const page of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws( + () => createPDFSource("/report.pdf", { page }), + /\[VIZE_UI_MEDIA_INVALID_PDF_PAGE\]/, + ); + } +}); + +void test("publishes independent ESM entries and declarations", async () => { + const packageJson = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), + ) as { + readonly exports: Readonly>; + }; + + for (const exportName of [".", "./pdf", "./source"]) { + const entry = packageJson.exports[exportName]; + if (entry === undefined) assert.fail(`Missing package export: ${exportName}`); + await stat(new URL(`..${entry.import.slice(1)}`, import.meta.url)); + await stat(new URL(`..${entry.types.slice(1)}`, import.meta.url)); + } + + const distributionUrl = new URL("../dist/source.mjs", import.meta.url); + const sourceEntry = (await import(distributionUrl.href)) as { + readonly normalizeMediaSource: unknown; + }; + assert.equal(typeof sourceEntry.normalizeMediaSource, "function"); +}); diff --git a/npm/ui/media/src/media-source.ts b/npm/ui/media/src/media-source.ts new file mode 100644 index 000000000..bc3227135 --- /dev/null +++ b/npm/ui/media/src/media-source.ts @@ -0,0 +1,116 @@ +/** Media resource category used to constrain inline data. */ +export type MediaSourceKind = "audio" | "image" | "pdf" | "stream" | "track" | "video"; + +/** Options for {@link normalizeMediaSource}. */ +export interface NormalizeMediaSourceOptions { + /** Expected media resource category. */ + readonly kind: MediaSourceKind; + + /** + * Permits an unencrypted HTTP resource for local development. + * + * @default false + */ + readonly allowInsecure?: boolean; +} + +const SOURCE_KINDS = new Set([ + "audio", + "image", + "pdf", + "stream", + "track", + "video", +]); +const SCHEME = /^([a-z][a-z\d+.-]*):/i; +const BASE64_PAYLOAD = /^(?:[a-z\d+/]{4})*(?:[a-z\d+/]{2}==|[a-z\d+/]{3}=)?$/i; +const BINARY_MEDIA_TYPE = /^(audio|image|video)\/([a-z\d][a-z\d.+-]*)$/i; +const INVALID_PERCENT_ESCAPE = /%(?![a-f\d]{2})/i; + +/** + * Validates and normalizes a media resource reference. + * + * Relative references, network-relative references, encrypted remote URLs, + * object URLs, and category-matched inline data are accepted. Unencrypted + * remote URLs require an explicit opt-in. Unknown and script-capable schemes + * are rejected. + * + * @throws {TypeError} When the resource is empty, malformed, unsafe, or does + * not match the requested media category. + */ +export function normalizeMediaSource(source: string, options: NormalizeMediaSourceOptions): string { + const kind = options?.kind; + if (!SOURCE_KINDS.has(kind)) { + throw new TypeError(`[VIZE_UI_MEDIA_INVALID_KIND] Unknown media source kind: ${String(kind)}`); + } + + if (typeof source !== "string") { + throw new TypeError("[VIZE_UI_MEDIA_INVALID_SOURCE] Media source must be a string"); + } + + const normalized = source.trim(); + if (normalized.length === 0 || containsControlCharacter(normalized)) { + throw new TypeError( + "[VIZE_UI_MEDIA_INVALID_SOURCE] Media source must be non-empty and contain no control characters", + ); + } + + const scheme = SCHEME.exec(normalized)?.[1]?.toLowerCase(); + if (scheme === undefined || scheme === "https" || scheme === "blob") return normalized; + if (scheme === "http" && options.allowInsecure === true) return normalized; + if (scheme === "data" && isAllowedDataSource(normalized, kind)) return normalized; + + throw new TypeError( + `[VIZE_UI_MEDIA_DISALLOWED_SOURCE] Source is not allowed for ${kind}: ${scheme}`, + ); +} + +function isAllowedDataSource(source: string, kind: MediaSourceKind): boolean { + if (kind === "stream") return false; + + const commaIndex = source.indexOf(","); + if (commaIndex < 6) return false; + + const metadata = source.slice(5, commaIndex).toLowerCase(); + const payload = source.slice(commaIndex + 1); + if (payload.length === 0) return false; + + const segments = metadata.split(";"); + const mediaType = segments.shift(); + const isBase64 = segments.at(-1) === "base64"; + if (isBase64) segments.pop(); + + if (kind === "pdf") { + return ( + mediaType === "application/pdf" && segments.length === 0 && isBase64 && isValidBase64(payload) + ); + } + + if (kind === "track") { + const hasValidParameters = + segments.length === 0 || (segments.length === 1 && segments[0] === "charset=utf-8"); + if (mediaType !== "text/vtt" || !hasValidParameters) return false; + return isBase64 ? isValidBase64(payload) : !INVALID_PERCENT_ESCAPE.test(payload); + } + + const inlineKind = mediaType === undefined ? undefined : BINARY_MEDIA_TYPE.exec(mediaType)?.[1]; + return ( + inlineKind?.toLowerCase() === kind && + segments.length === 0 && + isBase64 && + isValidBase64(payload) + ); +} + +function isValidBase64(payload: string): boolean { + return payload.length > 0 && BASE64_PAYLOAD.test(payload); +} + +function containsControlCharacter(value: string): boolean { + for (const character of value) { + const code = character.charCodeAt(0); + if (code <= 0x1f || code === 0x7f) return true; + } + + return false; +} diff --git a/npm/ui/media/src/pdf-source.ts b/npm/ui/media/src/pdf-source.ts new file mode 100644 index 000000000..c84dc0a03 --- /dev/null +++ b/npm/ui/media/src/pdf-source.ts @@ -0,0 +1,51 @@ +import { normalizeMediaSource } from "./media-source.ts"; + +/** Options for {@link createPDFSource}. */ +export interface CreatePDFSourceOptions { + /** + * One-based initial PDF page. + * + * Existing `page` parameters in the PDF fragment are replaced while other + * fragment parameters are preserved. + * + * @default undefined + */ + readonly page?: number; + + /** + * Permits an unencrypted HTTP resource for local development. + * + * @default false + */ + readonly allowInsecure?: boolean; +} + +/** + * Validates a PDF resource and applies a standards-compatible initial page. + * + * @throws {TypeError} When the PDF source is unsafe or malformed. + * @throws {RangeError} When `page` is not a positive safe integer. + */ +export function createPDFSource(source: string, options: CreatePDFSourceOptions = {}): string { + const normalized = normalizeMediaSource(source, { + kind: "pdf", + allowInsecure: options.allowInsecure ?? false, + }); + if (options.page === undefined) return normalized; + + if (!Number.isSafeInteger(options.page) || options.page < 1) { + throw new RangeError( + `[VIZE_UI_MEDIA_INVALID_PDF_PAGE] PDF page must be a positive safe integer; received ${String(options.page)}`, + ); + } + + const fragmentIndex = normalized.indexOf("#"); + const resource = fragmentIndex === -1 ? normalized : normalized.slice(0, fragmentIndex); + const currentFragment = fragmentIndex === -1 ? "" : normalized.slice(fragmentIndex + 1); + const parameters = currentFragment + .split("&") + .filter((parameter) => parameter.length > 0 && !parameter.toLowerCase().startsWith("page=")); + + parameters.push(`page=${options.page}`); + return `${resource}#${parameters.join("&")}`; +} diff --git a/npm/ui/media/src/pdf.ts b/npm/ui/media/src/pdf.ts new file mode 100644 index 000000000..cf8172fb8 --- /dev/null +++ b/npm/ui/media/src/pdf.ts @@ -0,0 +1,2 @@ +export { createPDFSource } from "./pdf-source.ts"; +export type { CreatePDFSourceOptions } from "./pdf-source.ts"; diff --git a/npm/ui/media/tsconfig.json b/npm/ui/media/tsconfig.json new file mode 100644 index 000000000..d0e5a1db8 --- /dev/null +++ b/npm/ui/media/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "rewriteRelativeImportExtensions": true, + "types": ["node"], + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/npm/ui/media/vite.config.ts b/npm/ui/media/vite.config.ts new file mode 100644 index 000000000..d277dd3b3 --- /dev/null +++ b/npm/ui/media/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + lint: { + ignorePatterns: ["dist/**"], + options: { typeAware: true }, + }, + fmt: { ignorePatterns: ["dist/**"] }, + pack: { + entry: { + index: "src/index.ts", + pdf: "src/pdf.ts", + source: "src/media-source.ts", + }, + format: "esm", + dts: true, + clean: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfcdb3962..919a45673 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -932,6 +932,18 @@ importers: specifier: catalog:vue-stable version: 3.5.35(typescript@6.0.3) + npm/ui/media: + devDependencies: + '@types/node': + specifier: catalog:typescript + version: 25.9.2 + typescript: + specifier: catalog:typescript + version: 6.0.3 + vite-plus: + specifier: catalog:vite-stack + version: 0.1.21(@tsdown/css@0.22.0)(@types/node@25.9.2)(@voidzero-dev/vite-plus-core@0.1.21(@tsdown/css@0.22.0)(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + npm/wasm: {} playground: diff --git a/tests/tooling/release-preflight-runner.test.ts b/tests/tooling/release-preflight-runner.test.ts index 045bb4c48..32fa6d5e5 100644 --- a/tests/tooling/release-preflight-runner.test.ts +++ b/tests/tooling/release-preflight-runner.test.ts @@ -110,6 +110,7 @@ test("release metadata inventory discovers every non-private npm and editor pack "npm/native/package.json", "npm/oxint/package.json", "npm/ui/core/package.json", + "npm/ui/media/package.json", "npm/wasm/package.json", ], );