feat(embeds): add embed.classroomio.com public widget host

Serve widgets from short public URLs via tenant-router with R2-backed
assets and runtime API_URL injection, so copy-paste snippets hide infra
paths. Update widget payloads, CSP, upload purge URLs, and dev-only API
inject; document format/stage workflow in AGENTS.md and fix ui-prefix-check
YAML syntax.
This commit is contained in:
Rotimi Best
2026-06-12 02:06:37 +01:00
parent c868b2f23f
commit 367ddcddd7
23 changed files with 399 additions and 62 deletions
+49 -3
View File
@@ -7,11 +7,33 @@ import { defineConfig } from 'vite';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const devApiUrl = process.env.API_URL ?? 'http://localhost:3002';
function embedApiUrlInjectPlugin() {
const bootstrapFiles = [
path.resolve(dirname, 'src/widgets/course-widget/bootstrap.ts'),
path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts')
];
return {
name: 'embed-api-url-inject',
transform(code: string, id: string) {
if (!bootstrapFiles.includes(id) || !devApiUrl) {
return null;
}
const snippet = `window.CIO=window.CIO||{};window.CIO.apiBaseUrl=${JSON.stringify(devApiUrl.replace(/\/$/, ''))};`;
return { code: `${snippet}\n${code}`, map: null };
}
};
}
const embedEntries = {
'question-type-picker': path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts'),
'course-widget': path.resolve(dirname, 'src/widgets/course-widget/bootstrap.ts')
} as const;
const embedDevEntrypoints = {
'/question-type-picker': '/src/widgets/question-type-picker/bootstrap.ts',
'/course-widget': '/src/widgets/course-widget/bootstrap.ts',
'/embeds/question-type-picker/question-type-picker.js': '/src/widgets/question-type-picker/bootstrap.ts',
'/embeds/course-widget/course-widget.js': '/src/widgets/course-widget/bootstrap.ts',
'/embeds/course-widget/embed.html': '/src/widgets/course-widget/embed.html'
@@ -26,10 +48,28 @@ function embedDevEntryPlugin() {
server.middlewares.use((req, _res, next) => {
const requestUrl = req.url ?? '';
const [pathname, search = ''] = requestUrl.split('?');
const querySuffix = search ? `?${search}` : '';
if (pathname === '/course-widget') {
const params = new URLSearchParams(search);
const target = params.has('key')
? '/src/widgets/course-widget/embed.html'
: '/src/widgets/course-widget/bootstrap.ts';
req.url = `${target}${querySuffix}`;
next();
return;
}
if (pathname === '/question-type-picker') {
req.url = `/src/widgets/question-type-picker/bootstrap.ts${querySuffix}`;
next();
return;
}
const rewrittenPath = embedDevEntrypoints[pathname as keyof typeof embedDevEntrypoints];
if (rewrittenPath) {
req.url = search ? `${rewrittenPath}?${search}` : rewrittenPath;
req.url = `${rewrittenPath}${querySuffix}`;
}
next();
@@ -44,7 +84,13 @@ export default defineConfig(({ command, mode }) => {
return {
root: dirname,
plugins: [embedDevEntryPlugin(), tailwindcss(), svelte(), cssInjectedByJsPlugin()],
plugins: [
embedDevEntryPlugin(),
...(command === 'serve' ? [embedApiUrlInjectPlugin()] : []),
tailwindcss(),
svelte(),
cssInjectedByJsPlugin()
],
resolve: {
alias: {
'@cio/ui': path.resolve(dirname, 'node_modules/@cio/ui/src'),
@@ -72,7 +118,7 @@ export default defineConfig(({ command, mode }) => {
rollupOptions: {
output: {
inlineDynamicImports: false,
chunkFileNames: '[name]-[hash].js'
chunkFileNames: buildTarget ? `${buildTarget}-[name]-[hash].js` : '[name]-[hash].js'
}
}
}