@keenmate/web-components-devkit 0.0.0-stage → 1.0.0-rc02
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/LICENSE +21 -0
- package/README.md +434 -2
- package/components.json +206 -0
- package/package.json +77 -4
- package/src/cli/args.js +16 -0
- package/src/cli/commands/check-release.js +66 -0
- package/src/cli/commands/check-vars.js +116 -0
- package/src/cli/commands/doctor.js +289 -0
- package/src/cli/commands/new.js +323 -0
- package/src/cli/commands/readme.js +111 -0
- package/src/cli/commands/sync.js +48 -0
- package/src/cli/kmwc.js +73 -0
- package/src/cli/sync-plan.js +163 -0
- package/src/config.d.ts +149 -0
- package/src/config.js +279 -0
- package/src/examples/auto.js +20 -0
- package/src/examples/chapter-nav.js +246 -0
- package/src/examples/constants.js +80 -0
- package/src/examples/controls-persistence.js +97 -0
- package/src/examples/copy-link.js +89 -0
- package/src/examples/css/base.css +215 -0
- package/src/examples/css/chapter-nav.css +179 -0
- package/src/examples/css/content.css +251 -0
- package/src/examples/css/controls.css +205 -0
- package/src/examples/css/copy-link.css +82 -0
- package/src/examples/css/demo.css +50 -0
- package/src/examples/css/index-page.css +118 -0
- package/src/examples/css/style.css +30 -0
- package/src/examples/css/theme-switch.css +38 -0
- package/src/examples/css/tokens.css +124 -0
- package/src/examples/css/widgets.css +133 -0
- package/src/examples/demo-code.js +30 -0
- package/src/examples/index-page.js +78 -0
- package/src/examples/index.d.ts +107 -0
- package/src/examples/index.js +18 -0
- package/src/examples/kit.js +22 -0
- package/src/examples/page-config.js +30 -0
- package/src/examples/sections.js +53 -0
- package/src/examples/theme-switch.js +72 -0
- package/src/examples/version-badge.js +23 -0
- package/src/playwright/index.d.ts +15 -0
- package/src/playwright/index.js +66 -0
- package/src/template.js +11 -0
- package/src/vite/index.d.ts +28 -0
- package/src/vite/index.js +243 -0
- package/src/vite/page-composer.js +216 -0
- package/templates/Dockerfile.tmpl +39 -0
- package/templates/Makefile.tmpl +136 -0
- package/templates/claude-block.md +40 -0
- package/templates/dockerignore.tmpl +10 -0
- package/templates/examples-layout.html +13 -0
- package/templates/new/.claude/commands/publish.md +226 -0
- package/templates/new/CHANGELOG.md +13 -0
- package/templates/new/LICENSE +21 -0
- package/templates/new/README.md +71 -0
- package/templates/new/_gitignore +7 -0
- package/templates/new/component-variables.manifest.json +32 -0
- package/templates/new/custom-elements-manifest.config.mjs +23 -0
- package/templates/new/docs/accessibility.md +24 -0
- package/templates/new/docs/examples.md +33 -0
- package/templates/new/docs/theming.md +54 -0
- package/templates/new/docs/usage.md +51 -0
- package/templates/new/e2e/dark-mode.spec.ts +30 -0
- package/templates/new/e2e/smoke.spec.ts +26 -0
- package/templates/new/examples-basic.html +57 -0
- package/templates/new/examples-theming.html +28 -0
- package/templates/new/index.html +16 -0
- package/templates/new/partials/note.html +5 -0
- package/templates/new/playwright.config.ts +4 -0
- package/templates/new/src/__FEATURE__.ts +55 -0
- package/templates/new/src/css/animations.css +1 -0
- package/templates/new/src/css/base.css +43 -0
- package/templates/new/src/css/controls.css +20 -0
- package/templates/new/src/css/dark-mode.css +36 -0
- package/templates/new/src/css/floating.css +1 -0
- package/templates/new/src/css/main.css +27 -0
- package/templates/new/src/css/states.css +22 -0
- package/templates/new/src/css/variables.css +42 -0
- package/templates/new/src/env.d.ts +7 -0
- package/templates/new/src/index.ts +17 -0
- package/templates/new/src/logger.ts +11 -0
- package/templates/new/src/types.ts +25 -0
- package/templates/new/src/web-component.ts +73 -0
- package/templates/new/test/dark-mode.html +18 -0
- package/templates/new/test/fixture.html +11 -0
- package/templates/new/tsconfig.json +15 -0
- package/templates/new/vite.config.ts +5 -0
- package/templates/new-minimal/CHANGELOG.md +13 -0
- package/templates/new-minimal/README.md +71 -0
- package/templates/new-minimal/component-variables.manifest.json +24 -0
- package/templates/new-minimal/docs/accessibility.md +20 -0
- package/templates/new-minimal/docs/examples.md +17 -0
- package/templates/new-minimal/docs/theming.md +53 -0
- package/templates/new-minimal/docs/usage.md +43 -0
- package/templates/new-minimal/e2e/smoke.spec.ts +10 -0
- package/templates/new-minimal/examples-basic.html +23 -0
- package/templates/new-minimal/examples-theming.html +28 -0
- package/templates/new-minimal/src/__FEATURE__.ts +37 -0
- package/templates/new-minimal/src/css/base.css +48 -0
- package/templates/new-minimal/src/css/controls.css +1 -0
- package/templates/new-minimal/src/css/states.css +1 -0
- package/templates/new-minimal/src/css/variables.css +28 -0
- package/templates/new-minimal/src/index.ts +17 -0
- package/templates/new-minimal/src/types.ts +10 -0
- package/templates/new-minimal/src/web-component.ts +48 -0
- package/templates/new-minimal/test/dark-mode.html +18 -0
- package/templates/new-minimal/test/fixture.html +11 -0
- package/templates/nginx.conf.tmpl +43 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section discovery shared by the chapter nav and the copy-link buttons — ONE
|
|
3
|
+
* slug algorithm, so the "on this page" jump list and the copied deep links
|
|
4
|
+
* always agree on every hash.
|
|
5
|
+
*
|
|
6
|
+
* Sections = every <h2> inside `.container` (falling back to <body>). The
|
|
7
|
+
* anchor id lives on the section wrapper (`.card` / `.example-section`),
|
|
8
|
+
* reusing an existing section/h2 id before slugging the heading text.
|
|
9
|
+
* Idempotent: a second call returns the same ids.
|
|
10
|
+
*/
|
|
11
|
+
import { HEADING_CHIP_SELECTOR, SECTION_CONTAINER_SELECTOR, SECTION_SELECTOR } from './constants.js';
|
|
12
|
+
|
|
13
|
+
export function generateSlug(text) {
|
|
14
|
+
return String(text)
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
17
|
+
.replace(/^-+|-+$/g, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getHeadingLabel(heading, fallbackLabel) {
|
|
21
|
+
const headingClone = heading.cloneNode(true);
|
|
22
|
+
headingClone.querySelectorAll(HEADING_CHIP_SELECTOR).forEach((chip) => chip.remove());
|
|
23
|
+
return (headingClone.textContent || '').replace(/\s+/g, ' ').trim() || fallbackLabel;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Make sure every section has an anchor id, and return the sections in order.
|
|
28
|
+
* @param {Document} [root]
|
|
29
|
+
* @returns {Array<{ heading: HTMLElement, section: HTMLElement, id: string, label: string }>}
|
|
30
|
+
*/
|
|
31
|
+
export function ensureSectionAnchors(root = document) {
|
|
32
|
+
const container = root.querySelector(SECTION_CONTAINER_SELECTOR) || root.body || root;
|
|
33
|
+
const headings = [...container.querySelectorAll('h2')];
|
|
34
|
+
const usedIds = new Set();
|
|
35
|
+
|
|
36
|
+
return headings.map((heading, headingIndex) => {
|
|
37
|
+
const section = heading.closest(SECTION_SELECTOR) || heading.parentElement || heading;
|
|
38
|
+
const label = getHeadingLabel(heading, `Section ${headingIndex + 1}`);
|
|
39
|
+
|
|
40
|
+
let id = section.id || heading.id;
|
|
41
|
+
if (!id) {
|
|
42
|
+
id = generateSlug(label) || `section-${headingIndex + 1}`;
|
|
43
|
+
if (usedIds.has(id)) {
|
|
44
|
+
let suffix = 2;
|
|
45
|
+
while (usedIds.has(`${id}-${suffix}`)) suffix++;
|
|
46
|
+
id = `${id}-${suffix}`;
|
|
47
|
+
}
|
|
48
|
+
section.id = id;
|
|
49
|
+
}
|
|
50
|
+
usedIds.add(id);
|
|
51
|
+
return { heading, section, id, label };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THEME SWITCH — a small fixed button cycling the page theme light → dark →
|
|
3
|
+
* auto (follow the OS). It sets `data-theme` on <html>: the kit's tokens flip
|
|
4
|
+
* via `color-scheme`, and components react through their own
|
|
5
|
+
* `:host-context([data-theme="dark"])` selectors and inherited `color-scheme`
|
|
6
|
+
* (rulebook color-scheme.md signals #3/#4) — so every example page doubles as
|
|
7
|
+
* a dark-mode smoke test of the component.
|
|
8
|
+
*
|
|
9
|
+
* The plugin also injects a tiny inline script into <head> that applies the
|
|
10
|
+
* saved theme before first paint (see `renderThemeBootstrapScript`), so a
|
|
11
|
+
* reload doesn't flash light. Styles: css/theme-switch.css.
|
|
12
|
+
*/
|
|
13
|
+
import { DEFAULT_THEME, THEME_ATTRIBUTE, THEME_STORAGE_KEY, THEMES, THEME_SWITCH_CLASSES } from './constants.js';
|
|
14
|
+
|
|
15
|
+
const THEME_ICONS = { light: '☀', dark: '☾', auto: '◐' };
|
|
16
|
+
const THEME_LABELS = { light: 'Light theme', dark: 'Dark theme', auto: 'Follow system theme' };
|
|
17
|
+
|
|
18
|
+
export function getSavedTheme() {
|
|
19
|
+
try {
|
|
20
|
+
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
|
21
|
+
return THEMES.includes(savedTheme) ? savedTheme : DEFAULT_THEME;
|
|
22
|
+
} catch {
|
|
23
|
+
return DEFAULT_THEME;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyTheme(theme) {
|
|
28
|
+
document.documentElement.dataset[THEME_ATTRIBUTE] = theme;
|
|
29
|
+
try {
|
|
30
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
31
|
+
} catch {
|
|
32
|
+
/* storage unavailable — theme still applies for this page view */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Inline <head> script source that applies the saved theme before first paint. */
|
|
37
|
+
export function renderThemeBootstrapScript() {
|
|
38
|
+
return `try{var t=localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});document.documentElement.dataset.${THEME_ATTRIBUTE}=${JSON.stringify(THEMES)}.indexOf(t)>-1?t:${JSON.stringify(DEFAULT_THEME)}}catch(e){document.documentElement.dataset.${THEME_ATTRIBUTE}=${JSON.stringify(DEFAULT_THEME)}}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let mountedThemeSwitch = null;
|
|
42
|
+
|
|
43
|
+
/** @returns {{ unmount(): void }} */
|
|
44
|
+
export function mountThemeSwitch() {
|
|
45
|
+
mountedThemeSwitch?.unmount();
|
|
46
|
+
if (!document.documentElement.dataset[THEME_ATTRIBUTE]) applyTheme(getSavedTheme());
|
|
47
|
+
|
|
48
|
+
const button = document.createElement('button');
|
|
49
|
+
button.type = 'button';
|
|
50
|
+
button.className = THEME_SWITCH_CLASSES.root;
|
|
51
|
+
const iconElement = document.createElement('span');
|
|
52
|
+
iconElement.className = THEME_SWITCH_CLASSES.icon;
|
|
53
|
+
iconElement.setAttribute('aria-hidden', 'true');
|
|
54
|
+
button.appendChild(iconElement);
|
|
55
|
+
|
|
56
|
+
const renderButton = () => {
|
|
57
|
+
const theme = document.documentElement.dataset[THEME_ATTRIBUTE];
|
|
58
|
+
iconElement.textContent = THEME_ICONS[theme] || THEME_ICONS[DEFAULT_THEME];
|
|
59
|
+
button.title = `${THEME_LABELS[theme]} (click to switch)`;
|
|
60
|
+
button.setAttribute('aria-label', button.title);
|
|
61
|
+
};
|
|
62
|
+
button.addEventListener('click', () => {
|
|
63
|
+
const currentTheme = document.documentElement.dataset[THEME_ATTRIBUTE];
|
|
64
|
+
applyTheme(THEMES[(THEMES.indexOf(currentTheme) + 1) % THEMES.length]);
|
|
65
|
+
renderButton();
|
|
66
|
+
});
|
|
67
|
+
renderButton();
|
|
68
|
+
document.body.appendChild(button);
|
|
69
|
+
|
|
70
|
+
mountedThemeSwitch = { unmount: () => button.remove() };
|
|
71
|
+
return mountedThemeSwitch;
|
|
72
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fill version badges from the injected page config. Any `[data-kmwc-version]`
|
|
3
|
+
* element (and the legacy `#version-badge`) gets `v1.2.3` in dev and
|
|
4
|
+
* `v1.2.3 (built 2026-10-12T19:49Z)` in a built examples site.
|
|
5
|
+
*/
|
|
6
|
+
import { getPageConfig } from './page-config.js';
|
|
7
|
+
import { VERSION_BADGE_SELECTOR } from './constants.js';
|
|
8
|
+
|
|
9
|
+
export function getVersionLabel(pageConfig = getPageConfig()) {
|
|
10
|
+
if (!pageConfig.version) return '';
|
|
11
|
+
return pageConfig.buildTimestamp ? `v${pageConfig.version} (built ${pageConfig.buildTimestamp})` : `v${pageConfig.version}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderVersionBadges(root = document) {
|
|
15
|
+
const pageConfig = getPageConfig();
|
|
16
|
+
const versionLabel = getVersionLabel(pageConfig);
|
|
17
|
+
if (!versionLabel) return;
|
|
18
|
+
for (const badge of root.querySelectorAll(VERSION_BADGE_SELECTOR)) {
|
|
19
|
+
if (badge.textContent.trim()) continue; // page put its own text — leave it
|
|
20
|
+
badge.textContent = versionLabel;
|
|
21
|
+
if (pageConfig.buildTimestamp) badge.title = `Built ${pageConfig.buildTimestamp}`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PlaywrightTestConfig, Project } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
export interface PlaywrightPresetConfig extends Omit<PlaywrightTestConfig, 'projects' | 'use' | 'webServer'> {
|
|
4
|
+
/** Component root; defaults to `process.cwd()`. */
|
|
5
|
+
root?: string;
|
|
6
|
+
/** Replaces the default single desktop-chromium project. */
|
|
7
|
+
projects?: Project[];
|
|
8
|
+
/** Merged over the default `use` (baseURL is derived from kmwc.ports.dev). */
|
|
9
|
+
use?: PlaywrightTestConfig['use'];
|
|
10
|
+
/** Merged over the default webServer (`npm run dev` on kmwc.ports.dev); an array replaces it. */
|
|
11
|
+
webServer?: Partial<NonNullable<Exclude<PlaywrightTestConfig['webServer'], unknown[]>>> | PlaywrightTestConfig['webServer'];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function definePlaywrightConfig(presetConfig?: PlaywrightPresetConfig): PlaywrightTestConfig;
|
|
15
|
+
export const devices: typeof import('@playwright/test').devices;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@keenmate/web-components-devkit/playwright` — the shared Playwright preset.
|
|
3
|
+
*
|
|
4
|
+
* import { definePlaywrightConfig } from '@keenmate/web-components-devkit/playwright';
|
|
5
|
+
* export default definePlaywrightConfig();
|
|
6
|
+
*
|
|
7
|
+
* Specs live in e2e/ and target fixture pages under test/*.html, served by the
|
|
8
|
+
* component's own dev server on kmwc.ports.dev (read from package.json, so the
|
|
9
|
+
* port can't drift from vite.config / the Makefile). Pass `projects` to add
|
|
10
|
+
* e.g. a touch-only mobile project; anything else is shallow-merged on top.
|
|
11
|
+
*/
|
|
12
|
+
import { getKmwcConfig, getPeerModule } from '../config.js';
|
|
13
|
+
|
|
14
|
+
/** The component's own @playwright/test (see getPeerModule). */
|
|
15
|
+
function getPlaywright(root = process.cwd()) {
|
|
16
|
+
return getPeerModule(root, '@playwright/test');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Playwright's device descriptors, from the component's install. */
|
|
20
|
+
export const devices = new Proxy({}, { get: (_, deviceName) => getPlaywright().devices[deviceName] });
|
|
21
|
+
|
|
22
|
+
/** @param {import('./index.js').PlaywrightPresetConfig} [presetConfig] */
|
|
23
|
+
export function definePlaywrightConfig(presetConfig = {}) {
|
|
24
|
+
const { root, projects, use, webServer, ...restConfig } = presetConfig;
|
|
25
|
+
const config = getKmwcConfig(root);
|
|
26
|
+
const { defineConfig, devices: playwrightDevices } = getPlaywright(config.root);
|
|
27
|
+
const baseUrl = `http://localhost:${config.ports.dev}`;
|
|
28
|
+
const isCi = !!process.env.CI;
|
|
29
|
+
|
|
30
|
+
return defineConfig({
|
|
31
|
+
testDir: './e2e',
|
|
32
|
+
timeout: 30_000,
|
|
33
|
+
expect: { timeout: 5_000 },
|
|
34
|
+
fullyParallel: true,
|
|
35
|
+
forbidOnly: isCi,
|
|
36
|
+
retries: isCi ? 2 : 0,
|
|
37
|
+
workers: isCi ? 1 : undefined,
|
|
38
|
+
reporter: isCi ? 'github' : 'list',
|
|
39
|
+
use: {
|
|
40
|
+
baseURL: baseUrl,
|
|
41
|
+
trace: 'on-first-retry',
|
|
42
|
+
screenshot: 'only-on-failure',
|
|
43
|
+
video: 'retain-on-failure',
|
|
44
|
+
...use,
|
|
45
|
+
},
|
|
46
|
+
projects: projects || [
|
|
47
|
+
{
|
|
48
|
+
name: 'chromium',
|
|
49
|
+
use: { ...playwrightDevices['Desktop Chrome'], viewport: { width: 1440, height: 1024 } },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
// An array (e.g. dev server + examples preview) is taken as-is.
|
|
53
|
+
webServer: Array.isArray(webServer)
|
|
54
|
+
? webServer
|
|
55
|
+
: {
|
|
56
|
+
command: 'npm run dev',
|
|
57
|
+
url: baseUrl,
|
|
58
|
+
reuseExistingServer: !isCi,
|
|
59
|
+
timeout: 60_000,
|
|
60
|
+
stdout: 'ignore',
|
|
61
|
+
stderr: 'pipe',
|
|
62
|
+
...webServer,
|
|
63
|
+
},
|
|
64
|
+
...restConfig,
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/template.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side layer: the one placeholder syntax the devkit uses for its own templates
|
|
3
|
+
* (generated files, the scaffold, the examples layout) — `{{UPPER_CASE}}`.
|
|
4
|
+
* An unknown placeholder is an error, never silently empty.
|
|
5
|
+
*/
|
|
6
|
+
export function renderTemplate(templateText, variables) {
|
|
7
|
+
return templateText.replace(/\{\{([A-Z_]+)\}\}/g, (placeholder, variableName) => {
|
|
8
|
+
if (!(variableName in variables)) throw new Error(`kmwc: template variable ${variableName} is not defined`);
|
|
9
|
+
return String(variables[variableName]);
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ConfigEnv, Plugin, PluginOption, UserConfig, UserConfigFnPromise } from 'vite';
|
|
2
|
+
import type { KmwcConfig, KmwcPackageConfig } from '../config.js';
|
|
3
|
+
|
|
4
|
+
export interface VitePresetConfig {
|
|
5
|
+
/** Component root; defaults to `process.cwd()`. */
|
|
6
|
+
root?: string;
|
|
7
|
+
/** Extra plugins, appended after the kmwc examples plugin. */
|
|
8
|
+
plugins?: PluginOption[];
|
|
9
|
+
/** Per-config override of `package.json#kmwc.library`. */
|
|
10
|
+
library?: KmwcPackageConfig['library'];
|
|
11
|
+
/** Deep-merged last via Vite's `mergeConfig`. */
|
|
12
|
+
overrides?: UserConfig;
|
|
13
|
+
/** Env-dependent overrides; its return is deep-merged after `overrides`. */
|
|
14
|
+
getOverridesCallback?: (env: ConfigEnv, config: KmwcConfig) => UserConfig | Promise<UserConfig>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ExamplesPluginConfig {
|
|
18
|
+
/** Component root; defaults to `process.cwd()`. */
|
|
19
|
+
root?: string;
|
|
20
|
+
/** Force the analytics snippet on/off (default: on for `vite build` only). */
|
|
21
|
+
shouldInjectAnalytics?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function defineComponentConfig(presetConfig?: VitePresetConfig): UserConfigFnPromise;
|
|
25
|
+
|
|
26
|
+
export function createExamplesPlugin(pluginConfig?: ExamplesPluginConfig): Plugin;
|
|
27
|
+
|
|
28
|
+
export function getExamplePages(config: KmwcConfig): Record<string, string>;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@keenmate/web-components-devkit/vite` — the shared Vite preset.
|
|
3
|
+
*
|
|
4
|
+
* A component's whole vite.config.ts becomes:
|
|
5
|
+
*
|
|
6
|
+
* import { defineComponentConfig } from '@keenmate/web-components-devkit/vite';
|
|
7
|
+
* export default defineComponentConfig();
|
|
8
|
+
*
|
|
9
|
+
* Everything else comes from package.json#kmwc. Three modes:
|
|
10
|
+
*
|
|
11
|
+
* vite dev server on kmwc.ports.dev (strict — no port hopping)
|
|
12
|
+
* vite build library build → dist/<fileName>.js + .umd.js + style.css (+ .d.ts)
|
|
13
|
+
* vite build --mode examples static example site → dist-examples/ (every root *.html is a page)
|
|
14
|
+
*
|
|
15
|
+
* The examples plugin injects the shared kit (styles, chapter nav, copy-link,
|
|
16
|
+
* version badge) into every root-level page, so pages carry only their own
|
|
17
|
+
* content — no per-repo copies of the helper scripts or the shared stylesheet.
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync, existsSync, cpSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { getKmwcConfig, getDevkitPackageJson, generateBuildDefines, loadPeerModule, DEVKIT_ROOT, NON_PAGE_HTML_FILES } from '../config.js';
|
|
22
|
+
import { renderThemeBootstrapScript } from '../examples/theme-switch.js';
|
|
23
|
+
import { composePage } from './page-composer.js';
|
|
24
|
+
|
|
25
|
+
const AUTO_ENTRY_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'auto.js');
|
|
26
|
+
const VIRTUAL_AUTO_ID = 'virtual:kmwc-examples';
|
|
27
|
+
const KIT_STYLESHEET_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'css', 'style.css').replace(/\\/g, '/');
|
|
28
|
+
// Vite's dev-server URL for a file outside the project root.
|
|
29
|
+
const KIT_STYLESHEET_DEV_URL = `/@fs/${KIT_STYLESHEET_PATH.replace(/^\//, '')}`;
|
|
30
|
+
const EXAMPLES_MODE = 'examples';
|
|
31
|
+
|
|
32
|
+
/** Root-level HTML pages to build as the examples site. */
|
|
33
|
+
export function getExamplePages(config) {
|
|
34
|
+
const skippedPages = new Set([...NON_PAGE_HTML_FILES, ...config.examples.excludedPages]);
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
readdirSync(config.root)
|
|
37
|
+
.filter((fileName) => fileName.endsWith('.html') && !skippedPages.has(fileName))
|
|
38
|
+
.map((fileName) => [fileName.replace(/\.html$/, ''), resolve(config.root, fileName)])
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getBuildTimestamp() {
|
|
43
|
+
return new Date().toISOString().slice(0, 16) + 'Z';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const mapToScriptSafeJson = (json) => json.replace(/</g, '\\u003c');
|
|
47
|
+
|
|
48
|
+
/** Plausible snippet for the given site id (build only). */
|
|
49
|
+
function renderPlausibleSnippet(config) {
|
|
50
|
+
const scriptUrl = `${config.analytics.plausibleHostUrl.replace(/\/$/, '')}/js/${config.analytics.plausibleSiteId}.js`;
|
|
51
|
+
return (
|
|
52
|
+
`<!-- Privacy-friendly analytics by Plausible (injected by kmwc at build time) -->\n` +
|
|
53
|
+
`<script async src="${scriptUrl}"></script>\n` +
|
|
54
|
+
`<script>window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()</script>\n`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The examples plugin. Usually wired by `defineComponentConfig()`; exported for
|
|
60
|
+
* repos (monorepos, showcases) that assemble their own Vite config.
|
|
61
|
+
*
|
|
62
|
+
* @param {import('./index.js').ExamplesPluginConfig} [pluginConfig]
|
|
63
|
+
*/
|
|
64
|
+
export function createExamplesPlugin(pluginConfig = {}) {
|
|
65
|
+
const config = getKmwcConfig(pluginConfig.root);
|
|
66
|
+
let command = 'serve';
|
|
67
|
+
let mode = 'development';
|
|
68
|
+
let outDir = config.examples.outDir;
|
|
69
|
+
const buildTimestamp = process.env.BUILD_TIME || getBuildTimestamp();
|
|
70
|
+
const devkitPackageJson = getDevkitPackageJson();
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
name: 'kmwc-examples',
|
|
74
|
+
|
|
75
|
+
configResolved(resolvedConfig) {
|
|
76
|
+
command = resolvedConfig.command;
|
|
77
|
+
mode = resolvedConfig.mode;
|
|
78
|
+
outDir = resolvedConfig.build.outDir;
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
resolveId(moduleId) {
|
|
82
|
+
return moduleId === VIRTUAL_AUTO_ID ? AUTO_ENTRY_PATH : null;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// A partial isn't a module, so Vite doesn't know which pages use it —
|
|
86
|
+
// reload the page on any non-root HTML change (partials/…).
|
|
87
|
+
handleHotUpdate({ file, server }) {
|
|
88
|
+
const relativePath = file.replace(/\\/g, '/').replace(config.root.replace(/\\/g, '/') + '/', '');
|
|
89
|
+
if (relativePath.endsWith('.html') && relativePath.includes('/') && !relativePath.startsWith('test/')) {
|
|
90
|
+
server.ws.send({ type: 'full-reload' });
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
transformIndexHtml: {
|
|
97
|
+
// `pre`: run before Vite processes the HTML, so the injected module script
|
|
98
|
+
// is bundled exactly like an authored one (dev and build alike).
|
|
99
|
+
order: 'pre',
|
|
100
|
+
handler(html, context) {
|
|
101
|
+
// Only root-level pages are example pages — test fixtures under test/
|
|
102
|
+
// (Playwright) stay bare. A page can opt out with <html data-kmwc="off">.
|
|
103
|
+
if (context.path.split('/').filter(Boolean).length > 1) return html;
|
|
104
|
+
if (/<html[^>]*\sdata-kmwc=["']?off/i.test(html)) return html;
|
|
105
|
+
|
|
106
|
+
// Includes, demos and — for fragment pages — the shared layout
|
|
107
|
+
// (page-composer.js). Full documents pass through untouched.
|
|
108
|
+
html = composePage(html, { config, pagePath: context.path });
|
|
109
|
+
|
|
110
|
+
const isBuild = command === 'build';
|
|
111
|
+
const pageConfig = {
|
|
112
|
+
id: config.id,
|
|
113
|
+
title: config.title,
|
|
114
|
+
tagline: config.tagline,
|
|
115
|
+
version: config.version,
|
|
116
|
+
packageName: config.packageName,
|
|
117
|
+
buildTimestamp: isBuild ? buildTimestamp : null,
|
|
118
|
+
mode: isBuild ? 'build' : 'dev',
|
|
119
|
+
canonicalOrigin: config.examples.canonicalOrigin,
|
|
120
|
+
storagePrefix: config.examples.storagePrefix,
|
|
121
|
+
repository: config.repository,
|
|
122
|
+
homepage: config.homepage,
|
|
123
|
+
devkitVersion: devkitPackageJson.version,
|
|
124
|
+
devkitHomepage: devkitPackageJson.homepage,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
let headHtml =
|
|
128
|
+
`<script>${renderThemeBootstrapScript()}</script>\n` +
|
|
129
|
+
`<script>window.__KMWC__=${mapToScriptSafeJson(JSON.stringify(pageConfig))};</script>\n`;
|
|
130
|
+
const accentDeclarations = [
|
|
131
|
+
config.theme.accentColor && `--kmwc-accent-color:${config.theme.accentColor}`,
|
|
132
|
+
config.theme.accentEndColor && `--kmwc-accent-end-color:${config.theme.accentEndColor}`,
|
|
133
|
+
].filter(Boolean);
|
|
134
|
+
if (accentDeclarations.length) headHtml += `<style>:root{${accentDeclarations.join(';')}}</style>\n`;
|
|
135
|
+
|
|
136
|
+
const shouldInjectAnalytics = pluginConfig.shouldInjectAnalytics ?? isBuild;
|
|
137
|
+
if (shouldInjectAnalytics && config.analytics.plausibleSiteId) headHtml = renderPlausibleSnippet(config) + headHtml;
|
|
138
|
+
|
|
139
|
+
// The kit stylesheet must block first paint, or every reload flashes an
|
|
140
|
+
// unstyled page (FOUC). Dev: a real <link> in <head> — a CSS import in a
|
|
141
|
+
// module script only applies once the script has run. Build: a CSS
|
|
142
|
+
// import, which Vite extracts into a hashed <link> in <head>.
|
|
143
|
+
if (!isBuild) headHtml += `<link rel="stylesheet" href="${KIT_STYLESHEET_DEV_URL}">\n`;
|
|
144
|
+
const kitImports = isBuild ? `import '${KIT_STYLESHEET_PATH}'; import '${VIRTUAL_AUTO_ID}';` : `import '${VIRTUAL_AUTO_ID}';`;
|
|
145
|
+
const bodyHtml = `<script type="module">${kitImports}</script>\n`;
|
|
146
|
+
|
|
147
|
+
html = /<head[^>]*>/i.test(html)
|
|
148
|
+
? html.replace(/<head[^>]*>/i, (headTag) => `${headTag}\n${headHtml}`)
|
|
149
|
+
: headHtml + html;
|
|
150
|
+
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${bodyHtml}</body>`) : html + bodyHtml;
|
|
151
|
+
return html;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// Runtime-fetched assets (demo JSON, docs) aren't in the module graph —
|
|
156
|
+
// copy them verbatim next to the built pages.
|
|
157
|
+
closeBundle() {
|
|
158
|
+
if (command !== 'build' || mode !== EXAMPLES_MODE) return;
|
|
159
|
+
for (const staticPath of config.examples.staticPaths) {
|
|
160
|
+
const sourcePath = resolve(config.root, staticPath);
|
|
161
|
+
if (!existsSync(sourcePath)) {
|
|
162
|
+
this.warn?.(`kmwc: examples.staticPaths entry "${staticPath}" does not exist — skipped`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
cpSync(sourcePath, resolve(config.root, outDir, staticPath), { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The full preset. `presetConfig.overrides` is deep-merged last; for values
|
|
173
|
+
* that depend on the Vite env, use `getOverridesCallback(env, config)`. A repo
|
|
174
|
+
* keeps its genuinely-specific bits — extra plugins, aliases, optimizeDeps —
|
|
175
|
+
* without forking the base.
|
|
176
|
+
*
|
|
177
|
+
* @param {import('./index.js').VitePresetConfig} [presetConfig]
|
|
178
|
+
*/
|
|
179
|
+
export function defineComponentConfig(presetConfig = {}) {
|
|
180
|
+
return async (env) => {
|
|
181
|
+
const config = getKmwcConfig(presetConfig.root);
|
|
182
|
+
// The component's own vite — the one running this config (see loadPeerModule).
|
|
183
|
+
const { mergeConfig, searchForWorkspaceRoot } = await loadPeerModule(config.root, 'vite');
|
|
184
|
+
const library = { ...config.library, ...(presetConfig.library || {}) };
|
|
185
|
+
const isExamplesMode = env.mode === EXAMPLES_MODE;
|
|
186
|
+
const isLibraryBuild = env.command === 'build' && !isExamplesMode;
|
|
187
|
+
|
|
188
|
+
const plugins = [createExamplesPlugin({ root: config.root }), ...(presetConfig.plugins || [])];
|
|
189
|
+
|
|
190
|
+
if (isLibraryBuild && library.shouldEmitTypes) {
|
|
191
|
+
const { default: createTypesPlugin } = await import('vite-plugin-dts');
|
|
192
|
+
plugins.push(createTypesPlugin({ rollupTypes: true, ...library.typesPluginConfig }));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const baseConfig = {
|
|
196
|
+
root: config.root,
|
|
197
|
+
appType: 'mpa',
|
|
198
|
+
// Relative asset URLs in the examples build, so the site works from any
|
|
199
|
+
// sub-path (or a plain file preview), not just a domain root.
|
|
200
|
+
...(isExamplesMode && env.command === 'build' ? { base: './' } : {}),
|
|
201
|
+
define: generateBuildDefines(config),
|
|
202
|
+
plugins,
|
|
203
|
+
server: {
|
|
204
|
+
port: config.ports.dev,
|
|
205
|
+
strictPort: true,
|
|
206
|
+
fs: {
|
|
207
|
+
// The devkit may be a sibling checkout (file: link) — let the dev
|
|
208
|
+
// server read its example-kit files.
|
|
209
|
+
allow: [searchForWorkspaceRoot(config.root), DEVKIT_ROOT],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
preview: {
|
|
213
|
+
port: config.ports.preview,
|
|
214
|
+
strictPort: true,
|
|
215
|
+
},
|
|
216
|
+
build: isExamplesMode
|
|
217
|
+
? {
|
|
218
|
+
outDir: config.examples.outDir,
|
|
219
|
+
emptyOutDir: true,
|
|
220
|
+
rollupOptions: { input: getExamplePages(config) },
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
sourcemap: library.shouldEmitSourcemap,
|
|
224
|
+
lib: {
|
|
225
|
+
entry: resolve(config.root, library.entry),
|
|
226
|
+
name: library.globalName,
|
|
227
|
+
formats: library.formats,
|
|
228
|
+
cssFileName: library.cssFileName,
|
|
229
|
+
fileName: (format) => `${library.fileName}.${format === 'es' ? 'js' : `${format}.js`}`,
|
|
230
|
+
},
|
|
231
|
+
rollupOptions: {
|
|
232
|
+
external: library.external,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
let mergedConfig = presetConfig.overrides ? mergeConfig(baseConfig, presetConfig.overrides) : baseConfig;
|
|
238
|
+
if (presetConfig.getOverridesCallback) {
|
|
239
|
+
mergedConfig = mergeConfig(mergedConfig, await presetConfig.getOverridesCallback(env, config));
|
|
240
|
+
}
|
|
241
|
+
return mergedConfig;
|
|
242
|
+
};
|
|
243
|
+
}
|