feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
+130
View File
@@ -0,0 +1,130 @@
# @cio/embeds
Standalone **ES module** bundles for third-party sites (no dependency on `apps/website`). The question-type-picker entry shows a **small loading spinner** (same style as `@cio/ui` `Spinner`) as soon as the bootstrap script runs, then loads the main widget chunk—so slow connections see feedback while the larger bundle downloads.
## Question type picker
Build outputs **`dist/question-type-picker.js`** (bootstrap) plus **additional `question-type-picker-*.js` chunks**. Upload **every** `.js` file from `dist/` to the same directory on your CDN so dynamic `import()` resolves correctly.
CSS for the widget is injected when the main chunk runs.
### Develop
```bash
pnpm --filter @cio/embeds dev
```
Open the dev harness at `http://localhost:5180` (see root `index.html`).
### Build
```bash
pnpm --filter @cio/embeds build
```
`prebuild` runs `@cio/ui` Tailwind generation so `@cio/ui/output.css` exists for `src/embed.css`.
## Deploy to Cloudflare R2
Built files are uploaded with the same S3-compatible credentials and **`assets`** bucket used for Storybook. Objects live under a **per-widget prefix** so future embeds do not clash:
| Widget | R2 key prefix |
| -------------------- | ------------------------------ |
| Question type picker | `embeds/question-type-picker/` |
After upload, every file from `dist/` appears at `embeds/question-type-picker/<filename>`. The bootstrap script is always **`question-type-picker.js`**; lazy-loaded chunks use **content hashes** in the filename (for example `question-type-picker-embed-mount-7a3b2c1d.js`) so they can be cached for a long time. The upload script sets a **short TTL + `must-revalidate`** on the bootstrap only, so a normal refresh picks up new chunk URLs after you deploy. If you still see an old UI, hard-refresh or purge your CDN cache for `question-type-picker.js`.
Map your **public CDN or custom domain** to this bucket (or path) so those URLs are reachable over HTTPS.
### Environment variables
Set these locally (e.g. `.env` in `apps/embeds`, loaded by the upload script) or in CI:
- `CLOUDFLARE_ACCESS_KEY`
- `CLOUDFLARE_SECRET_ACCESS_KEY`
- `CLOUDFLARE_ACCOUNT_ID`
**CDN cache purge (optional):** R2 is only storage; if `assets.cdn.clsrio.com` (or your public host) is **proxied through Cloudflare**, edge cache can still serve old bytes after upload. The upload script can call Cloudflare’s **Purge Cache** API for every file URL it just published.
- `CLOUDFLARE_API_TOKEN` — API token with **Zone → Cache Purge → Purge** on the zone that serves the asset hostname (not the R2 S3 access key).
- `CLOUDFLARE_ZONE_ID` — that zone’s ID (dashboard → site → Overview → API).
- `EMBED_CDN_BASE_URL` — public origin for URLs to purge, default `https://assets.cdn.clsrio.com` (no trailing slash). Override if your embeds are served under another host.
- `EMBED_SKIP_CDN_PURGE=1` — skip purge (e.g. local testing).
If `CLOUDFLARE_API_TOKEN` or `CLOUDFLARE_ZONE_ID` is missing, upload still succeeds and purge is skipped.
### Manual publish (build + upload)
From the repo root:
```bash
pnpm --filter @cio/embeds run embeds:publish
```
Or from `apps/embeds` after a build:
```bash
pnpm upload:embeds
```
`pnpm upload:embeds` only uploads `dist/`; run `pnpm build` first if needed.
### GitHub Actions
Workflow: [`.github/workflows/deploy-embeds.yml`](../../.github/workflows/deploy-embeds.yml).
- **When it runs:** push to **`main`** that touches `packages/ui/**`, `packages/question-types/**`, or `apps/embeds/**`, or **workflow_dispatch**.
- **What it does:** `pnpm i`, then `pnpm --filter @cio/embeds run embeds:publish`.
- **Secrets:** the job uses the **`production`** environment; configure the R2 `CLOUDFLARE_ACCESS_KEY`, `CLOUDFLARE_SECRET_ACCESS_KEY`, and `CLOUDFLARE_ACCOUNT_ID` there. Optionally add `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ZONE_ID` so each deploy purges the Cloudflare CDN cache for the published embed URLs. Optional repo variable `EMBED_CDN_BASE_URL` overrides the default public base URL used when building purge URLs.
## Embed on a site
The bootstrap file **`question-type-picker.js`** loads additional chunks via dynamic `import()`. Browsers resolve those URLs **relative to the bootstrap script’s URL**, so **every file from `dist/` must be served from the same directory** (same path prefix on your CDN). Do not host only the bootstrap file.
### Production (CDN / R2 public URL)
Use your public base URL for the picker folder—for example:
```text
https://<your-public-host>/embeds/question-type-picker/
```
Then reference the bootstrap script under that path.
**Auto-mount** (looks for `[data-cio-widget="question-type-picker"]`):
```html
<div data-cio-widget="question-type-picker"></div>
<script type="module" src="https://<your-public-host>/embeds/question-type-picker/question-type-picker.js"></script>
```
**Manual mount** (no `data-cio-widget` on that node—avoid double-mounting):
```html
<div id="qtp"></div>
<script type="module">
await import('https://<your-public-host>/embeds/question-type-picker/question-type-picker.js');
const el = document.getElementById('qtp');
if (el) window.CIO?.questionTypePicker?.mount(el);
</script>
```
### Local or any static host
If you copy all of `dist/` to a single URL prefix, set `src` / `import()` to that prefix + `question-type-picker.js` (same pattern as above, with your host and path).
Use a **`type="module"`** script in all cases; the bundle is ES modules with code-splitting.
### Local smoke test
From `apps/embeds`, after `pnpm build`, serve the folder and open `example-embed.html`:
```bash
pnpm build && npx serve . -p 5190
# open http://localhost:5190/example-embed.html
```
## Adding another widget
1. Add `src/widgets/<name>/widget.svelte` and a bootstrap entry (same pattern as `question-type-picker`).
2. Add a dedicated `build.lib.entry` or Vite config if you need a separate publishable script.
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CDN</title>
</head>
<body>
<div data-cio-widget="question-type-picker"></div>
<script
type="module"
src="https://assets.cdn.clsrio.com/embeds/question-type-picker/question-type-picker.js"
></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Question type picker embed (static test)</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 2rem;
background: #fafafa;
}
</style>
</head>
<body>
<p style="max-width: 56rem; margin: 0 auto 1rem; color: #52525b; font-size: 0.875rem">
Serve this directory over HTTP (e.g. <code>npx serve .</code> from <code>apps/embeds</code> after
<code>pnpm build</code>). File URLs will not load the script correctly.
</p>
<div style="max-width: 56rem; margin: 0 auto" data-cio-widget="question-type-picker"></div>
<script type="module" src="./dist/question-type-picker.js"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ClassroomIO embeds — dev harness</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 2rem;
background: #f4f4f5;
}
.harness-note {
max-width: 56rem;
margin: 0 auto 1.5rem;
font-size: 0.875rem;
color: #52525b;
}
#demo {
max-width: 56rem;
margin: 0 auto;
}
</style>
</head>
<body>
<p class="harness-note">
Dev harness for the question-type-picker embed. Production sites load
<code>question-type-picker.js</code> and a mount target only.
</p>
<div id="demo" data-cio-widget="question-type-picker"></div>
<script type="module" src="/src/widgets/question-type-picker/bootstrap.ts"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@cio/embeds",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 5180",
"prebuild": "pnpm --filter @cio/ui run build",
"build": "rm -rf dist && vite build",
"format": "prettier --write .",
"preview": "vite preview --port 4180",
"upload:embeds": "tsx scripts/upload-embeds.ts",
"embeds:publish": "pnpm build && pnpm upload:embeds"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.388.0",
"@cio/tsconfig": "workspace:*",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
"svelte": "^5.39.11",
"dotenv": "^16.3.1",
"tsx": "^4.7.1",
"typescript": "^5.5.0",
"vite": "^7.1.4",
"vite-plugin-css-injected-by-js": "^3.5.2"
},
"dependencies": {
"@cio/ui": "workspace:*",
"@cio/question-types": "workspace:*",
"@lucide/svelte": "^0.562.0"
}
}
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env tsx
/**
* Upload built embed assets to Cloudflare R2 (S3-compatible), same bucket pattern as Storybook.
*
* Each widget gets its own prefix under `embeds/<widget>/` so future widgets stay isolated.
*
* @see packages/storybook/scripts/upload-storybook.ts
*/
import 'dotenv/config';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { join, relative } from 'node:path';
import { readFileSync, readdirSync, statSync } from 'node:fs';
const BUCKET = 'assets';
/** R2 object key prefix (no trailing slash). */
const EMBED_WIDGET_PREFIX = 'embeds/question-type-picker';
/** Cloudflare allows a limited number of URLs per purge request. */
const PURGE_URL_BATCH = 30;
const DEFAULT_CDN_BASE = 'https://assets.cdn.clsrio.com';
function normalizePublicBaseUrl(raw: string | undefined): string {
const value = raw != null && raw.trim() !== '' ? raw.trim() : DEFAULT_CDN_BASE;
const trimmed = value.replace(/\/$/, '');
if (!trimmed.startsWith('https://')) {
throw new Error('EMBED_CDN_BASE_URL must be an https URL (no trailing slash).');
}
return trimmed;
}
/**
* Purge Cloudflare **edge cache** for public URLs that map to R2 objects.
* R2 itself has no separate HTTP cache; purging fixes stale responses when the hostname is proxied through Cloudflare.
*/
async function purgeCloudflareCdnForUrls(urls: string[]): Promise<void> {
if (process.env.EMBED_SKIP_CDN_PURGE === '1' || process.env.EMBED_SKIP_CDN_PURGE === 'true') {
console.log('Skipping Cloudflare CDN purge (EMBED_SKIP_CDN_PURGE is set).');
return;
}
const token = process.env.CLOUDFLARE_API_TOKEN;
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
if (!token || !zoneId) {
console.log(
'Skipping Cloudflare CDN cache purge. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID to purge after upload.'
);
return;
}
if (urls.length === 0) {
return;
}
const uniqueUrls = [...new Set(urls)];
for (let i = 0; i < uniqueUrls.length; i += PURGE_URL_BATCH) {
const batch = uniqueUrls.slice(i, i + PURGE_URL_BATCH);
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ files: batch })
});
const payload: unknown = await response.json().catch(() => ({}));
const success =
response.ok &&
typeof payload === 'object' &&
payload !== null &&
'success' in payload &&
(payload as { success?: boolean }).success === true;
if (!success) {
throw new Error(`Cloudflare CDN purge failed: ${response.status} ${JSON.stringify(payload)}`);
}
console.log(`Purged Cloudflare CDN cache for ${batch.length} URL(s).`);
}
}
class EmbedsUploader {
private s3Client: S3Client | null = null;
private readonly buildDir: string;
constructor() {
this.buildDir = join(process.cwd(), 'dist');
this.initializeS3Client();
}
private initializeS3Client(): void {
const accessKey = process.env.CLOUDFLARE_ACCESS_KEY;
const secretKey = process.env.CLOUDFLARE_SECRET_ACCESS_KEY;
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
if (accessKey && secretKey && accountId) {
this.s3Client = new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretKey
}
});
console.log('S3 client initialized for Cloudflare R2');
} else {
throw new Error(
'Missing Cloudflare R2 credentials (CLOUDFLARE_ACCESS_KEY, CLOUDFLARE_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID)'
);
}
}
private getContentType(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase();
const contentTypes: Record<string, string> = {
html: 'text/html',
js: 'application/javascript',
mjs: 'application/javascript',
json: 'application/json',
css: 'text/css',
map: 'application/json',
txt: 'text/plain'
};
return contentTypes[ext || ''] || 'application/octet-stream';
}
private getCacheControl(filePath: string): string {
if (filePath.endsWith('.html')) {
return 'public, max-age=300';
}
if (filePath.match(/\.(js|mjs)$/)) {
const baseName = filePath.split(/[/\\]/).pop() ?? filePath;
// Stable bootstrap URL must revalidate quickly so new chunk hashes roll out after deploy.
if (baseName === 'question-type-picker.js') {
return 'public, max-age=300, must-revalidate';
}
return 'public, max-age=31536000, immutable';
}
if (filePath.endsWith('.css')) {
return 'public, max-age=3600';
}
return 'public, max-age=3600';
}
private getAllFiles(dir: string, baseDir: string = dir): string[] {
const files: string[] = [];
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const relativePath = relative(baseDir, fullPath);
if (entry.isDirectory()) {
files.push(...this.getAllFiles(fullPath, baseDir));
} else {
files.push(relativePath);
}
}
return files;
}
async uploadFile(relativePath: string): Promise<void> {
if (!this.s3Client) {
throw new Error('S3 client not initialized');
}
const fullPath = join(this.buildDir, relativePath);
const content = readFileSync(fullPath);
const contentType = this.getContentType(relativePath);
const cacheControl = this.getCacheControl(relativePath);
const key = `${EMBED_WIDGET_PREFIX}/${relativePath.split('\\').join('/')}`;
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: content,
ContentType: contentType,
CacheControl: cacheControl
});
await this.s3Client.send(command);
console.log(`Uploaded: ${key}`);
}
async uploadAll(): Promise<void> {
try {
statSync(this.buildDir);
} catch {
throw new Error(`Build directory not found: ${this.buildDir}. Run pnpm build in apps/embeds first.`);
}
console.log('Uploading embeds to R2...');
console.log(`Build directory: ${this.buildDir}`);
console.log(`Key prefix: ${EMBED_WIDGET_PREFIX}/`);
const files = this.getAllFiles(this.buildDir);
if (files.length === 0) {
throw new Error('No files in dist/. Run pnpm build first.');
}
console.log(`Found ${files.length} files to upload`);
const batchSize = 10;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
await Promise.all(batch.map((file) => this.uploadFile(file)));
console.log(`Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(files.length / batchSize)}`);
}
console.log('Embeds upload completed.');
const publicBase = normalizePublicBaseUrl(process.env.EMBED_CDN_BASE_URL);
const purgeUrls = files.map(
(relativePath) => `${publicBase}/${EMBED_WIDGET_PREFIX}/${relativePath.split('\\').join('/')}`
);
await purgeCloudflareCdnForUrls(purgeUrls);
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: tsx scripts/upload-embeds.ts
Uploads apps/embeds/dist/* to R2 under ${EMBED_WIDGET_PREFIX}/
Environment (same as Storybook R2 upload):
CLOUDFLARE_ACCESS_KEY
CLOUDFLARE_SECRET_ACCESS_KEY
CLOUDFLARE_ACCOUNT_ID
Optional — purge Cloudflare CDN cache for the public asset host after upload (not R2; use when the domain is orange-clouded):
CLOUDFLARE_API_TOKEN (Zone → Cache Purge → Purge)
CLOUDFLARE_ZONE_ID
EMBED_CDN_BASE_URL (default ${DEFAULT_CDN_BASE})
EMBED_SKIP_CDN_PURGE=1 to skip purge
Bucket: ${BUCKET}
`);
return;
}
const uploader = new EmbedsUploader();
await uploader.uploadAll();
}
const isMain = process.argv[1]?.includes('upload-embeds');
if (isMain) {
main()
.then(() => process.exit(0))
.catch((error) => {
console.error('Script failed:', error);
process.exit(1);
});
}
export { EmbedsUploader, EMBED_WIDGET_PREFIX };
+6
View File
@@ -0,0 +1,6 @@
@import 'tailwindcss';
@import '@cio/ui/output.css';
@source '../node_modules/@cio/ui/src/**/*.svelte';
@source './**/*.svelte';
+20
View File
@@ -0,0 +1,20 @@
import { mount, unmount } from 'svelte';
import type { Component } from 'svelte';
export type MountResult = {
destroy: () => void;
};
export function mountSvelteComponent<Props extends Record<string, unknown>>(
target: HTMLElement,
Component: Component<Props>,
props?: Props
): MountResult {
const instance = mount(Component, { target, props: props ?? ({} as Props) });
return {
destroy: () => {
unmount(instance);
}
};
}
@@ -0,0 +1,118 @@
const WIDGET_ATTR = 'data-cio-widget';
const WIDGET_VALUE = 'question-type-picker';
const LOADING_CLASS = 'cio-embed-loading';
/** Must stay in sync with `QuestionTypePicker` outer `min-h-[320px]` so the embed does not jump after load. */
const EMBED_MIN_HEIGHT_PX = 320;
export type QuestionTypePickerEmbedApi = {
mount: (element: HTMLElement) => { destroy: () => void };
};
declare global {
interface Window {
CIO?: {
questionTypePicker?: QuestionTypePickerEmbedApi;
};
}
}
let stylesInjected = false;
function injectSpinnerStyles(): void {
if (stylesInjected) return;
stylesInjected = true;
const css = `
@keyframes cio-embed-spin { to { transform: rotate(360deg); } }
.${LOADING_CLASS} {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: ${EMBED_MIN_HEIGHT_PX}px;
padding: 1rem;
color: oklch(0.556 0 0);
}
.${LOADING_CLASS} svg {
animation: cio-embed-spin 0.8s linear infinite;
flex-shrink: 0;
}
`;
const styleEl = document.createElement('style');
styleEl.setAttribute('data-cio-embed', 'question-type-picker-loading');
styleEl.textContent = css;
document.head.appendChild(styleEl);
}
/** 16×16 — same multi-segment loader as `@cio/ui` Spinner (Lucide Loader, `size-4`). */
const LOADER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v4"/><path d="m16.24 7.76 2.83-2.83"/><path d="M22 12h-4"/><path d="m16.24 16.24 2.83 2.83"/><path d="M12 22v-4"/><path d="m7.76 16.24-2.83 2.83"/><path d="M2 12h4"/><path d="m7.76 7.76-2.83-2.83"/></svg>`;
function renderLoading(container: HTMLElement): void {
injectSpinnerStyles();
const root = document.createElement('div');
root.className = LOADING_CLASS;
root.setAttribute('role', 'status');
root.setAttribute('aria-live', 'polite');
root.setAttribute('aria-label', 'Loading');
root.innerHTML = LOADER_SVG;
container.replaceChildren(root);
}
function collectTargets(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>(`[${WIDGET_ATTR}="${WIDGET_VALUE}"]`)];
}
function scheduleMount(targets: HTMLElement[]): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void import('./embed-mount.ts').then((mod) => {
mod.mountAllAuto(targets);
});
});
});
}
function manualMount(element: HTMLElement): { destroy: () => void } {
renderLoading(element);
let cancelled = false;
let destroyWidget: (() => void) | null = null;
void import('./embed-mount.ts').then((mod) => {
if (cancelled) return;
destroyWidget = mod.mountWidget(element).destroy;
});
return {
destroy: () => {
cancelled = true;
destroyWidget?.();
element.replaceChildren();
}
};
}
function init(): void {
const targets = collectTargets();
for (const element of targets) {
renderLoading(element);
}
window.CIO = window.CIO ?? {};
window.CIO.questionTypePicker = {
mount: manualMount
};
scheduleMount(targets);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
@@ -0,0 +1,22 @@
import '../../embed.css';
import { mount, unmount } from 'svelte';
import Widget from './widget.svelte';
export function mountWidget(target: HTMLElement): { destroy: () => void } {
target.replaceChildren();
const instance = mount(Widget, { target });
return {
destroy: () => {
unmount(instance);
}
};
}
export function mountAllAuto(targets: readonly HTMLElement[]): void {
for (const element of targets) {
mountWidget(element);
}
}
@@ -0,0 +1,10 @@
<script lang="ts">
/** Subpath import avoids `@cio/ui` barrel `index.css` side effect (embed ships its own `embed.css`). */
import { QuestionTypePicker } from '@cio/ui/custom/question-type-picker';
</script>
<div
class="cio-embed-root cio-question-type-picker-embed ui:box-border ui:min-h-[320px] ui:min-w-0 ui:w-full ui:max-w-full"
>
<QuestionTypePicker />
</div>
+8
View File
@@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/vite-plugin-svelte').SvelteConfig} */
const config = {
preprocess: vitePreprocess()
};
export default config;
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@cio/tsconfig/base.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["svelte"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+50
View File
@@ -0,0 +1,50 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig(({ command }) => ({
root: dirname,
plugins: [tailwindcss(), svelte(), cssInjectedByJsPlugin()],
resolve: {
alias: {
'@cio/ui': path.resolve(dirname, 'node_modules/@cio/ui/src'),
'@cio/ui/*': path.resolve(dirname, 'node_modules/@cio/ui/src/*'),
$src: path.resolve(dirname, 'node_modules/@cio/ui/src')
},
conditions: ['svelte', 'browser', 'import']
},
optimizeDeps: {
include: ['svelte-tiptap'],
exclude: ['@tiptap/core', '@tiptap/pm']
},
build:
command === 'build'
? {
outDir: 'dist',
emptyOutDir: true,
cssCodeSplit: false,
lib: {
entry: path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts'),
name: 'CIOEmbeds',
formats: ['es'],
fileName: () => 'question-type-picker.js'
},
rollupOptions: {
output: {
inlineDynamicImports: false,
// Hashed names so CDN/browser can cache chunks immutably; bootstrap entry stays `question-type-picker.js`.
chunkFileNames: 'question-type-picker-[name]-[hash].js'
}
}
}
: {},
server: {
port: 5180
}
}));