Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Wilson
3e6b128643 ci update
All checks were successful
Build & Push Docker (latest) / verify (push) Successful in 9m31s
Build & Push Docker (latest) / build (push) Successful in 16s
2026-05-29 23:58:42 +10:00
Aiden Wilson
959f6590c3 conversion to type script
Some checks failed
Build & Push Docker (latest) / build (push) Has been cancelled
Build & Push Docker (latest) / verify (push) Has been cancelled
2026-05-29 23:55:31 +10:00
17 changed files with 701 additions and 50 deletions

View File

@@ -15,7 +15,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: npm
- name: Install dependencies

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
dist
*.lcov
# nyc test coverage

View File

@@ -1,13 +1,16 @@
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm ci
COPY public ./public
COPY src ./src
COPY public ./public
COPY scripts ./scripts
COPY tsconfig.json ./
RUN npm run build && npm prune --omit=dev
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]

View File

@@ -9,11 +9,14 @@ OBS Browser Source, OGraf, or any HTML-capable character generator.
```sh
npm install
npm run build
npm start
```
Open `http://localhost:3000`.
For development, use `npm run dev` to run the TypeScript server in watch mode.
## Run with Docker
```sh

554
package-lock.json generated
View File

@@ -8,9 +8,563 @@
"name": "oembed-graphics",
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.3",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -4,14 +4,19 @@
"description": "Docker-hosted oEmbed graphics service for broadcast overlays.",
"type": "module",
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"typecheck": "node --check src/server.js && node --check src/oembed.js && node --check src/providers.js && node --check src/templates.js && node --check public/caspar.js && node --check public/collage.js",
"build": "npm run typecheck",
"test": "node --test"
"dev": "npm run build && tsx watch src/server.ts",
"start": "node dist/src/server.js",
"typecheck": "tsc --noEmit",
"build": "tsc && node scripts/copy-assets.mjs",
"test": "npm run build && node --test"
},
"engines": {
"node": ">=20"
},
"license": "AGPL-3.0-or-later"
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.3",
"typescript": "^6.0.3"
}
}

View File

@@ -202,7 +202,10 @@ function applyStageOptions(options) {
document.body.classList.toggle("transparent", options.transparent);
document.documentElement.classList.toggle("transparent", options.transparent);
const stage = document.querySelector(".stage");
const stage = document.querySelector<HTMLElement>(".stage");
if (!stage) {
return;
}
stage.classList.toggle("fit-cover", options.fit === "cover");
stage.classList.toggle("fit-contain", options.fit !== "cover");
}
@@ -242,7 +245,10 @@ function startAutoplayAssist(root, options) {
}
function renderEmbed(oembed, sourceUrl, options) {
const embed = document.querySelector(".embed");
const embed = document.querySelector<HTMLElement>(".embed");
if (!embed) {
return;
}
const providerClass = `provider-${slugify(oembed.provider_name)}`;
const embedWidth = Math.min(Number(oembed.width) || 500, 500);
const embedHeight = Number(oembed.height);

View File

@@ -1,8 +1,12 @@
const dataElement = document.getElementById("collage-data");
const stage = document.querySelector(".collage-stage");
const track = document.querySelector(".collage-track");
const dataElement = document.getElementById("collage-data") as HTMLScriptElement;
const stage = document.querySelector<HTMLElement>(".collage-stage");
const track = document.querySelector<HTMLElement>(".collage-track");
const config = JSON.parse(dataElement.textContent);
if (!stage || !track) {
throw new Error("Collage stage could not be initialized.");
}
let lastFrame = performance.now();
let itemCursor = 0;
const columns = [];
@@ -15,7 +19,7 @@ let trackTop = 0;
let hydrationTimer;
const cardObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const card = entry.target;
const card = entry.target as HTMLElement;
const column = cardColumns.get(card);
const previousExtent = cardExtent(card);

20
public/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
type ProviderWidgetRoot = Element | Document;
interface Window {
update?: (payload: unknown) => void;
play?: () => void;
stop?: () => void;
twttr?: {
widgets?: {
load?: (root?: ProviderWidgetRoot) => void;
};
};
instgrm?: {
Embeds?: {
process?: () => void;
};
};
bluesky?: {
scan?: (root?: ProviderWidgetRoot) => void;
};
}

4
scripts/copy-assets.mjs Normal file
View File

@@ -0,0 +1,4 @@
import { cp, mkdir } from "node:fs/promises";
await mkdir("dist/public", { recursive: true });
await cp("public/styles.css", "dist/public/styles.css");

View File

@@ -1,9 +1,27 @@
import { findProvider, loadProviders } from "./providers.js";
import { findProvider, loadProviders, type Provider } from "./providers.js";
const DEFAULT_TIMEOUT_MS = 8000;
const DEFAULT_MAX_WIDTH = 500;
function normalizeMaxWidth(endpoint, maxWidth) {
type HttpError = Error & { status?: number };
type FetchImpl = typeof fetch;
type FetchOembedOptions = {
fetchImpl?: FetchImpl;
providers?: Provider[];
timeoutMs?: number;
maxWidth?: number;
maxHeight?: number;
};
function httpError(message: string, status: number): HttpError {
const error: HttpError = new Error(message);
error.status = status;
return error;
}
function normalizeMaxWidth(_endpoint: string, maxWidth?: number) {
if (!maxWidth) {
return maxWidth;
}
@@ -11,7 +29,7 @@ function normalizeMaxWidth(endpoint, maxWidth) {
return Math.min(maxWidth, DEFAULT_MAX_WIDTH);
}
export function buildOembedUrl(endpoint, targetUrl, maxWidth, maxHeight) {
export function buildOembedUrl(endpoint: string, targetUrl: string, maxWidth?: number, maxHeight?: number) {
const requestUrl = new URL(endpoint);
const normalizedMaxWidth = normalizeMaxWidth(endpoint, maxWidth);
requestUrl.searchParams.set("url", targetUrl);
@@ -28,36 +46,30 @@ export function buildOembedUrl(endpoint, targetUrl, maxWidth, maxHeight) {
return requestUrl;
}
export async function fetchOembed(targetUrl, {
export async function fetchOembed(targetUrl: string, {
fetchImpl = fetch,
providers,
timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
maxWidth,
maxHeight,
} = {}) {
}: FetchOembedOptions = {}) {
let parsed;
try {
parsed = new URL(targetUrl);
} catch {
const error = new Error("The url parameter must be an absolute URL.");
error.status = 400;
throw error;
throw httpError("The url parameter must be an absolute URL.", 400);
}
if (!["http:", "https:"].includes(parsed.protocol)) {
const error = new Error("Only http and https URLs are supported.");
error.status = 400;
throw error;
throw httpError("Only http and https URLs are supported.", 400);
}
const availableProviders = providers || await loadProviders({ fetchImpl });
const provider = findProvider(targetUrl, availableProviders);
if (!provider) {
const error = new Error("No oEmbed provider matched this URL.");
error.status = 404;
throw error;
throw httpError("No oEmbed provider matched this URL.", 404);
}
const controller = new AbortController();
@@ -73,9 +85,7 @@ export async function fetchOembed(targetUrl, {
});
if (!response.ok) {
const error = new Error(`oEmbed provider returned ${response.status}.`);
error.status = response.status;
throw error;
throw httpError(`oEmbed provider returned ${response.status}.`, response.status);
}
const data = await response.json();

View File

@@ -1,22 +1,39 @@
const DEFAULT_PROVIDERS_URL = "https://oembed.com/providers.json";
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
let providerCache = {
expiresAt: 0,
providers: [],
export type RawProvider = {
provider_name: string;
provider_url: string;
endpoints?: Array<{
schemes?: string[];
url: string;
}>;
};
export function wildcardToRegExp(pattern) {
export type Provider = {
providerName: string;
providerUrl: string;
endpoint: string;
scheme: string;
regex: RegExp;
};
let providerCache = {
expiresAt: 0,
providers: [] as Provider[],
};
export function wildcardToRegExp(pattern: string) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`, "i");
}
export function normalizeEndpoint(endpoint) {
export function normalizeEndpoint(endpoint: string) {
return endpoint.replace("{format}", "json");
}
export function flattenProviders(rawProviders) {
const providers = [];
export function flattenProviders(rawProviders: RawProvider[]) {
const providers: Provider[] = [];
for (const provider of rawProviders) {
for (const endpoint of provider.endpoints || []) {
@@ -37,7 +54,7 @@ export function flattenProviders(rawProviders) {
return providers;
}
export function findProvider(url, providers) {
export function findProvider(url: string, providers: Provider[]) {
return providers.find((provider) => provider.regex.test(url));
}
@@ -46,6 +63,11 @@ export async function loadProviders({
ttlMs = Number(process.env.PROVIDERS_TTL_MS || DEFAULT_TTL_MS),
fetchImpl = fetch,
force = false,
}: {
providersUrl?: string;
ttlMs?: number;
fetchImpl?: typeof fetch;
force?: boolean;
} = {}) {
const now = Date.now();
@@ -64,7 +86,7 @@ export async function loadProviders({
throw new Error(`Could not load providers: ${response.status} ${response.statusText}`);
}
const rawProviders = await response.json();
const rawProviders = await response.json() as RawProvider[];
const providers = flattenProviders(rawProviders);
providerCache = {

View File

@@ -1,4 +1,5 @@
import { createServer } from "node:http";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -9,7 +10,8 @@ import { casparTemplatePage, collagePage, errorPage, graphicPage, homePage } fro
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const rootDir = join(__dirname, "..");
const publicDir = join(rootDir, "public");
const compiledPublicDir = join(rootDir, "dist", "public");
const publicDir = existsSync(compiledPublicDir) ? compiledPublicDir : join(rootDir, "public");
const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || "0.0.0.0";

View File

@@ -399,7 +399,7 @@ export function casparTemplatePage() {
<main class="stage fit-contain">
<section class="embed provider-empty" data-source="" style="--embed-width:500px"></section>
</main>
<script src="/caspar.js"></script>
<script type="module" src="/caspar.js"></script>
</body>
</html>`;
}
@@ -477,7 +477,7 @@ export function collagePage({
<div class="collage-track"></div>
</main>
<script id="collage-data" type="application/json">${safeJson(collageData)}</script>
<script src="/collage.js"></script>
<script type="module" src="/collage.js"></script>
</body>
</html>`;
}

View File

@@ -1,8 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildOembedUrl } from "../src/oembed.js";
import { findProvider, flattenProviders, wildcardToRegExp } from "../src/providers.js";
import { buildOembedUrl } from "../dist/src/oembed.js";
import { findProvider, flattenProviders, wildcardToRegExp } from "../dist/src/providers.js";
test("wildcard provider schemes match target URLs", () => {
const regex = wildcardToRegExp("https://*.example.com/watch*");

View File

@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { casparTemplatePage, collagePage, graphicPage, homePage, prepareEmbedHtml } from "../src/templates.js";
import { casparTemplatePage, collagePage, graphicPage, homePage, prepareEmbedHtml } from "../dist/src/templates.js";
test("adds autoplay parameters and permissions to iframe embeds", () => {
const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>');
@@ -207,7 +207,7 @@ test("renders a CasparCG template shell", () => {
assert.match(page, /<body class="graphic transparent is-loading"/);
assert.match(page, /<section class="embed provider-empty"/);
assert.match(page, /<script src="\/caspar\.js"><\/script>/);
assert.match(page, /<script type="module" src="\/caspar\.js"><\/script>/);
});
test("renders a vertical scrolling collage page with repeated embed cards", () => {
@@ -261,7 +261,7 @@ test("renders a vertical scrolling collage page with repeated embed cards", () =
assert.match(page, /"hydrateDelay":180/);
assert.match(page, /"repeat":3/);
assert.match(page, /"shuffle":false/);
assert.match(page, /<script src="\/collage\.js"><\/script>/);
assert.match(page, /<script type="module" src="\/collage\.js"><\/script>/);
});
test("shrinks collage card width to fit the configured screen width", () => {

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"rootDir": ".",
"outDir": "dist",
"strict": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "public/**/*.ts"],
"exclude": ["dist", "node_modules"]
}