diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index a38e834..2d4d3b0 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -18,12 +18,12 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Setup Node.js - uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: lts/* + node-version: 24 - name: Install dependencies run: npm ci --prefer-offline --no-audit --ignore-scripts @@ -32,7 +32,7 @@ jobs: run: npm run build:docs - name: Deploy to GitHub Pages - uses: JamesIves/github-pages-deploy-action@v4 + uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4 with: branch: gh-pages folder: docs \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 026aafc..d30b7b7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,11 +16,11 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - uses: wagoid/commitlint-github-action@v5 - - uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: wagoid/commitlint-github-action@9763196e10f27aef304c9b8b660d31d97fce0f99 # v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: cache: npm - node-version: 18 + node-version: 24 - run: npm ci - run: npm run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf11998..1b5ee46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,6 @@ on: types: [completed] branches: [main] workflow_dispatch: - branches: [main] permissions: contents: read # for checkout jobs: @@ -17,22 +16,22 @@ jobs: id-token: write # to enable use of OIDC for npm trusted publishing name: release runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + if: ${{ (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }} steps: - name: generate token id: generate_token - uses: tibdex/github-app-token@v1 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: - app_id: ${{ secrets.RELEASE_BOT_APP_ID }} - private_key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: persist-credentials: false fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} - - uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: lts/* + node-version: 24 registry-url: 'https://registry.npmjs.org' - run: npm ci --prefer-offline --no-audit - run: npx semantic-release diff --git a/.github/workflows/sync-branches.yml b/.github/workflows/sync-branches.yml index 884e4dc..c863923 100644 --- a/.github/workflows/sync-branches.yml +++ b/.github/workflows/sync-branches.yml @@ -5,22 +5,21 @@ on: types: [completed] branches: [main] workflow_dispatch: - branches: [main] permissions: contents: read jobs: sync-branches: name: sync dev branch with main runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + if: ${{ (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }} steps: - name: generate token id: generate_token - uses: tibdex/github-app-token@v1 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: - app_id: ${{ secrets.RELEASE_BOT_APP_ID }} - private_key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 # Need full history for branch operations token: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/test-node.yml b/.github/workflows/test-node.yml index 4a62535..2834aaf 100644 --- a/.github/workflows/test-node.yml +++ b/.github/workflows/test-node.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: '23' + node-version: '24' cache: 'npm' - name: Install dependencies diff --git a/.prettierignore b/.prettierignore index 86c3c55..0d30210 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,6 @@ coverage # Markdown *.md *.mdx + +# Styles (hand-authored, compact formatting preserved) +styles diff --git a/README.md b/README.md index 91b9327..3f380f7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,32 @@ Then [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modu ```javascript import { Util, ILog } from '@ceeblue/web-utils'; ``` + +The package root (`@ceeblue/web-utils`) is pure logic — no DOM, no CSS. DOM/canvas components live in the `ui` subpath: +```javascript +import { UIMetrics, UITimeline } from '@ceeblue/web-utils/ui'; +``` + +The design system (tokens, base styles, generic component classes) lives in its own package, [`@ceeblue/web-ui`](https://github.com/CeeblueTV/web-ui). Load it alongside this one: +```javascript +import '@ceeblue/web-ui/styles.css'; +``` +There is **no dependency** between the two packages. The DOM/canvas components here self-host their own styles and reference the `--cb-*` design tokens by name (with built-in fallbacks), so they render standalone and pick up `web-ui`'s theme when it is present. Set `data-cb-theme="dark"` (or `"light"`) on `` to choose a theme. + +For the simplest embed — one tag, no build step — import the widget you need; each entry registers its own custom element, so you load only what you use: +```javascript +import '@ceeblue/web-utils/ui/timeline'; // registers +import '@ceeblue/web-utils/ui/metrics'; // registers +``` +```html + + +``` +…or straight from a CDN, with no build and no npm install at all: +```html + + +``` > [!IMPORTANT] > > If your project uses TypeScript, it is recommended that you set target: "ES6" in your configuration to match our use of ES6 features and ensure that your build will succeed (for those requiring a backward-compatible UMD version, a local build is recommended). @@ -36,12 +62,14 @@ import { Util, ILog } from '@ceeblue/web-utils'; 1. [Clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) this repository 2. Got to the `web-utils` folder and run `npm install` to install the packages dependencies. -3. Run `npm run build`. The output will be five files placed in the **/dist/** folder: +3. Run `npm run build`. The output is placed in the **/dist/** folder, one set of files per entry point — `web-utils` (the pure-logic root), `ui/web-utils-ui` (the DOM/canvas widgets) and `ui/timeline` + `ui/metrics` (the per-widget custom elements): - **web-utils.d.ts** Typescript definitions file - **web-utils.js**: Bundled JavaScript library - **web-utils.js.map**: Source map that associates the bundled library with the original source files - **web-utils.min.js** Minified version of the library, optimized for size - **web-utils.min.js.map** Source map that associates the minified library with the original source files + - the same five **ui/web-utils-ui.\*** files for the `@ceeblue/web-utils/ui` entry + - the same five **ui/timeline.\*** and **ui/metrics.\*** files, one set per per-widget custom-element entry (design-system CSS now lives in [`@ceeblue/web-ui`](https://github.com/CeeblueTV/web-ui)) ``` git clone https://github.com/CeeblueTV/web-utils.git diff --git a/eslint.config.js b/eslint.config.js index 89fc7eb..9ebec6b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,7 @@ import headersPlugin from 'eslint-plugin-headers'; export default [ { - ignores: ['dist/**', 'docs/**'] + ignores: ['dist/**', 'docs/**', 'coverage/**'] }, { files: ['eslint.config.js'], diff --git a/index.ts b/index.ts index 951af7f..3422c8d 100644 --- a/index.ts +++ b/index.ts @@ -20,11 +20,13 @@ export { WebSocketReliable, WebSocketReliableError } from './src/WebSocketReliab export * as EpochTime from './src/EpochTime'; export { LogLevel, ILog, Log, Loggable, log } from './src/Log'; export { PlayerStats } from './src/stats/PlayerStats'; +export * as Media from './src/Media'; // Export the Common Media Library as the CML namespace. // Example usage: CML.Cmcd, CML.CmcdStreamingFormat, etc. export * as CML from '@svta/common-media-library'; -export { UIMetrics } from './src/ui/UIMetrics'; +// UI components (UIMetrics, UITimeline) live in the `@ceeblue/web-utils/ui` subpath entry +// (see src/ui/index.ts) to keep this root entry free of DOM/CSS code. const __lib__version__ = '?'; // will be replaced on building by project version diff --git a/package-lock.json b/package-lock.json index b02a065..f2119d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "eslint-plugin-headers": "~1.0.4", "is-ci": "~3.0.0", "jsdom": "^26.0.0", + "lightningcss": "^1.33.0", "lint-staged": "~15.5.2", "prettier": "3.2.4", "rollup": "~3.30.0", @@ -6844,6 +6845,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/package.json b/package.json index 8ed24f8..8f4e219 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,42 @@ "bugs": { "url": "https://github.com/CeeblueTV/web-utils/issues" }, + "files": [ + "dist" + ], "main": "dist/web-utils.js", + "module": "dist/web-utils.js", "types": "dist/web-utils.d.ts", "type": "module", + "exports": { + ".": { + "types": "./dist/web-utils.d.ts", + "default": "./dist/web-utils.js" + }, + "./ui": { + "types": "./dist/ui/web-utils-ui.d.ts", + "default": "./dist/ui/web-utils-ui.js" + }, + "./ui/timeline": { + "types": "./dist/ui/timeline.d.ts", + "default": "./dist/ui/timeline.js" + }, + "./ui/metrics": { + "types": "./dist/ui/metrics.d.ts", + "default": "./dist/ui/metrics.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": [ + "**/timeline.js", + "**/timeline.min.js", + "**/metrics.js", + "**/metrics.min.js" + ], "scripts": { "build": "rollup -c", "build:es5": "rollup -c --format umd", - "build:docs": "typedoc --tsconfig tsconfig.json index.ts", + "build:docs": "typedoc --tsconfig tsconfig.json index.ts src/ui/index.ts", "test": "vitest --run", "test:coverage": "vitest --run --coverage", "lint": "eslint . && prettier --check .", @@ -62,6 +91,7 @@ "eslint-plugin-headers": "~1.0.4", "is-ci": "~3.0.0", "jsdom": "^26.0.0", + "lightningcss": "^1.33.0", "lint-staged": "~15.5.2", "prettier": "3.2.4", "rollup": "~3.30.0", diff --git a/rollup.config.js b/rollup.config.js index 86e755a..d134f99 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -13,9 +13,31 @@ import typescript from '@rollup/plugin-typescript'; import terser from '@rollup/plugin-terser'; import { dts } from 'rollup-plugin-dts'; import { nodeResolve } from '@rollup/plugin-node-resolve'; +import { transform as transformCss } from 'lightningcss'; -const input = 'index.ts'; -const output = 'dist/web-utils'; +// Import `.css` files as strings, so a component can self-host a co-located, editable stylesheet: +// edited as real CSS, minified by lightningcss at build time (comments, incl. the license header, are +// dropped) and injected at runtime. +const cssString = () => ({ + name: 'css-string', + transform(code, id) { + if (!id.endsWith('.css')) { + return null; + } + const { code: min } = transformCss({ filename: id, code: Buffer.from(code), minify: true }); + return { code: `export default ${JSON.stringify(min.toString())};`, map: null }; + } +}); + +// Public entry points, each emitted as a self-contained bundle in dist/: +// - index: pure logic (no DOM, no CSS) → `@ceeblue/web-utils` +// - ui/index: DOM/canvas components → `@ceeblue/web-utils/ui` +const entries = [ + { input: 'index.ts', out: 'dist/web-utils' }, + { input: 'src/ui/index.ts', out: 'dist/ui/web-utils-ui' }, // distinct basename: safe if files get flattened + { input: 'src/ui/timeline.ts', out: 'dist/ui/timeline' }, // custom element (self-registers on import) + { input: 'src/ui/metrics.ts', out: 'dist/ui/metrics' } // custom element (self-registers on import) +]; export default args => { let target; @@ -51,18 +73,20 @@ export default args => { throw new Error('Version is undefined or not a string.'); } - return [ + // Each entry yields three sequential builds: bundle → minify the bundle → type definitions. + return entries.flatMap(entry => [ { // Transpile and bundle the code - input, + input: entry.input, output: { name: process.env.npm_package_name, format, // iife, es, cjs, umd, amd, system compact: true, sourcemap: true, - file: output + '.js' + file: entry.out + '.js' }, plugins: [ + cssString(), replace({ __lib__version__: "'" + version + "'", preventAssignment: true @@ -74,23 +98,23 @@ export default args => { }, { // Minify the bundled code - input: output + '.js', + input: entry.out + '.js', output: { compact: true, sourcemap: true, - file: output + '.min.js' + file: entry.out + '.min.js' }, plugins: [terser()], context: 'window' // Useful for ES5 builds, ensures 'this' refers to 'window' in a browser context }, { // Generate type definitions - input, + input: entry.input, output: { compact: true, - file: output + '.d.ts' + file: entry.out + '.d.ts' }, plugins: [dts()] } - ]; + ]); }; diff --git a/src/Media.spec.ts b/src/Media.spec.ts new file mode 100644 index 0000000..b69e7ca --- /dev/null +++ b/src/Media.spec.ts @@ -0,0 +1,82 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { Type, Codec, MAX_GOP_DURATION, typeToString, screenResolution, overScreenSize } from './Media'; + +describe('Media', () => { + it('exposes the media vocabulary constants', () => { + expect(MAX_GOP_DURATION).toBe(10000); + expect(Type.DATA).toBe(0); + expect(Type.AUDIO).toBe(1); + expect(Type.VIDEO).toBe(2); + expect(Codec.UNKNOWN).toBe(''); + expect(Codec.H264).toBe('H264'); + expect(Codec.OPUS).toBe('OPUS'); + }); + + describe('typeToString', () => { + it('maps each known type to its name', () => { + expect(typeToString(Type.AUDIO)).toBe('audio'); + expect(typeToString(Type.VIDEO)).toBe('video'); + expect(typeToString(Type.DATA)).toBe('data'); + }); + it('falls back to "unknown" for an unmapped value', () => { + expect(typeToString(99 as Type)).toBe('unknown'); + }); + }); + + describe('overScreenSize', () => { + it('is true only when the resolution exceeds the screen on both axes', () => { + expect(overScreenSize({ width: 1920, height: 1080 }, { width: 1280, height: 720 })).toBe(true); + expect(overScreenSize({ width: 1280, height: 720 }, { width: 1920, height: 1080 })).toBe(false); + // wider but not taller → not over on both axes + expect(overScreenSize({ width: 3000, height: 500 }, { width: 1920, height: 1080 })).toBe(false); + }); + it('is falsy when no screen is provided', () => { + expect(overScreenSize({ width: 1920, height: 1080 })).toBeFalsy(); + }); + }); + + describe('screenResolution', () => { + const realScreen = Object.getOwnPropertyDescriptor(window, 'screen'); + const realRatio = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio'); + const setScreen = (value: unknown) => Object.defineProperty(window, 'screen', { configurable: true, value }); + const setRatio = (value: unknown) => + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value }); + + afterEach(() => { + if (realScreen) { + Object.defineProperty(window, 'screen', realScreen); + } + if (realRatio) { + Object.defineProperty(window, 'devicePixelRatio', realRatio); + } + }); + + it('scales landscape dimensions by the device pixel ratio', () => { + setScreen({ width: 1280, height: 720 }); + setRatio(2); + expect(screenResolution()).toEqual({ width: 2560, height: 1440 }); + }); + + it('swaps axes for a portrait screen so it reports the max fullscreen ability', () => { + setScreen({ width: 1080, height: 1920 }); + setRatio(1); + expect(screenResolution()).toEqual({ width: 1920, height: 1080 }); + }); + + it('defaults the ratio to 1 when devicePixelRatio is absent', () => { + setScreen({ width: 800, height: 600 }); + setRatio(0); + expect(screenResolution()).toEqual({ width: 800, height: 600 }); + }); + + it('returns undefined when there is no screen', () => { + setScreen(undefined); + expect(screenResolution()).toBeUndefined(); + }); + }); +}); diff --git a/src/Media.ts b/src/Media.ts new file mode 100644 index 0000000..1c87e9e --- /dev/null +++ b/src/Media.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +/** + * Maximum GOP (group-of-pictures) duration in milliseconds — a convenient averaging window. + */ +export const MAX_GOP_DURATION = 10000; + +/** + * Media type of a track or sample. Numeric so tracks can be ordered (video first). + */ +export enum Type { + DATA = 0, + AUDIO = 1, + VIDEO = 2 +} + +/** + * Media codec, empty string when unknown. + */ +export enum Codec { + UNKNOWN = '', + // Video + H264 = 'H264', + HEVC = 'HEVC', + VP8 = 'VP8', + // Audio + MP3 = 'MP3', + AAC = 'AAC', + OPUS = 'OPUS', + // Data + ID3 = 'ID3', + JSON = 'JSON', + SUBTITLE = 'SUBTITLE' +} + +/** + * A single media sample (frame). This is the protocol-agnostic input vocabulary consumed by UI + * widgets such as `UITimeline`: any producer able to emit this shape can feed them. + */ +export type Sample = { + time: number; + duration: number; + data: Uint8Array; + compositionOffset?: number; + isKeyFrame?: boolean; + subSamples?: Array<{ clearBytes: number; encryptedBytes: number }>; // DRM field for SENC box + iv?: Uint8Array; // DRM per-sample IV (when ContentProtection.ivMode === 'sample') +}; + +/** + * Track selection. + */ +export type Tracks = { + /** + * Audio track, undefined = MBR, -1 = Remove the track + */ + audio?: number; + /** + * Video track, undefined = MBR, -1 = Remove the track + */ + video?: number; + /** + * Datas tracks to receive, undefined = ALL + */ + data?: Set; +}; + +/** + * A pixel resolution. + */ +export type Resolution = { + width: number; + height: number; +}; + +/** + * Human-readable name of a media {@link Type}. + * @param type media type + */ +export function typeToString(type: Type) { + switch (type) { + case Type.AUDIO: + return 'audio'; + case Type.VIDEO: + return 'video'; + case Type.DATA: + return 'data'; + default: + } + return 'unknown'; +} + +/** + * The display resolution in device pixels, or undefined outside a browser. In portrait the axes are + * swapped so the result always represents the maximum fullscreen ability (landscape orientation). + * @returns the screen {@link Resolution}, or undefined when there is no DOM + */ +export function screenResolution(): Resolution | undefined { + if (typeof window === 'undefined' || !window.screen) { + return; + } + const ratio = window.devicePixelRatio || 1; + let height = ratio * window.screen.height; + let width = ratio * window.screen.width; + if (height > width) { + // smartphone, switch to compute max fullscreen ability (height becomes width) + [width, height] = [height, width]; + } + return { width, height }; +} + +/** + * Whether a resolution exceeds the displayable screen. + * @param resolution the resolution to test + * @param screen the screen resolution to compare against + * @returns true when resolution is larger than screen on both axes + */ +export function overScreenSize(resolution: Resolution, screen?: Resolution) { + return screen && resolution.height > screen.height && resolution.width > screen.width; +} diff --git a/src/ui/UIMetrics.css b/src/ui/UIMetrics.css new file mode 100644 index 0000000..e2c10cd --- /dev/null +++ b/src/ui/UIMetrics.css @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +/* + * Self-hosted stylesheet for the UIMetrics widget — injected into its root (the document, or the + * shadow root when embedded as ) at runtime. It consumes the --cb-* design tokens from + * :root, which cross the shadow boundary. + */ +.cb-stats-list { + background: transparent; + list-style: none; + margin: 0; + padding: 0; + display: block; + width: 100%; +} + +/* UIMetrics injects rows directly as children — force them full-width. */ +.cb-stats-list > svg { + display: block; + width: 100%; + border-bottom: 1px solid var(--cb-border, rgba(103, 114, 148, 0.18)); + font-family: var(--cb-f-mono, ui-monospace, monospace); + font-size: 12px; + fill: var(--cb-txt, #3d4459); +} + +.cb-stats-list > svg:last-child { + border-bottom: none; +} diff --git a/src/ui/UIMetrics.spec.ts b/src/ui/UIMetrics.spec.ts new file mode 100644 index 0000000..b8906bb --- /dev/null +++ b/src/ui/UIMetrics.spec.ts @@ -0,0 +1,95 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { UIMetrics } from './UIMetrics'; + +// Capture requestAnimationFrame callbacks so display()'s innerHTML flush is deterministic. +let rafQueue: FrameRequestCallback[] = []; +const flushRaf = () => { + const q = rafQueue; + rafQueue = []; + q.forEach(cb => cb(0)); +}; + +const created: UIMetrics[] = []; +const make = () => { + const ui = document.createElement('ul'); + // jsdom has no layout, so force a width for display() to compute displayable points. + Object.defineProperty(ui, 'clientWidth', { configurable: true, value: 600 }); + document.body.appendChild(ui); + const m = new UIMetrics(ui); + created.push(m); + return { ui, m }; +}; + +beforeEach(() => { + rafQueue = []; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => rafQueue.push(cb)); +}); + +afterEach(() => { + created.forEach(m => m.destroy()); + created.length = 0; + document.body.innerHTML = ''; + vi.unstubAllGlobals(); +}); + +describe('UIMetrics', () => { + it('tags its container and self-hosts its stylesheet', () => { + const { ui } = make(); + expect(ui.classList.contains('cb-stats-list')).toBe(true); + // jsdom lacks constructable stylesheets, so the ${r.type} #${r.id} · seq ${s.n}${s.key ? ' · key' : ''}
` + + `frames ${s.frames} · size ${(s.bytes / 1024).toFixed(1)} KiB
` + + `DTS ${(s.dtsStart / 1000).toFixed(3)}→${(s.dtsEnd / 1000).toFixed(3)}s (${medSpan}ms)
` + + `recv +${(s.recvStart - this._t0).toFixed(0)}→+${(s.recvEnd - this._t0).toFixed(0)}ms (${recvSpan.toFixed(0)}ms)`; + this._tip.style.display = 'block'; + // Flip the tooltip away from the edges so it is never clipped (notably on the bottom row). + const tw = this._tip.offsetWidth; + const th = this._tip.offsetHeight; + let tx = x + 12; + if (tx + tw > this._container.clientWidth) { + tx = Math.max(2, x - tw - 12); + } + let ty = y + 12; + if (ty + th > this._canvas.clientHeight) { + ty = Math.max(2, y - th - 12); + } + this._tip.style.left = tx + 'px'; + this._tip.style.top = ty + 'px'; + } +} + +/** + * `` — a Web Component wrapping {@link UITimeline} in its own shadow root, so a page can + * embed it with a single tag and no build step. Register it with {@link defineTimeline} (or import the + * `@ceeblue/web-utils/ui/timeline` entry, which registers it for you). Styles are self-hosted; colors + * come from the `--cb-*` tokens on `:root` (custom properties cross the shadow boundary). + */ +export class CbTimelineElement extends HTMLElement { + static readonly observedAttributes = ['axis', 'window']; + + private _tl?: UITimeline; + + connectedCallback() { + if (this._tl) { + return; + } + const shadow = this.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + // Custom elements are display:inline by default — give the host a real box. + style.textContent = ':host{display:block;width:100%}.plot{position:relative;width:100%}'; + const plot = document.createElement('div'); + plot.className = 'plot'; + shadow.append(style, plot); + + this._tl = new UITimeline(plot); + const axis = this.getAttribute('axis'); + if (axis) { + this._tl.axis = axis as UITimelineAxis; + } + const win = this.getAttribute('window'); + if (win) { + this._tl.windowDuration = Number(win); + } + } + + disconnectedCallback() { + this._tl?.destroy(); + this._tl = undefined; + } + + attributeChangedCallback(name: string, _oldValue: string | null, value: string | null) { + if (!this._tl || value == null) { + return; + } + if (name === 'axis') { + this._tl.axis = value as UITimelineAxis; + } else if (name === 'window') { + this._tl.windowDuration = Number(value); + } + } + + /** The underlying widget, for reading state (e.g. {@link UITimeline.following}) or feeding samples. */ + get timeline(): UITimeline | undefined { + return this._tl; + } + + pushVideo(track: number, sample: Media.Sample) { + this._tl?.pushVideo(track, sample); + } + pushAudio(track: number, sample: Media.Sample) { + this._tl?.pushAudio(track, sample); + } + pushData(track: number, sample: Media.Sample) { + this._tl?.pushData(track, sample); + } +} + +/** Register the `` custom element (idempotent). */ +export function defineTimeline() { + if (!customElements.get('cb-timeline')) { + customElements.define('cb-timeline', CbTimelineElement); + } +} diff --git a/src/ui/css.d.ts b/src/ui/css.d.ts new file mode 100644 index 0000000..ead5038 --- /dev/null +++ b/src/ui/css.d.ts @@ -0,0 +1,12 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +// Component stylesheets are imported as strings (bundled by the rollup css-string plugin) and injected +// at runtime, so each component can self-host a co-located, editable `.css` file. +declare module '*.css' { + const css: string; + export default css; +} diff --git a/src/ui/elements.spec.ts b/src/ui/elements.spec.ts new file mode 100644 index 0000000..2637391 --- /dev/null +++ b/src/ui/elements.spec.ts @@ -0,0 +1,63 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { defineTimeline, CbTimelineElement } from './UITimeline'; +import { defineMetrics, CbMetricsElement } from './UIMetrics'; +// Importing the entry modules registers the tags on import (covers ui/timeline.ts and ui/metrics.ts). +import './timeline'; +import './metrics'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('custom elements', () => { + it('the entry modules register both tags', () => { + expect(customElements.get('cb-timeline')).toBe(CbTimelineElement); + expect(customElements.get('cb-metrics')).toBe(CbMetricsElement); + }); + + it('defineTimeline/defineMetrics are idempotent (guarded against re-registration)', () => { + expect(() => { + defineTimeline(); + defineMetrics(); + }).not.toThrow(); + }); + + it(' reaches the widget, applies attributes, delegates samples and cleans up', () => { + const el = document.createElement('cb-timeline') as CbTimelineElement; + el.setAttribute('axis', 'media'); + el.setAttribute('window', '5'); + document.body.appendChild(el); + + expect(el.timeline).toBeDefined(); + expect(el.timeline?.axis).toBe('media'); + expect(el.timeline?.windowDuration).toBe(5); + + el.pushVideo(1, { time: 0, duration: 40, isKeyFrame: true, data: new Uint8Array(10) }); + el.pushAudio(2, { time: 0, duration: 40, data: new Uint8Array(5) }); + el.pushData(3, { time: 0, duration: 40, data: new Uint8Array(5) }); + expect(el.timeline?.hasData).toBe(true); + + // attributeChangedCallback on a connected element + el.setAttribute('axis', 'reception'); + expect(el.timeline?.axis).toBe('reception'); + el.setAttribute('window', '8'); + expect(el.timeline?.windowDuration).toBe(8); + + el.remove(); // disconnectedCallback tears the widget down + expect(el.timeline).toBeUndefined(); + }); + + it(' reaches the widget, delegates display and cleans up', () => { + const el = document.createElement('cb-metrics') as CbMetricsElement; + document.body.appendChild(el); + expect(el.metrics).toBeDefined(); + expect(() => el.display(new Map>([['A', [1, 2, 3]]]))).not.toThrow(); + el.remove(); + expect(el.metrics).toBeUndefined(); + }); +}); diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 0000000..1996708 --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +// UI components (DOM/canvas). Import via the `@ceeblue/web-utils/ui` subpath so the pure-logic +// root entry stays free of DOM code. +export { UIMetrics } from './UIMetrics'; +export { UITimeline, UITimelineAxis } from './UITimeline'; diff --git a/src/ui/metrics.ts b/src/ui/metrics.ts new file mode 100644 index 0000000..e7e1c41 --- /dev/null +++ b/src/ui/metrics.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +/** + * Registers the `` custom element on import, so a page can embed it with a single tag + * and no build step: + * ```html + * + * + * ``` + * The `UIMetrics` engine and `CbMetricsElement` class are re-exported for programmatic use. + */ +import { defineMetrics } from './UIMetrics'; + +defineMetrics(); + +export { UIMetrics, CbMetricsElement } from './UIMetrics'; diff --git a/src/ui/timeline.ts b/src/ui/timeline.ts new file mode 100644 index 0000000..4f9b17a --- /dev/null +++ b/src/ui/timeline.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +/** + * Registers the `` custom element on import, so a page can embed it with a single tag + * and no build step: + * ```html + * + * + * ``` + * The `UITimeline` engine and `CbTimelineElement` class are re-exported for programmatic use. + */ +import { defineTimeline } from './UITimeline'; + +defineTimeline(); + +export { UITimeline, UITimelineAxis, CbTimelineElement } from './UITimeline'; diff --git a/tsconfig.json b/tsconfig.json index 248b466..2aa8728 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["./index.ts"], + "include": ["./src/ui/css.d.ts", "./index.ts", "./src/ui/index.ts", "./src/ui/timeline.ts", "./src/ui/metrics.ts"], "compilerOptions": { "strictPropertyInitialization": true, "noImplicitAny": true, diff --git a/vitest.config.ts b/vitest.config.ts index 686abfe..79d00d7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,21 @@ */ import { defineConfig } from 'vitest/config'; +// Mirror the rollup build: import `.css` files as strings, so a component that self-hosts a co-located +// stylesheet (e.g. UIMetrics) can be imported in tests. +const cssString = () => ({ + name: 'css-string', + enforce: 'pre', + transform(code: string, id: string) { + if (id.endsWith('.css')) { + return { code: `export default ${JSON.stringify(code)};`, map: null }; + } + return null; + } +}); + export default defineConfig({ + plugins: [cssString()], test: { globals: true, environment: 'jsdom',