feat: widgets and ai chat
This commit is contained in:
@@ -128,3 +128,61 @@ pnpm build && npx serve . -p 5190
|
||||
|
||||
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.
|
||||
|
||||
## Course widget host split
|
||||
|
||||
The course widget uses three different URL concerns:
|
||||
|
||||
1. **Embed script host**
|
||||
|
||||
- this is where the browser downloads `course-widget.js`
|
||||
- production example: `https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js`
|
||||
- configured on the API side with `PUBLIC_EMBED_BASE_URL`
|
||||
|
||||
2. **Payload API host**
|
||||
|
||||
- this is where the widget fetches `GET /widgets/:publicKey/payload`
|
||||
- it reuses the existing API public URL via `PUBLIC_SERVER_URL`
|
||||
- the generated embed snippet includes that host as `data-api-base-url`
|
||||
|
||||
3. **Course destination host**
|
||||
|
||||
- this is where end users are sent when they click a course
|
||||
- it comes from the organization public site or custom domain, not the API host
|
||||
|
||||
These are intentionally separate. The widget fetches data from the API host, but links users to the organization’s public course pages.
|
||||
|
||||
## Course widget local development
|
||||
|
||||
Local development should not depend on deployed CDN assets.
|
||||
|
||||
### Expected local hosts
|
||||
|
||||
- embed script host: `PUBLIC_EMBED_BASE_URL`
|
||||
- payload API host: existing `PUBLIC_SERVER_URL`
|
||||
|
||||
When `PUBLIC_EMBED_BASE_URL` is unset and the API runs in development, widget embed code falls back to:
|
||||
|
||||
```text
|
||||
http://localhost:5180
|
||||
```
|
||||
|
||||
That matches the `@cio/embeds` Vite dev server.
|
||||
|
||||
When `PUBLIC_SERVER_URL` is set to your local API URL, the generated snippet will look like:
|
||||
|
||||
```html
|
||||
<div data-cio-widget="course-widget" data-widget-key="wgt_xxx" data-api-base-url="http://localhost:3002"></div>
|
||||
<script async type="module" src="http://localhost:5180/embeds/course-widget/course-widget.js"></script>
|
||||
```
|
||||
|
||||
### Dev server path shape
|
||||
|
||||
The embeds Vite dev server rewrites these stable production-style paths:
|
||||
|
||||
- `/embeds/question-type-picker/question-type-picker.js`
|
||||
- `/embeds/course-widget/course-widget.js`
|
||||
|
||||
to the underlying source entry files during development.
|
||||
|
||||
That means dashboard-generated widget embed snippets can use the same path shape in local development and production without requiring the CDN to exist first.
|
||||
|
||||
@@ -13,12 +13,20 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<p style="max-width: 56rem; margin: 0 auto 1rem; color: #52525b; font-size: 0.875rem">
|
||||
<!-- <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>
|
||||
<script type="module" src="./dist/question-type-picker.js"></script> -->
|
||||
|
||||
<div
|
||||
data-cio-widget="course-widget"
|
||||
data-widget-key="wgt_UG2yRrPmQC8mbKLL"
|
||||
data-api-base-url="http://localhost:3002"
|
||||
></div>
|
||||
<script async type="module" src="http://localhost:5180/embeds/course-widget/course-widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5180",
|
||||
"prebuild": "pnpm --filter @cio/ui run build",
|
||||
"build": "rm -rf dist && vite build",
|
||||
"build:question-type-picker": "vite build --mode question-type-picker",
|
||||
"build:course-widget": "vite build --mode course-widget",
|
||||
"build": "rm -rf dist && pnpm build:question-type-picker && pnpm build:course-widget",
|
||||
"format": "prettier --write .",
|
||||
"preview": "vite preview --port 4180",
|
||||
"upload:embeds": "tsx scripts/upload-embeds.ts",
|
||||
@@ -26,6 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@cio/ui": "workspace:*",
|
||||
"@cio/utils": "workspace:*",
|
||||
"@cio/question-types": "workspace:*",
|
||||
"@lucide/svelte": "^0.562.0"
|
||||
}
|
||||
|
||||
@@ -16,8 +16,16 @@ 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';
|
||||
const EMBED_WIDGET_TARGETS = [
|
||||
{
|
||||
buildDirName: 'question-type-picker',
|
||||
prefix: 'embeds/question-type-picker'
|
||||
},
|
||||
{
|
||||
buildDirName: 'course-widget',
|
||||
prefix: 'embeds/course-widget'
|
||||
}
|
||||
] as const;
|
||||
|
||||
/** Cloudflare allows a limited number of URLs per purge request. */
|
||||
const PURGE_URL_BATCH = 30;
|
||||
@@ -88,10 +96,10 @@ async function purgeCloudflareCdnForUrls(urls: string[]): Promise<void> {
|
||||
|
||||
class EmbedsUploader {
|
||||
private s3Client: S3Client | null = null;
|
||||
private readonly buildDir: string;
|
||||
private readonly buildRootDir: string;
|
||||
|
||||
constructor() {
|
||||
this.buildDir = join(process.cwd(), 'dist');
|
||||
this.buildRootDir = join(process.cwd(), 'dist');
|
||||
this.initializeS3Client();
|
||||
}
|
||||
|
||||
@@ -138,7 +146,7 @@ class EmbedsUploader {
|
||||
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') {
|
||||
if (baseName === 'question-type-picker.js' || baseName === 'course-widget.js') {
|
||||
return 'public, max-age=300, must-revalidate';
|
||||
}
|
||||
return 'public, max-age=31536000, immutable';
|
||||
@@ -167,17 +175,17 @@ class EmbedsUploader {
|
||||
return files;
|
||||
}
|
||||
|
||||
async uploadFile(relativePath: string): Promise<void> {
|
||||
async uploadFile(targetPrefix: string, buildDir: string, relativePath: string): Promise<void> {
|
||||
if (!this.s3Client) {
|
||||
throw new Error('S3 client not initialized');
|
||||
}
|
||||
|
||||
const fullPath = join(this.buildDir, relativePath);
|
||||
const fullPath = join(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 normalizedRelativePath = relativePath.split('\\').join('/');
|
||||
const key = `${targetPrefix}/${normalizedRelativePath}`;
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
@@ -191,36 +199,45 @@ class EmbedsUploader {
|
||||
}
|
||||
|
||||
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`);
|
||||
console.log(`Build root directory: ${this.buildRootDir}`);
|
||||
|
||||
const purgeUrls: string[] = [];
|
||||
const publicBase = normalizePublicBaseUrl(process.env.EMBED_CDN_BASE_URL);
|
||||
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)}`);
|
||||
|
||||
for (const target of EMBED_WIDGET_TARGETS) {
|
||||
const buildDir = join(this.buildRootDir, target.buildDirName);
|
||||
|
||||
try {
|
||||
statSync(buildDir);
|
||||
} catch {
|
||||
throw new Error(`Build directory not found: ${buildDir}. Run pnpm build in apps/embeds first.`);
|
||||
}
|
||||
|
||||
console.log(`Target prefix: ${target.prefix}`);
|
||||
|
||||
const files = this.getAllFiles(buildDir);
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No files found in ${buildDir}. Run pnpm build first.`);
|
||||
}
|
||||
|
||||
console.log(`Found ${files.length} file(s) for ${target.buildDirName}`);
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const batch = files.slice(i, i + batchSize);
|
||||
await Promise.all(batch.map((file) => this.uploadFile(target.prefix, buildDir, file)));
|
||||
console.log(
|
||||
`Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(files.length / batchSize)} for ${target.buildDirName}`
|
||||
);
|
||||
}
|
||||
|
||||
purgeUrls.push(
|
||||
...files.map((relativePath) => `${publicBase}/${target.prefix}/${relativePath.split('\\').join('/')}`)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +249,8 @@ async function main(): Promise<void> {
|
||||
console.log(`
|
||||
Usage: tsx scripts/upload-embeds.ts
|
||||
|
||||
Uploads apps/embeds/dist/* to R2 under ${EMBED_WIDGET_PREFIX}/
|
||||
Uploads widget-specific embed builds under:
|
||||
${EMBED_WIDGET_TARGETS.map((target) => ` - dist/${target.buildDirName} -> ${target.prefix}`).join('\n')}
|
||||
|
||||
Environment (same as Storybook R2 upload):
|
||||
CLOUDFLARE_ACCESS_KEY
|
||||
@@ -264,4 +282,4 @@ if (isMain) {
|
||||
});
|
||||
}
|
||||
|
||||
export { EmbedsUploader, EMBED_WIDGET_PREFIX };
|
||||
export { EmbedsUploader, EMBED_WIDGET_TARGETS };
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
const WIDGET_ATTR = 'data-cio-widget';
|
||||
const WIDGET_VALUE = 'course-widget';
|
||||
const WIDGET_KEY_ATTR = 'data-widget-key';
|
||||
const API_BASE_ATTR = 'data-api-base-url';
|
||||
const LOADING_CLASS = 'cio-embed-loading';
|
||||
const EMBED_MIN_HEIGHT_PX = 360;
|
||||
|
||||
export type CourseWidgetEmbedApi = {
|
||||
mount: (element: HTMLElement, options: { publicKey: string; apiBaseUrl?: string }) => { destroy: () => void };
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CIO?: {
|
||||
apiBaseUrl?: string;
|
||||
courseWidget?: CourseWidgetEmbedApi;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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', 'course-widget-loading');
|
||||
styleEl.textContent = css;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
const LOADER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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(): Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }> {
|
||||
return [...document.querySelectorAll<HTMLElement>(`[${WIDGET_ATTR}="${WIDGET_VALUE}"]`)]
|
||||
.map((element) => ({
|
||||
element,
|
||||
publicKey: element.getAttribute(WIDGET_KEY_ATTR) ?? '',
|
||||
apiBaseUrl: element.getAttribute(API_BASE_ATTR) ?? window.CIO?.apiBaseUrl
|
||||
}))
|
||||
.filter((target) => Boolean(target.publicKey));
|
||||
}
|
||||
|
||||
function scheduleMount(targets: Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }>): void {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
void import('./embed-mount.ts').then((mod) => {
|
||||
mod.mountAllAuto(targets);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function manualMount(
|
||||
element: HTMLElement,
|
||||
options: { publicKey: string; apiBaseUrl?: string }
|
||||
): { 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, options).destroy;
|
||||
});
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
cancelled = true;
|
||||
destroyWidget?.();
|
||||
element.replaceChildren();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
const targets = collectTargets();
|
||||
|
||||
for (const target of targets) {
|
||||
renderLoading(target.element);
|
||||
}
|
||||
|
||||
window.CIO = window.CIO ?? {};
|
||||
window.CIO.courseWidget = {
|
||||
mount: manualMount
|
||||
};
|
||||
|
||||
scheduleMount(targets);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mount, unmount } from 'svelte';
|
||||
import { widgetStyles } from '@cio/ui/custom/widget-layouts';
|
||||
|
||||
import Widget from './widget.svelte';
|
||||
import shellCss from './widget.css?raw';
|
||||
|
||||
function createShadowHost(target: HTMLElement): { shadowRoot: ShadowRoot; mountPoint: HTMLElement } {
|
||||
target.replaceChildren();
|
||||
|
||||
const host = document.createElement('cio-course-widget');
|
||||
target.appendChild(host);
|
||||
|
||||
const shadowRoot = host.attachShadow({ mode: 'open' });
|
||||
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = `${shellCss}\n${widgetStyles}`;
|
||||
shadowRoot.appendChild(styleElement);
|
||||
|
||||
const mountPoint = document.createElement('div');
|
||||
shadowRoot.appendChild(mountPoint);
|
||||
|
||||
return { shadowRoot, mountPoint };
|
||||
}
|
||||
|
||||
export function mountWidget(
|
||||
target: HTMLElement,
|
||||
options: { publicKey: string; apiBaseUrl?: string }
|
||||
): { destroy: () => void } {
|
||||
const { mountPoint } = createShadowHost(target);
|
||||
|
||||
const instance = mount(Widget, {
|
||||
target: mountPoint,
|
||||
props: options
|
||||
});
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
unmount(instance);
|
||||
target.replaceChildren();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mountAllAuto(targets: Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }>): void {
|
||||
for (const target of targets) {
|
||||
mountWidget(target.element, {
|
||||
publicKey: target.publicKey,
|
||||
apiBaseUrl: target.apiBaseUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
:host {
|
||||
all: initial;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cio-widget-shell {
|
||||
font-family: 'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
color: rgba(15, 23, 42, 0.8);
|
||||
background: transparent;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cio-widget-loading,
|
||||
.cio-widget-error {
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cio-widget-error p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.cio-widget-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(15, 23, 42, 0.1);
|
||||
border-top-color: rgba(15, 23, 42, 0.6);
|
||||
border-radius: 999px;
|
||||
animation: cio-widget-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cio-widget-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import { CourseWidget } from '@cio/ui/custom/widget-layouts';
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://api.classroomio.com';
|
||||
|
||||
let { publicKey, apiBaseUrl = DEFAULT_API_BASE_URL }: { publicKey: string; apiBaseUrl?: string } = $props();
|
||||
|
||||
let payload = $state<TWidgetPayload | null>(null);
|
||||
let errorMessage = $state('');
|
||||
|
||||
async function loadPayload() {
|
||||
try {
|
||||
errorMessage = '';
|
||||
|
||||
const response = await fetch(
|
||||
`${(apiBaseUrl || DEFAULT_API_BASE_URL).replace(/\/$/, '')}/widgets/${publicKey}/payload`
|
||||
);
|
||||
const result = (await response.json()) as { success?: boolean; data?: TWidgetPayload; error?: string };
|
||||
|
||||
if (!response.ok || !result.success || !result.data) {
|
||||
throw new Error(result.error || 'Failed to load widget');
|
||||
}
|
||||
|
||||
payload = result.data;
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : 'Failed to load widget';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadPayload();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="cio-widget-shell cio-widget-error">
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
{:else if !payload}
|
||||
<div class="cio-widget-shell cio-widget-loading">
|
||||
<div class="cio-widget-spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<CourseWidget {payload} />
|
||||
{/if}
|
||||
+75
-37
@@ -7,44 +7,82 @@ import { defineConfig } from 'vite';
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
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 = {
|
||||
'/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'
|
||||
} as const;
|
||||
|
||||
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')
|
||||
function embedDevEntryPlugin() {
|
||||
return {
|
||||
name: 'embed-dev-entry-plugin',
|
||||
configureServer(server: {
|
||||
middlewares: { use: (handler: (req: { url?: string }, res: unknown, next: () => void) => void) => void };
|
||||
}) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
const requestUrl = req.url ?? '';
|
||||
const [pathname, search = ''] = requestUrl.split('?');
|
||||
const rewrittenPath = embedDevEntrypoints[pathname as keyof typeof embedDevEntrypoints];
|
||||
|
||||
if (rewrittenPath) {
|
||||
req.url = search ? `${rewrittenPath}?${search}` : rewrittenPath;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
const buildTarget = mode in embedEntries ? (mode as keyof typeof embedEntries) : null;
|
||||
const buildEntry = buildTarget ? { [buildTarget]: embedEntries[buildTarget] } : embedEntries;
|
||||
|
||||
return {
|
||||
root: dirname,
|
||||
plugins: [embedDevEntryPlugin(), 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']
|
||||
},
|
||||
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'
|
||||
optimizeDeps: {
|
||||
include: ['svelte-tiptap'],
|
||||
exclude: ['@tiptap/core', '@tiptap/pm']
|
||||
},
|
||||
build:
|
||||
command === 'build'
|
||||
? {
|
||||
outDir: buildTarget ? `dist/${buildTarget}` : 'dist',
|
||||
emptyOutDir: false,
|
||||
cssCodeSplit: false,
|
||||
lib: {
|
||||
entry: buildEntry,
|
||||
name: 'CIOEmbeds',
|
||||
formats: ['es'],
|
||||
fileName: (_format, entryName) => `${entryName}.js`
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: false,
|
||||
chunkFileNames: '[name]-[hash].js'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: {},
|
||||
server: {
|
||||
port: 5180
|
||||
}
|
||||
}));
|
||||
: {},
|
||||
server: {
|
||||
port: 5180,
|
||||
cors: true
|
||||
},
|
||||
preview: {
|
||||
port: 4180,
|
||||
cors: true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user