openvibers/openvibe.shared Commits
The repository root is the package: exports for every module, test runner, CI, v1.0.0 changelog
Changes
nnew file mode 100644@@@ -0,0 +1,3 @@++* text=auto eol=lf++*.png binary++*.woff2 binarynnew file mode 100644@@@ -0,0 +1,27 @@++name: ci++on:++ push:++ branches: [main]++ tags: ['v*']++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npm test++ - name: size report++ run: node scripts/size-report.js++ # The release artifact is GitHub's tarball of the tag (repository root = package root):++ # install one built the same way into an empty project and load it by its package name.++ - name: install from a tag-style tarball++ run: |++ git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD++ mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null++ npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"++ node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"nnew file mode 100644@@@ -0,0 +1,2 @@++node_modules/++*.tgznnew file mode 100644@@@ -0,0 +1,38 @@++# Changelog++++All notable changes to `openvibe-shared`. Versions follow [semver](https://semver.org/): a++breaking change to any exported module, browser global or served file name is a new major.++A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++++## 1.0.0 — 2026-09-22++++First release from its own repository. Imported history-free from++`OpenVibe.Network/packages/openvibe-shared` at Network commit `f337eb6`; every module is++byte-identical to that commit except `navbar.js`, which only changed as listed below.++Vendored copies in Media, Tools and Community carried a stale `ov-icons.js` (and Community a++stale navbar test); canonical was correct everywhere — see `docs/divergence-2026-09-22.md`.++++### Changed++- **navbar.js no longer ships every glyph.** The 87 Font Awesome solid glyph paths moved to a new++ browser file, `nav-icons.js`. navbar.js keeps the 13 glyphs it draws itself (account menu,++ admin link, wallet chip) plus the widths of the rest, and fetches `nav-icons.js?v=<content hash>`++ once, after first paint, the first time a site's link, chip or menu row names another glyph —++ from the directory navbar.js was loaded from, else `https://openvibe.network/shared/`. The slot++ is an empty `<svg>` of the glyph's exact size until the path arrives, so nothing moves; the++ finished DOM and pixels are identical to the Network copy (checked in headless Chrome). If++ `nav-icons.js` cannot load, those icons fall back to `<i class="fa-solid …">` (the behaviour++ before inline glyphs). A page that loads `nav-icons.js` before navbar.js gets every glyph++ synchronously. New option: `iconsUrl`.++ navbar.js: 151.8 KB raw / 39.0 KB brotli → 118.6 KB / 27.6 KB.++- `scripts/build-nav-icons.py` now writes `nav-icons.js`; `scripts/build-navbar-icons.js`++ regenerates navbar.js's block from it (`--check` in the tests).++++### Added++- `files.js` (`require('openvibe-shared/files')`): the list of browser files a site may serve at++ `/shared/<file>`, the package directory, `isBrowserFile()` and `path()`.++- `exports` for every module (`openvibe-shared/navbar`, `/nav-icons`, `/theme-loader`, … and++ `/package.json`); every subpath the Network copy exported still resolves to the same file.++- Tests: `test/run.js`, a navbar.js size budget (29 KB brotli) and icon availability test,++ browser-bundle hygiene (no `require()` or Node globals in served files), package exports.++- `scripts/size-report.js`: raw and brotli (q11) size of every browser file.++- CI on Node 22.22.1, including an install of a tag-style tarball into an empty project.nnew file mode 100644@@@ -0,0 +1,154 @@++{++ "name": "openvibe-shared",++ "version": "1.0.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-shared",++ "version": "1.0.0",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ }++ }++}@@@ -1,24 +1,58 @@{"name": "openvibe-shared","version": "1.0.0",-- "description": "Shared auth, theme, and brand utilities for the OpenVibe",++ "description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {".": "./index.js",++ "./package.json": "./package.json",++ "./files": "./files.js",++ "./account-switcher": "./account-switcher.js","./analytics": "./analytics.js",++ "./app-icon": "./app-icon.js","./auth-client": "./auth-client.js",-- "./theme-sync": "./theme-sync.js","./brand": "./brand.js",++ "./builtin-themes": "./builtin-themes.js",++ "./chrome-ssr": "./chrome-ssr.js",++ "./footer": "./footer.js",++ "./history": "./history.js",++ "./icons": "./ov-icons.js",++ "./island": "./island.js",++ "./legal": "./legal.js","./middleware": "./middleware.js",++ "./nav-icons": "./nav-icons.js",++ "./navbar": "./navbar.js",++ "./notification-ui": "./notification-ui.js","./notifications": "./notifications.js",-- "./url-resolver": "./url-resolver.js",++ "./openvibe-sw": "./openvibe-sw.js",++ "./ov-icons": "./ov-icons.js",++ "./ov-mark": "./ov-mark.js",++ "./panels": "./panels.js","./seo": "./seo.js",-- "./icons": "./ov-icons.js",-- "./legal": "./legal.js",-- "./footer": "./footer.js",-- "./chrome-ssr": "./chrome-ssr.js",-- "./app-icon": "./app-icon.js"++ "./sso-client": "./sso-client.js",++ "./theme-loader": "./theme-loader.js",++ "./theme-sync": "./theme-sync.js",++ "./tooltip": "./tooltip.js",++ "./ui": "./ui.js",++ "./url-resolver": "./url-resolver.js",++ "./user-card": "./user-card.js"++ },++ "files": [++ "*.js",++ "scripts/",++ "CHANGELOG.md"++ ],++ "scripts": {++ "test": "node test/run.js",++ "size": "node scripts/size-report.js",++ "build:icons": "python3 scripts/build-nav-icons.py",++ "build:themes": "node scripts/build-theme-loader.js"++ },++ "repository": {++ "type": "git",++ "url": "git+https://github.com/OpenVibers/OpenVibe.Shared.git"},++ "homepage": "https://github.com/OpenVibers/OpenVibe.Shared#readme","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -6,8 +6,8 @@* theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so* it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:*-- * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite-- * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale++ * node scripts/build-theme-loader.js # rewrite++ * node scripts/build-theme-loader.js --check # exit 1 if stale** Every entry carries the FULL variable set (the theme's own values over the default theme's), so* switching themes replaces every token — a theme that does not name --success no longer keeps the@@@ -46,6 +46,6 @@ const { a, b } = currentBlock(src);const next = src.slice(0, a) + render() + src.slice(b);const check = process.argv.includes('--check');if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }--if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }++if (check) { console.error('theme-loader.js is stale: run node scripts/build-theme-loader.js'); process.exit(1); }fs.writeFileSync(LOADER, next);console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++// Browser files (files.js BROWSER) run as plain <script> tags on every OpenVibe site: no++// require(), no Node globals, CommonJS export only behind a typeof check, and every one parses.++// Node-only modules stay out of the list.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const { BROWSER, dir, isBrowserFile, path: filePath } = require('../files');++++const SERVER_ONLY = [++ [/\brequire\s*\(/, 'require()'],++ [/\brequire\.resolve\b/, 'require.resolve'],++ [/\b__dirname\b|\b__filename\b/, '__dirname/__filename'],++ [/\bprocess\.(env|argv|cwd|exit)\b/, 'process.*'],++ [/\bBuffer\.(from|alloc|concat)\b/, 'Buffer'],++];++// Comments may mention require('openvibe-shared/…') in usage notes.++const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');++++for (const f of BROWSER) {++ const file = path.join(dir, f);++ assert.ok(fs.existsSync(file), `${f} exists`);++ const src = fs.readFileSync(file, 'utf8');++ const code = stripComments(src);++ for (const [re, what] of SERVER_ONLY) assert.ok(!re.test(code), `${f} uses ${what} (server-only)`);++ for (const m of code.matchAll(/module\.exports\s*=/g)) {++ const line = code.slice(code.lastIndexOf('\n', m.index) + 1, code.indexOf('\n', m.index));++ assert.ok(/typeof module/.test(line), `${f}: module.exports only behind a typeof module check (${line.trim()})`);++ }++ assert.doesNotThrow(() => new vm.Script(src, { filename: f }), `${f} parses as a classic script`);++}++++// Every root module that pulls in other modules with require() is Node-only and never served.++for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.js'))) {++ if (/\brequire\s*\(/.test(stripComments(fs.readFileSync(path.join(dir, f), 'utf8')))) assert.ok(!isBrowserFile(f), `${f} requires modules; it must not be listed as a browser file`);++}++assert.ok(!isBrowserFile('middleware.js') && !isBrowserFile('../package.json'));++assert.throws(() => filePath('auth-client.js'), /not a browser file/);++assert.strictEqual(filePath('navbar.js'), path.join(dir, 'navbar.js'));++++console.log(`browser bundles: ${BROWSER.length} files clean`);nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++// Every subpath consumers use, and every module file, resolves through the package's own name++// (Node self-reference), so require('openvibe-shared/<x>') works the same once installed.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const pkg = require('../package.json');++++const ROOT = path.join(__dirname, '..');++const modules = fs.readdirSync(ROOT).filter((f) => f.endsWith('.js'));++const targets = new Set(Object.values(pkg.exports).map((t) => t.replace(/^\.\//, '')));++for (const f of modules) assert.ok(targets.has(f), `${f} is exported`);++for (const [sub, target] of Object.entries(pkg.exports)) {++ assert.ok(fs.existsSync(path.join(ROOT, target)), `${sub} -> ${target} exists`);++ assert.strictEqual(require.resolve(sub === '.' ? pkg.name : `${pkg.name}/${sub.slice(2)}`), path.join(ROOT, target), `${sub} resolves`);++}++// Subpaths in use across Network, Live, Media, Tools, Community and Sites (2026-09-22).++for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'theme-sync', 'url-resolver', 'package.json', 'navbar', 'files']) {++ assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);++}++// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).++for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons']) require(`openvibe-shared/${sub}`);++assert.strictEqual(typeof require('openvibe-shared/navbar').init, 'function');++assert.ok(/^\d+\.\d+\.\d+$/.test(pkg.version), 'semver version');++assert.ok(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8').includes(`## ${pkg.version}`), 'CHANGELOG has an entry for this version');++console.log(`package: ${Object.keys(pkg.exports).length} exports resolve`);nnew file mode 100644@@@ -0,0 +1,62 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, a few at a++ * time, and fails if any of them fails.++ *++ * npm test # everything++ * npm test -- navbar icons # only files whose name contains one of the words++ *++ * Plain Node with stubbed browser globals: no browser, no network, no running site.++ */++'use strict';++const { spawn } = require('child_process');++const fs = require('fs');++const os = require('os');++const path = require('path');++++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const files = fs.readdirSync(__dirname)++ .filter((f) => f.endsWith('.test.js'))++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))++ .sort();++const PARALLEL = Math.max(1, Math.min(4, os.cpus().length - 1));++const TIMEOUT_MS = 60000;++++function runOne(file) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [path.join(__dirname, file)], {++ cwd: path.join(__dirname, '..'),++ env: { ...process.env, NODE_ENV: 'test' },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => {++ clearTimeout(timer);++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });++ });++ });++}++++(async () => {++ if (!files.length) { console.error('no test files matched'); process.exit(1); }++ const queue = [...files];++ const results = [];++ await Promise.all(Array.from({ length: PARALLEL }, async () => {++ while (queue.length) {++ const r = await runOne(queue.shift());++ results.push(r);++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(36)} ${String(r.ms).padStart(6)}ms`);++ }++ }));++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) {++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);++ console.log(r.output.split('\n').slice(-40).join('\n'));++ }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+* text=auto eol=lf
+*.png binary
+*.woff2 binary
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ tags: ['v*']
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ - name: size report
+ run: node scripts/size-report.js
+ # The release artifact is GitHub's tarball of the tag (repository root = package root):
+ # install one built the same way into an empty project and load it by its package name.
+ - name: install from a tag-style tarball
+ run: |
+ git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
+ mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
+ npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
+ node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+node_modules/
+*.tgz
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+# Changelog
+
+All notable changes to `openvibe-shared`. Versions follow [semver](https://semver.org/): a
+breaking change to any exported module, browser global or served file name is a new major.
+A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+
+## 1.0.0 — 2026-09-22
+
+First release from its own repository. Imported history-free from
+`OpenVibe.Network/packages/openvibe-shared` at Network commit `f337eb6`; every module is
+byte-identical to that commit except `navbar.js`, which only changed as listed below.
+Vendored copies in Media, Tools and Community carried a stale `ov-icons.js` (and Community a
+stale navbar test); canonical was correct everywhere — see `docs/divergence-2026-09-22.md`.
+
+### Changed
+- **navbar.js no longer ships every glyph.** The 87 Font Awesome solid glyph paths moved to a new
+ browser file, `nav-icons.js`. navbar.js keeps the 13 glyphs it draws itself (account menu,
+ admin link, wallet chip) plus the widths of the rest, and fetches `nav-icons.js?v=<content hash>`
+ once, after first paint, the first time a site's link, chip or menu row names another glyph —
+ from the directory navbar.js was loaded from, else `https://openvibe.network/shared/`. The slot
+ is an empty `<svg>` of the glyph's exact size until the path arrives, so nothing moves; the
+ finished DOM and pixels are identical to the Network copy (checked in headless Chrome). If
+ `nav-icons.js` cannot load, those icons fall back to `<i class="fa-solid …">` (the behaviour
+ before inline glyphs). A page that loads `nav-icons.js` before navbar.js gets every glyph
+ synchronously. New option: `iconsUrl`.
+ navbar.js: 151.8 KB raw / 39.0 KB brotli → 118.6 KB / 27.6 KB.
+- `scripts/build-nav-icons.py` now writes `nav-icons.js`; `scripts/build-navbar-icons.js`
+ regenerates navbar.js's block from it (`--check` in the tests).
+
+### Added
+- `files.js` (`require('openvibe-shared/files')`): the list of browser files a site may serve at
+ `/shared/<file>`, the package directory, `isBrowserFile()` and `path()`.
+- `exports` for every module (`openvibe-shared/navbar`, `/nav-icons`, `/theme-loader`, … and
+ `/package.json`); every subpath the Network copy exported still resolves to the same file.
+- Tests: `test/run.js`, a navbar.js size budget (29 KB brotli) and icon availability test,
+ browser-bundle hygiene (no `require()` or Node globals in served files), package exports.
+- `scripts/size-report.js`: raw and brotli (q11) size of every browser file.
+- CI on Node 22.22.1, including an install of a tag-style tarball into an empty project.
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+{
+ "name": "openvibe-shared",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-shared",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ }
+ }
+}
@@ -1,24 +1,58 @@
@@ -1,24 +1,58 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
"version": "1.0.0",
"version": "1.0.0",
- "description": "Shared auth, theme, and brand utilities for the OpenVibe",
+ "description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
".": "./index.js",
".": "./index.js",
+ "./package.json": "./package.json",
+ "./files": "./files.js",
+ "./account-switcher": "./account-switcher.js",
"./analytics": "./analytics.js",
"./analytics": "./analytics.js",
+ "./app-icon": "./app-icon.js",
"./auth-client": "./auth-client.js",
"./auth-client": "./auth-client.js",
- "./theme-sync": "./theme-sync.js",
"./brand": "./brand.js",
"./brand": "./brand.js",
+ "./builtin-themes": "./builtin-themes.js",
+ "./chrome-ssr": "./chrome-ssr.js",
+ "./footer": "./footer.js",
+ "./history": "./history.js",
+ "./icons": "./ov-icons.js",
+ "./island": "./island.js",
+ "./legal": "./legal.js",
"./middleware": "./middleware.js",
"./middleware": "./middleware.js",
+ "./nav-icons": "./nav-icons.js",
+ "./navbar": "./navbar.js",
+ "./notification-ui": "./notification-ui.js",
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
- "./url-resolver": "./url-resolver.js",
+ "./openvibe-sw": "./openvibe-sw.js",
+ "./ov-icons": "./ov-icons.js",
+ "./ov-mark": "./ov-mark.js",
+ "./panels": "./panels.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
- "./icons": "./ov-icons.js",
- "./legal": "./legal.js",
- "./footer": "./footer.js",
- "./chrome-ssr": "./chrome-ssr.js",
- "./app-icon": "./app-icon.js"
+ "./sso-client": "./sso-client.js",
+ "./theme-loader": "./theme-loader.js",
+ "./theme-sync": "./theme-sync.js",
+ "./tooltip": "./tooltip.js",
+ "./ui": "./ui.js",
+ "./url-resolver": "./url-resolver.js",
+ "./user-card": "./user-card.js"
+ },
+ "files": [
+ "*.js",
+ "scripts/",
+ "CHANGELOG.md"
+ ],
+ "scripts": {
+ "test": "node test/run.js",
+ "size": "node scripts/size-report.js",
+ "build:icons": "python3 scripts/build-nav-icons.py",
+ "build:themes": "node scripts/build-theme-loader.js"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/OpenVibers/OpenVibe.Shared.git"
},
},
+ "homepage": "https://github.com/OpenVibers/OpenVibe.Shared#readme",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -6,8 +6,8 @@
@@ -6,8 +6,8 @@
* theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so
* theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so
* it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:
* it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:
*
*
- * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite
- * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale
+ * node scripts/build-theme-loader.js # rewrite
+ * node scripts/build-theme-loader.js --check # exit 1 if stale
*
*
* Every entry carries the FULL variable set (the theme's own values over the default theme's), so
* Every entry carries the FULL variable set (the theme's own values over the default theme's), so
* switching themes replaces every token — a theme that does not name --success no longer keeps the
* switching themes replaces every token — a theme that does not name --success no longer keeps the
@@ -46,6 +46,6 @@ const { a, b } = currentBlock(src);
@@ -46,6 +46,6 @@ const { a, b } = currentBlock(src);
const next = src.slice(0, a) + render() + src.slice(b);
const next = src.slice(0, a) + render() + src.slice(b);
const check = process.argv.includes('--check');
const check = process.argv.includes('--check');
if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
-if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }
+if (check) { console.error('theme-loader.js is stale: run node scripts/build-theme-loader.js'); process.exit(1); }
fs.writeFileSync(LOADER, next);
fs.writeFileSync(LOADER, next);
console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);
console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+// Browser files (files.js BROWSER) run as plain <script> tags on every OpenVibe site: no
+// require(), no Node globals, CommonJS export only behind a typeof check, and every one parses.
+// Node-only modules stay out of the list.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const { BROWSER, dir, isBrowserFile, path: filePath } = require('../files');
+
+const SERVER_ONLY = [
+ [/\brequire\s*\(/, 'require()'],
+ [/\brequire\.resolve\b/, 'require.resolve'],
+ [/\b__dirname\b|\b__filename\b/, '__dirname/__filename'],
+ [/\bprocess\.(env|argv|cwd|exit)\b/, 'process.*'],
+ [/\bBuffer\.(from|alloc|concat)\b/, 'Buffer'],
+];
+// Comments may mention require('openvibe-shared/…') in usage notes.
+const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
+
+for (const f of BROWSER) {
+ const file = path.join(dir, f);
+ assert.ok(fs.existsSync(file), `${f} exists`);
+ const src = fs.readFileSync(file, 'utf8');
+ const code = stripComments(src);
+ for (const [re, what] of SERVER_ONLY) assert.ok(!re.test(code), `${f} uses ${what} (server-only)`);
+ for (const m of code.matchAll(/module\.exports\s*=/g)) {
+ const line = code.slice(code.lastIndexOf('\n', m.index) + 1, code.indexOf('\n', m.index));
+ assert.ok(/typeof module/.test(line), `${f}: module.exports only behind a typeof module check (${line.trim()})`);
+ }
+ assert.doesNotThrow(() => new vm.Script(src, { filename: f }), `${f} parses as a classic script`);
+}
+
+// Every root module that pulls in other modules with require() is Node-only and never served.
+for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.js'))) {
+ if (/\brequire\s*\(/.test(stripComments(fs.readFileSync(path.join(dir, f), 'utf8')))) assert.ok(!isBrowserFile(f), `${f} requires modules; it must not be listed as a browser file`);
+}
+assert.ok(!isBrowserFile('middleware.js') && !isBrowserFile('../package.json'));
+assert.throws(() => filePath('auth-client.js'), /not a browser file/);
+assert.strictEqual(filePath('navbar.js'), path.join(dir, 'navbar.js'));
+
+console.log(`browser bundles: ${BROWSER.length} files clean`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+// Every subpath consumers use, and every module file, resolves through the package's own name
+// (Node self-reference), so require('openvibe-shared/<x>') works the same once installed.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const pkg = require('../package.json');
+
+const ROOT = path.join(__dirname, '..');
+const modules = fs.readdirSync(ROOT).filter((f) => f.endsWith('.js'));
+const targets = new Set(Object.values(pkg.exports).map((t) => t.replace(/^\.\//, '')));
+for (const f of modules) assert.ok(targets.has(f), `${f} is exported`);
+for (const [sub, target] of Object.entries(pkg.exports)) {
+ assert.ok(fs.existsSync(path.join(ROOT, target)), `${sub} -> ${target} exists`);
+ assert.strictEqual(require.resolve(sub === '.' ? pkg.name : `${pkg.name}/${sub.slice(2)}`), path.join(ROOT, target), `${sub} resolves`);
+}
+// Subpaths in use across Network, Live, Media, Tools, Community and Sites (2026-09-22).
+for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'theme-sync', 'url-resolver', 'package.json', 'navbar', 'files']) {
+ assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);
+}
+// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).
+for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons']) require(`openvibe-shared/${sub}`);
+assert.strictEqual(typeof require('openvibe-shared/navbar').init, 'function');
+assert.ok(/^\d+\.\d+\.\d+$/.test(pkg.version), 'semver version');
+assert.ok(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8').includes(`## ${pkg.version}`), 'CHANGELOG has an entry for this version');
+console.log(`package: ${Object.keys(pkg.exports).length} exports resolve`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, a few at a
+ * time, and fails if any of them fails.
+ *
+ * npm test # everything
+ * npm test -- navbar icons # only files whose name contains one of the words
+ *
+ * Plain Node with stubbed browser globals: no browser, no network, no running site.
+ */
+'use strict';
+const { spawn } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const files = fs.readdirSync(__dirname)
+ .filter((f) => f.endsWith('.test.js'))
+ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
+ .sort();
+const PARALLEL = Math.max(1, Math.min(4, os.cpus().length - 1));
+const TIMEOUT_MS = 60000;
+
+function runOne(file) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [path.join(__dirname, file)], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, NODE_ENV: 'test' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => {
+ clearTimeout(timer);
+ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
+ });
+ });
+}
+
+(async () => {
+ if (!files.length) { console.error('no test files matched'); process.exit(1); }
+ const queue = [...files];
+ const results = [];
+ await Promise.all(Array.from({ length: PARALLEL }, async () => {
+ while (queue.length) {
+ const r = await runOne(queue.shift());
+ results.push(r);
+ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(36)} ${String(r.ms).padStart(6)}ms`);
+ }
+ }));
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) {
+ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
+ console.log(r.output.split('\n').slice(-40).join('\n'));
+ }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();