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

This commit is contained in:
Aiden Wilson
2026-05-29 23:55:31 +10:00
parent 30cd5c7b13
commit 959f6590c3
16 changed files with 695 additions and 44 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

554
package-lock.json generated
View File

@@ -8,9 +8,563 @@
"name": "oembed-graphics", "name": "oembed-graphics",
"version": "0.1.0", "version": "0.1.0",
"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"
},
"engines": { "engines": {
"node": ">=20" "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.", "description": "Docker-hosted oEmbed graphics service for broadcast overlays.",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node --watch src/server.js", "dev": "npm run build && tsx watch src/server.ts",
"start": "node src/server.js", "start": "node dist/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", "typecheck": "tsc --noEmit",
"build": "npm run typecheck", "build": "tsc && node scripts/copy-assets.mjs",
"test": "node --test" "test": "npm run build && node --test"
}, },
"engines": { "engines": {
"node": ">=20" "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.body.classList.toggle("transparent", options.transparent);
document.documentElement.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-cover", options.fit === "cover");
stage.classList.toggle("fit-contain", options.fit !== "cover"); stage.classList.toggle("fit-contain", options.fit !== "cover");
} }
@@ -242,7 +245,10 @@ function startAutoplayAssist(root, options) {
} }
function renderEmbed(oembed, sourceUrl, 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 providerClass = `provider-${slugify(oembed.provider_name)}`;
const embedWidth = Math.min(Number(oembed.width) || 500, 500); const embedWidth = Math.min(Number(oembed.width) || 500, 500);
const embedHeight = Number(oembed.height); const embedHeight = Number(oembed.height);

View File

@@ -1,8 +1,12 @@
const dataElement = document.getElementById("collage-data"); const dataElement = document.getElementById("collage-data") as HTMLScriptElement;
const stage = document.querySelector(".collage-stage"); const stage = document.querySelector<HTMLElement>(".collage-stage");
const track = document.querySelector(".collage-track"); const track = document.querySelector<HTMLElement>(".collage-track");
const config = JSON.parse(dataElement.textContent); const config = JSON.parse(dataElement.textContent);
if (!stage || !track) {
throw new Error("Collage stage could not be initialized.");
}
let lastFrame = performance.now(); let lastFrame = performance.now();
let itemCursor = 0; let itemCursor = 0;
const columns = []; const columns = [];
@@ -15,7 +19,7 @@ let trackTop = 0;
let hydrationTimer; let hydrationTimer;
const cardObserver = new ResizeObserver((entries) => { const cardObserver = new ResizeObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
const card = entry.target; const card = entry.target as HTMLElement;
const column = cardColumns.get(card); const column = cardColumns.get(card);
const previousExtent = cardExtent(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_TIMEOUT_MS = 8000;
const DEFAULT_MAX_WIDTH = 500; 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) { if (!maxWidth) {
return maxWidth; return maxWidth;
} }
@@ -11,7 +29,7 @@ function normalizeMaxWidth(endpoint, maxWidth) {
return Math.min(maxWidth, DEFAULT_MAX_WIDTH); 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 requestUrl = new URL(endpoint);
const normalizedMaxWidth = normalizeMaxWidth(endpoint, maxWidth); const normalizedMaxWidth = normalizeMaxWidth(endpoint, maxWidth);
requestUrl.searchParams.set("url", targetUrl); requestUrl.searchParams.set("url", targetUrl);
@@ -28,36 +46,30 @@ export function buildOembedUrl(endpoint, targetUrl, maxWidth, maxHeight) {
return requestUrl; return requestUrl;
} }
export async function fetchOembed(targetUrl, { export async function fetchOembed(targetUrl: string, {
fetchImpl = fetch, fetchImpl = fetch,
providers, providers,
timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS), timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
maxWidth, maxWidth,
maxHeight, maxHeight,
} = {}) { }: FetchOembedOptions = {}) {
let parsed; let parsed;
try { try {
parsed = new URL(targetUrl); parsed = new URL(targetUrl);
} catch { } catch {
const error = new Error("The url parameter must be an absolute URL."); throw httpError("The url parameter must be an absolute URL.", 400);
error.status = 400;
throw error;
} }
if (!["http:", "https:"].includes(parsed.protocol)) { if (!["http:", "https:"].includes(parsed.protocol)) {
const error = new Error("Only http and https URLs are supported."); throw httpError("Only http and https URLs are supported.", 400);
error.status = 400;
throw error;
} }
const availableProviders = providers || await loadProviders({ fetchImpl }); const availableProviders = providers || await loadProviders({ fetchImpl });
const provider = findProvider(targetUrl, availableProviders); const provider = findProvider(targetUrl, availableProviders);
if (!provider) { if (!provider) {
const error = new Error("No oEmbed provider matched this URL."); throw httpError("No oEmbed provider matched this URL.", 404);
error.status = 404;
throw error;
} }
const controller = new AbortController(); const controller = new AbortController();
@@ -73,9 +85,7 @@ export async function fetchOembed(targetUrl, {
}); });
if (!response.ok) { if (!response.ok) {
const error = new Error(`oEmbed provider returned ${response.status}.`); throw httpError(`oEmbed provider returned ${response.status}.`, response.status);
error.status = response.status;
throw error;
} }
const data = await response.json(); const data = await response.json();

View File

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

View File

@@ -1,4 +1,5 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { extname, join } from "node:path"; import { extname, join } from "node:path";
import { fileURLToPath } from "node:url"; 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 __dirname = fileURLToPath(new URL(".", import.meta.url));
const rootDir = join(__dirname, ".."); 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 port = Number(process.env.PORT || 3000);
const host = process.env.HOST || "0.0.0.0"; const host = process.env.HOST || "0.0.0.0";

View File

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

View File

@@ -1,7 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; 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", () => { test("adds autoplay parameters and permissions to iframe embeds", () => {
const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>'); const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>');

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"]
}