@keenmate/web-components-devkit 0.0.0-stage → 1.0.0-rc01

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/LICENSE +21 -0
  3. package/README.md +417 -2
  4. package/components.json +189 -0
  5. package/package.json +75 -4
  6. package/src/cli/args.js +16 -0
  7. package/src/cli/commands/check-release.js +66 -0
  8. package/src/cli/commands/check-vars.js +116 -0
  9. package/src/cli/commands/doctor.js +289 -0
  10. package/src/cli/commands/new.js +302 -0
  11. package/src/cli/commands/readme.js +111 -0
  12. package/src/cli/commands/sync.js +48 -0
  13. package/src/cli/kmwc.js +72 -0
  14. package/src/cli/sync-plan.js +163 -0
  15. package/src/config.d.ts +147 -0
  16. package/src/config.js +257 -0
  17. package/src/examples/auto.js +20 -0
  18. package/src/examples/chapter-nav.js +246 -0
  19. package/src/examples/constants.js +80 -0
  20. package/src/examples/controls-persistence.js +97 -0
  21. package/src/examples/copy-link.js +89 -0
  22. package/src/examples/css/base.css +215 -0
  23. package/src/examples/css/chapter-nav.css +179 -0
  24. package/src/examples/css/content.css +251 -0
  25. package/src/examples/css/controls.css +205 -0
  26. package/src/examples/css/copy-link.css +82 -0
  27. package/src/examples/css/demo.css +50 -0
  28. package/src/examples/css/index-page.css +118 -0
  29. package/src/examples/css/style.css +30 -0
  30. package/src/examples/css/theme-switch.css +38 -0
  31. package/src/examples/css/tokens.css +110 -0
  32. package/src/examples/css/widgets.css +133 -0
  33. package/src/examples/demo-code.js +30 -0
  34. package/src/examples/index-page.js +78 -0
  35. package/src/examples/index.d.ts +107 -0
  36. package/src/examples/index.js +18 -0
  37. package/src/examples/kit.js +22 -0
  38. package/src/examples/page-config.js +30 -0
  39. package/src/examples/sections.js +53 -0
  40. package/src/examples/theme-switch.js +72 -0
  41. package/src/examples/version-badge.js +23 -0
  42. package/src/playwright/index.d.ts +15 -0
  43. package/src/playwright/index.js +66 -0
  44. package/src/template.js +11 -0
  45. package/src/vite/index.d.ts +28 -0
  46. package/src/vite/index.js +243 -0
  47. package/src/vite/page-composer.js +209 -0
  48. package/templates/Dockerfile.tmpl +39 -0
  49. package/templates/Makefile.tmpl +136 -0
  50. package/templates/claude-block.md +40 -0
  51. package/templates/dockerignore.tmpl +10 -0
  52. package/templates/examples-layout.html +13 -0
  53. package/templates/new/.claude/commands/publish.md +226 -0
  54. package/templates/new/CHANGELOG.md +13 -0
  55. package/templates/new/LICENSE +21 -0
  56. package/templates/new/README.md +71 -0
  57. package/templates/new/_gitignore +7 -0
  58. package/templates/new/component-variables.manifest.json +32 -0
  59. package/templates/new/custom-elements-manifest.config.mjs +23 -0
  60. package/templates/new/docs/accessibility.md +24 -0
  61. package/templates/new/docs/examples.md +33 -0
  62. package/templates/new/docs/theming.md +54 -0
  63. package/templates/new/docs/usage.md +51 -0
  64. package/templates/new/e2e/dark-mode.spec.ts +30 -0
  65. package/templates/new/e2e/smoke.spec.ts +26 -0
  66. package/templates/new/examples-basic.html +57 -0
  67. package/templates/new/examples-theming.html +28 -0
  68. package/templates/new/index.html +16 -0
  69. package/templates/new/partials/note.html +5 -0
  70. package/templates/new/playwright.config.ts +4 -0
  71. package/templates/new/src/__FEATURE__.ts +55 -0
  72. package/templates/new/src/css/animations.css +1 -0
  73. package/templates/new/src/css/base.css +43 -0
  74. package/templates/new/src/css/controls.css +20 -0
  75. package/templates/new/src/css/dark-mode.css +36 -0
  76. package/templates/new/src/css/floating.css +1 -0
  77. package/templates/new/src/css/main.css +27 -0
  78. package/templates/new/src/css/states.css +22 -0
  79. package/templates/new/src/css/variables.css +40 -0
  80. package/templates/new/src/env.d.ts +7 -0
  81. package/templates/new/src/index.ts +17 -0
  82. package/templates/new/src/logger.ts +11 -0
  83. package/templates/new/src/types.ts +25 -0
  84. package/templates/new/src/web-component.ts +73 -0
  85. package/templates/new/test/dark-mode.html +18 -0
  86. package/templates/new/test/fixture.html +11 -0
  87. package/templates/new/tsconfig.json +15 -0
  88. package/templates/new/vite.config.ts +5 -0
  89. package/templates/new-minimal/CHANGELOG.md +13 -0
  90. package/templates/new-minimal/README.md +71 -0
  91. package/templates/new-minimal/component-variables.manifest.json +24 -0
  92. package/templates/new-minimal/docs/accessibility.md +20 -0
  93. package/templates/new-minimal/docs/examples.md +17 -0
  94. package/templates/new-minimal/docs/theming.md +53 -0
  95. package/templates/new-minimal/docs/usage.md +43 -0
  96. package/templates/new-minimal/e2e/smoke.spec.ts +10 -0
  97. package/templates/new-minimal/examples-basic.html +23 -0
  98. package/templates/new-minimal/examples-theming.html +28 -0
  99. package/templates/new-minimal/src/__FEATURE__.ts +37 -0
  100. package/templates/new-minimal/src/css/base.css +48 -0
  101. package/templates/new-minimal/src/css/controls.css +1 -0
  102. package/templates/new-minimal/src/css/states.css +1 -0
  103. package/templates/new-minimal/src/css/variables.css +26 -0
  104. package/templates/new-minimal/src/index.ts +17 -0
  105. package/templates/new-minimal/src/types.ts +10 -0
  106. package/templates/new-minimal/src/web-component.ts +48 -0
  107. package/templates/new-minimal/test/dark-mode.html +18 -0
  108. package/templates/new-minimal/test/fixture.html +11 -0
  109. package/templates/nginx.conf.tmpl +43 -0
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Page composition for example pages — the "one copy of every repeated line"
3
+ * half of the examples kit. Runs inside the kmwc Vite plugin (dev and build),
4
+ * before the kit is injected:
5
+ *
6
+ * 1. <kmwc-include src="partials/x.html" any-param="…">inner</kmwc-include>
7
+ * → the partial, with {{anyParam}} / {{content}} substituted. Nested
8
+ * includes are fine; cycles are an error.
9
+ * 2. <kmwc-demo>…markup…</kmwc-demo>
10
+ * → the live markup AND a code block of that exact markup, so a snippet
11
+ * can never drift from its demo. <script data-kmwc-show> inside a demo is
12
+ * executed and shown too.
13
+ * 3. A page without <html> is a FRAGMENT: it gets the devkit layout (head,
14
+ * header with back link / title / subtitle, container, component entry
15
+ * script), driven by <meta name="kmwc:…"> tags:
16
+ * kmwc:title page title (h1 + <title>) — required
17
+ * kmwc:subtitle header subtitle
18
+ * kmwc:layout page (default) | bare (no header / container — e.g. the index)
19
+ * kmwc:entry component entry script; default /<library.entry>, "none" to skip
20
+ * kmwc:body-class classes for <body> (e.g. dark-output)
21
+ * <link> and <style> elements anywhere in a fragment move into <head>.
22
+ * Full documents (<html …>) are left as they are — pages migrate one at a time.
23
+ *
24
+ * Pure string work on purpose: unit-testable, no HTML parser dependency. The
25
+ * tag shapes are deliberately strict (no nesting of a tag inside itself).
26
+ */
27
+ import { readFileSync, existsSync } from 'node:fs';
28
+ import { join, resolve } from 'node:path';
29
+ import { DEVKIT_ROOT } from '../config.js';
30
+ import { renderTemplate } from '../template.js';
31
+
32
+ const LAYOUT_TEMPLATE_PATH = join(DEVKIT_ROOT, 'templates', 'examples-layout.html');
33
+ const MAX_INCLUDE_DEPTH = 10;
34
+ const DEFAULT_LAYOUT = 'page';
35
+ const LAYOUTS = ['page', 'bare'];
36
+
37
+ const INCLUDE_PATTERN = /<kmwc-include\b([^>]*?)(?:\/>|>([\s\S]*?)<\/kmwc-include>)/g;
38
+ const DEMO_PATTERN = /<kmwc-demo\b([^>]*)>([\s\S]*?)<\/kmwc-demo>/g;
39
+ const SHOWN_SCRIPT_PATTERN = /<script\b([^>]*\bdata-kmwc-show\b[^>]*)>([\s\S]*?)<\/script>/g;
40
+ const PAGE_META_PATTERN = /<meta\s+name="kmwc:([a-z-]+)"\s+content="([^"]*)"\s*\/?>\s*/g;
41
+ const HEAD_ELEMENT_PATTERN = /<link\b[^>]*>\s*|<style\b[^>]*>[\s\S]*?<\/style>\s*/g;
42
+ const ATTRIBUTE_PATTERN = /([a-zA-Z][\w-]*)(?:\s*=\s*"([^"]*)")?/g;
43
+
44
+ const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
45
+ // A full document STARTS with a doctype or <html> (after a BOM, whitespace, comments). Matching
46
+ // `<html>` anywhere would misread a fragment whose script or code sample mentions it.
47
+ const DOCUMENT_START_PATTERN = /^?(?:\s|<!--[\s\S]*?-->)*<(?:!doctype\b|html[\s>])/i;
48
+
49
+ /**
50
+ * Apply `mapper` to the text OUTSIDE HTML comments only, so a usage example in
51
+ * a comment (`<!-- <kmwc-include src="…"> -->`) is never expanded.
52
+ */
53
+ export function mapOutsideComments(html, mapper) {
54
+ let mappedHtml = '';
55
+ let lastIndex = 0;
56
+ for (const comment of html.matchAll(HTML_COMMENT_PATTERN)) {
57
+ mappedHtml += mapper(html.slice(lastIndex, comment.index)) + comment[0];
58
+ lastIndex = comment.index + comment[0].length;
59
+ }
60
+ return mappedHtml + mapper(html.slice(lastIndex));
61
+ }
62
+
63
+ const mapToCamelCase = (kebabName) => kebabName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
64
+
65
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
66
+ const mapToEscapedHtml = (text) => String(text).replace(/[&<>"]/g, (character) => HTML_ESCAPES[character]);
67
+
68
+ /** `preview-class="x" code` → { previewClass: 'x', code: '' } */
69
+ export function getAttributes(attributeText) {
70
+ const attributes = {};
71
+ for (const match of attributeText.matchAll(ATTRIBUTE_PATTERN)) {
72
+ attributes[mapToCamelCase(match[1])] = match[2] ?? '';
73
+ }
74
+ return attributes;
75
+ }
76
+
77
+ /** Strip the common leading indentation and surrounding blank lines. */
78
+ export function mapToDedentedText(text) {
79
+ const lines = text.replace(/\t/g, ' ').split(/\r?\n/);
80
+ while (lines.length && !lines[0].trim()) lines.shift();
81
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
82
+ const indentation = Math.min(...lines.filter((line) => line.trim()).map((line) => line.match(/^ */)[0].length));
83
+ return lines.map((line) => line.slice(Number.isFinite(indentation) ? indentation : 0)).join('\n');
84
+ }
85
+
86
+ /**
87
+ * Replace every <kmwc-include> (recursively). Parameters are the tag's
88
+ * attributes (kebab → camel: `note-title` → {{noteTitle}}) plus {{content}}
89
+ * (the tag's inner HTML). Unknown {{placeholders}} are left untouched — a
90
+ * partial may legitimately show template syntax.
91
+ */
92
+ export function resolveIncludes(html, { root, includeChain = [], readFile = defaultReadFile }) {
93
+ if (includeChain.length > MAX_INCLUDE_DEPTH) throw new Error(`kmwc: includes nested deeper than ${MAX_INCLUDE_DEPTH}: ${includeChain.join(' → ')}`);
94
+ return mapOutsideComments(html, (text) =>
95
+ text.replace(INCLUDE_PATTERN, (includeTag, attributeText, innerHtml = '') => {
96
+ const { src: partialPath, ...parameters } = getAttributes(attributeText);
97
+ if (!partialPath) throw new Error(`kmwc: <kmwc-include> without src: ${includeTag.slice(0, 80)}`);
98
+ if (includeChain.includes(partialPath)) throw new Error(`kmwc: include cycle: ${[...includeChain, partialPath].join(' → ')}`);
99
+ const partialHtml = readFile(resolve(root, partialPath), partialPath);
100
+ const variables = { ...parameters, content: innerHtml.trim() };
101
+ const filledHtml = partialHtml.replace(/\{\{\s*([a-zA-Z][\w]*)\s*\}\}/g, (placeholder, name) => (name in variables ? variables[name] : placeholder));
102
+ return resolveIncludes(filledHtml, { root, includeChain: [...includeChain, partialPath], readFile });
103
+ })
104
+ );
105
+ }
106
+
107
+ function defaultReadFile(absolutePath, displayPath) {
108
+ if (!existsSync(absolutePath)) throw new Error(`kmwc: <kmwc-include src="${displayPath}"> — file not found (${absolutePath})`);
109
+ return readFileSync(absolutePath, 'utf8');
110
+ }
111
+
112
+ function renderCodeBlock(codeText, language) {
113
+ return `<div class="code-block kmwc-demo__code" data-language="${language}"><pre><code>${mapToEscapedHtml(codeText)}</code></pre></div>`;
114
+ }
115
+
116
+ /**
117
+ * Turn every <kmwc-demo> into live markup + the code that produced it.
118
+ * Attributes: `preview-class` (default "demo-area"), `markup-hidden` (show
119
+ * only the scripts' code), `code-hidden` (live only — rarely what you want).
120
+ */
121
+ export function renderDemos(html) {
122
+ return mapOutsideComments(html, (text) => text.replace(DEMO_PATTERN, renderDemo));
123
+ }
124
+
125
+ function renderDemo(demoTag, attributeText, innerHtml) {
126
+ const attributes = getAttributes(attributeText);
127
+ const shownScripts = [];
128
+ const markupHtml = innerHtml.replace(SHOWN_SCRIPT_PATTERN, (scriptTag, scriptAttributeText, scriptBody) => {
129
+ shownScripts.push({ attributeText: scriptAttributeText.replace(/\s*\bdata-kmwc-show\b(="[^"]*")?/, ''), body: scriptBody });
130
+ return '';
131
+ });
132
+ const codeBlocks = [];
133
+ if (!('markupHidden' in attributes) && markupHtml.trim()) codeBlocks.push(renderCodeBlock(mapToDedentedText(markupHtml), 'html'));
134
+ for (const script of shownScripts) codeBlocks.push(renderCodeBlock(mapToDedentedText(script.body), 'js'));
135
+
136
+ const previewClass = attributes.previewClass ?? 'demo-area';
137
+ const scriptsHtml = shownScripts.map((script) => `<script${script.attributeText}>${script.body}</script>`).join('\n');
138
+ return (
139
+ `<div class="kmwc-demo">\n` +
140
+ `<div class="kmwc-demo__preview ${previewClass}">${markupHtml}</div>\n` +
141
+ ('codeHidden' in attributes ? '' : codeBlocks.join('\n')) +
142
+ `\n</div>\n${scriptsHtml}`
143
+ );
144
+ }
145
+
146
+ /** Read and remove the page's <meta name="kmwc:…"> tags (`kmwc:body-class` → `bodyClass`). */
147
+ export function getPageMeta(html) {
148
+ const pageMeta = {};
149
+ const htmlWithoutMeta = mapOutsideComments(html, (text) =>
150
+ text.replace(PAGE_META_PATTERN, (metaTag, name, content) => {
151
+ pageMeta[mapToCamelCase(name)] = content;
152
+ return '';
153
+ })
154
+ );
155
+ return { pageMeta, htmlWithoutMeta };
156
+ }
157
+
158
+ export const checkIsFragment = (html) => !DOCUMENT_START_PATTERN.test(html);
159
+
160
+ /** Wrap a fragment page in the devkit layout. */
161
+ export function renderLayout(fragmentHtml, { config, pagePath }) {
162
+ const { pageMeta, htmlWithoutMeta } = getPageMeta(fragmentHtml);
163
+ const layout = pageMeta.layout || DEFAULT_LAYOUT;
164
+ if (!LAYOUTS.includes(layout)) throw new Error(`kmwc: ${pagePath}: unknown kmwc:layout "${layout}" (${LAYOUTS.join(' | ')})`);
165
+ if (layout === 'page' && !pageMeta.title) throw new Error(`kmwc: ${pagePath}: a fragment page needs <meta name="kmwc:title" content="…">`);
166
+
167
+ const headElements = [];
168
+ const bodyContent = mapOutsideComments(htmlWithoutMeta, (text) =>
169
+ text.replace(HEAD_ELEMENT_PATTERN, (element) => {
170
+ headElements.push(` ${element.trim()}`);
171
+ return '';
172
+ })
173
+ );
174
+
175
+ const entry = pageMeta.entry ?? `/${config.library.entry.replace(/^\.?\//, '')}`;
176
+ const pageTitle = pageMeta.title || config.title;
177
+ const bodyHtml =
178
+ layout === 'bare'
179
+ ? bodyContent.trim()
180
+ : [
181
+ '<div class="container">',
182
+ ' <header>',
183
+ ' <a href="index.html" class="back-link">← Back to Examples</a>',
184
+ ` <h1>${pageTitle}</h1>`,
185
+ pageMeta.subtitle ? ` <p class="subtitle">${pageMeta.subtitle}</p>` : '',
186
+ ' </header>',
187
+ bodyContent.trim(),
188
+ '</div>',
189
+ ]
190
+ .filter(Boolean)
191
+ .join('\n');
192
+
193
+ return renderTemplate(readFileSync(LAYOUT_TEMPLATE_PATH, 'utf8'), {
194
+ DOCUMENT_TITLE: layout === 'bare' ? pageTitle : `${pageTitle} — ${mapToEscapedHtml(config.title)}`,
195
+ HEAD_HTML: headElements.join('\n'),
196
+ BODY_ATTRIBUTES: pageMeta.bodyClass ? ` class="${pageMeta.bodyClass}"` : '',
197
+ BODY_HTML: bodyHtml,
198
+ // In <head>: module scripts run in document order, so the element is
199
+ // defined before the page's own scripts run.
200
+ ENTRY_SCRIPT_HTML: entry === 'none' ? '' : ` <script type="module" src="${entry}"></script>`,
201
+ });
202
+ }
203
+
204
+ /** The whole pipeline: includes → demos → layout (fragments only). */
205
+ export function composePage(html, { config, pagePath, readFile }) {
206
+ let composedHtml = resolveIncludes(html, { root: config.root, readFile });
207
+ composedHtml = renderDemos(composedHtml);
208
+ return checkIsFragment(composedHtml) ? renderLayout(composedHtml, { config, pagePath }) : composedHtml;
209
+ }