@keenmate/web-components-devkit 1.0.0-rc05 → 1.0.0-rc07

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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Repo checks behind `kmwc doctor` that read a component's own files (and its
3
+ * registry siblings): accepted rulebook deviations, library output names, ports,
4
+ * analytics ids and page CSS the kit already covers. Each check is a pure
5
+ * function over text / config where it can be (unit-tested), plus a thin
6
+ * file-reading wrapper.
7
+ */
8
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ import { DEVKIT_ROOT } from '../config.js';
11
+
12
+ export const VALIDATION_NOTES_FILE = 'VALIDATION-NOTES.md';
13
+ const PORT_BLOCK_SIZE = 100;
14
+ const PLAUSIBLE_SNIPPET_FILE = 'plausible-snippet.html';
15
+ const DOC_FILES = ['README.md', 'CLAUDE.md'];
16
+ const DOCS_DIR = 'docs';
17
+ const SIBLING_PORT_FILES = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js', 'playwright.config.ts', 'playwright.config.mjs', 'playwright.config.js', 'Makefile', 'make.bat'];
18
+ const PORTED_SCRIPTS = { dev: 'dev', preview: 'preview', 'preview:examples': 'preview' };
19
+ const CHECK_ID_PATTERN = /\bC-[A-Z]+-\d+\b/g;
20
+
21
+ const readText = (filePath) => (existsSync(filePath) ? readFileSync(filePath, 'utf8') : null);
22
+ const getUniqueSorted = (values) => [...new Set(values)].sort((left, right) => (left > right ? 1 : left < right ? -1 : 0));
23
+
24
+ // ── accepted deviations ──────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Check ids accepted in VALIDATION-NOTES.md: every `C-XX-N` in a `##` heading
28
+ * (`## C-CST-4 / C-CST-10 — …` accepts both). Only headings count, so prose that
29
+ * merely mentions a check doesn't accept it.
30
+ */
31
+ export function getAcceptedCheckIds(notesText) {
32
+ if (!notesText) return new Set();
33
+ const headings = notesText.split(/\r?\n/).filter((line) => /^##\s/.test(line));
34
+ return new Set(headings.flatMap((heading) => heading.match(CHECK_ID_PATTERN) || []));
35
+ }
36
+
37
+ export function loadAcceptedCheckIds(root) {
38
+ return getAcceptedCheckIds(readText(join(root, VALIDATION_NOTES_FILE)));
39
+ }
40
+
41
+ // ── library output names ─────────────────────────────────────────────────────
42
+
43
+ /** The dist/ files the library build emits for a resolved `config.library`. */
44
+ export function getLibraryOutputPaths(library) {
45
+ const outputPaths = library.formats.map((format) => `dist/${library.fileName}.${format === 'es' ? 'js' : `${format}.js`}`);
46
+ outputPaths.push(`dist/${library.cssFileName}.css`);
47
+ if (library.shouldEmitTypes) outputPaths.push('dist/index.d.ts');
48
+ return outputPaths;
49
+ }
50
+
51
+ /** Every `./dist/…` target package.json points at, as [{ field, target }] (globs skipped). */
52
+ export function getPackageDistTargets(packageJson) {
53
+ const targets = [];
54
+ const addTarget = (field, target) => {
55
+ if (typeof target !== 'string') return;
56
+ const normalizedTarget = target.replace(/^\.\//, '');
57
+ if (normalizedTarget.startsWith('dist/') && !normalizedTarget.includes('*') && !normalizedTarget.endsWith('/')) targets.push({ field, target: normalizedTarget });
58
+ };
59
+ for (const field of ['main', 'module', 'types', 'typings', 'style', 'browser', 'unpkg', 'jsdelivr']) addTarget(field, packageJson[field]);
60
+ const visitExports = (value, path) => {
61
+ if (typeof value === 'string') addTarget(path, value);
62
+ else if (value && typeof value === 'object') for (const [key, child] of Object.entries(value)) visitExports(child, `${path}[${JSON.stringify(key)}]`);
63
+ };
64
+ visitExports(packageJson.exports, 'exports');
65
+ return targets;
66
+ }
67
+
68
+ /**
69
+ * package.json targets the library build won't produce — a renamed UMD/ES file
70
+ * or CSS breaks consumers silently (Vite 7 names the CSS after the package
71
+ * unless kmwc.library.cssFileName says otherwise). Pure.
72
+ */
73
+ export function getLibraryOutputMismatches(packageJson, library) {
74
+ const outputPaths = new Set(getLibraryOutputPaths(library));
75
+ // Types emitted by the repo's own step (`tsc`) aren't the preset's to know.
76
+ const isOwnTypes = (target) => !library.shouldEmitTypes && target.endsWith('.d.ts');
77
+ return getPackageDistTargets(packageJson).filter(({ target }) => !outputPaths.has(target) && !target.endsWith('.map') && !isOwnTypes(target));
78
+ }
79
+
80
+ /**
81
+ * A hand-written vite.config's `lib.name` (the UMD global) — migration check: the
82
+ * preset derives the global from kmwc.library.globalName. null when not found.
83
+ */
84
+ export function getLegacyGlobalName(viteConfigText) {
85
+ const libraryBlock = viteConfigText.match(/\blib\s*:\s*\{([\s\S]*?)\}/);
86
+ return libraryBlock?.[1].match(/\bname\s*:\s*['"]([A-Za-z_$][\w$]*)['"]/)?.[1] || null;
87
+ }
88
+
89
+ // ── ports ────────────────────────────────────────────────────────────────────
90
+
91
+ /** [first, last] port of a registry block (blocks are numbered by 100). */
92
+ export function getPortBlockRange(portBlock) {
93
+ return [portBlock, portBlock + PORT_BLOCK_SIZE - 1];
94
+ }
95
+
96
+ const checkIsInRange = (port, [firstPort, lastPort]) => port >= firstPort && port <= lastPort;
97
+
98
+ /** Scripts that pin a `--port` different from kmwc.ports → [{ scriptName, port, expectedPort }]. Pure. */
99
+ export function getScriptPortMismatches(scripts = {}, ports) {
100
+ return Object.entries(PORTED_SCRIPTS).flatMap(([scriptName, portName]) => {
101
+ const port = Number(String(scripts[scriptName] || '').match(/--port[=\s]+(\d+)/)?.[1]);
102
+ return port && port !== ports[portName] ? [{ scriptName, port, expectedPort: ports[portName] }] : [];
103
+ });
104
+ }
105
+
106
+ /** Ports a text names as `localhost:N`, `--port N`, `port: N`, `port N` or `PORT ?= N`. Pure. */
107
+ export function getMentionedPorts(text) {
108
+ const patterns = [/localhost:(\d{4,5})\b/g, /--port[=\s]+(\d{4,5})\b/g, /\bport\s*[:=]\s*(\d{4,5})\b/gi, /\bport\s+(\d{4,5})\b/gi, /PORT\s*\??=\s*(\d{4,5})\b/g];
109
+ return getUniqueSorted(patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => Number(match[1]))));
110
+ }
111
+
112
+ /** Mentions in README.md / CLAUDE.md / docs/*.md of ports outside the repo's block → [{ file, port }]. */
113
+ export function loadStalePortMentions(root, portBlock) {
114
+ const blockRange = getPortBlockRange(portBlock);
115
+ const docsDir = join(root, DOCS_DIR);
116
+ const docFiles = [...DOC_FILES, ...(existsSync(docsDir) ? readdirSync(docsDir).filter((name) => name.endsWith('.md')).map((name) => `${DOCS_DIR}/${name}`) : [])];
117
+ return docFiles.flatMap((file) => {
118
+ const text = readText(join(root, file));
119
+ return text ? getMentionedPorts(text).filter((port) => !checkIsInRange(port, blockRange)).map((port) => ({ file, port })) : [];
120
+ });
121
+ }
122
+
123
+ /** Ports a repo listens on: kmwc.ports (+ preview), script --port flags, ports in its vite / playwright configs and Makefile. */
124
+ export function loadRepoPorts(repoRoot) {
125
+ const repoPorts = [];
126
+ const packageJson = JSON.parse(readText(join(repoRoot, 'package.json')) || '{}');
127
+ const kmwcPorts = packageJson.kmwc?.ports || {};
128
+ if (kmwcPorts.dev) repoPorts.push({ port: kmwcPorts.dev, file: 'package.json#kmwc.ports.dev' }, { port: kmwcPorts.dev + 1, file: 'package.json#kmwc.ports (preview)' });
129
+ if (kmwcPorts.image) repoPorts.push({ port: kmwcPorts.image, file: 'package.json#kmwc.ports.image' });
130
+ for (const [scriptName, command] of Object.entries(packageJson.scripts || {})) {
131
+ for (const port of getMentionedPorts(command)) repoPorts.push({ port, file: `package.json#scripts.${scriptName}` });
132
+ }
133
+ for (const file of SIBLING_PORT_FILES) {
134
+ const text = readText(join(repoRoot, file));
135
+ if (text) for (const port of getMentionedPorts(text)) repoPorts.push({ port, file });
136
+ }
137
+ return repoPorts;
138
+ }
139
+
140
+ /**
141
+ * A sibling's checkout, resolved next to this repo. Registry paths are relative
142
+ * to the devkit checkout, which sits beside the components — so `../<id>` works
143
+ * from any component root. Entries inside the devkit (`.`, `playground`) and the
144
+ * repo itself are not siblings.
145
+ */
146
+ function getSiblingRoot(root, registryEntry) {
147
+ if (!registryEntry.path?.startsWith('../')) return null;
148
+ const siblingRoot = resolve(root, registryEntry.path);
149
+ return siblingRoot === resolve(root) ? null : siblingRoot;
150
+ }
151
+
152
+ /** Sibling repos listening on a port inside this repo's block → [{ id, port, file }]. */
153
+ export function loadSiblingPortCollisions(config, registry) {
154
+ const ownEntry = registry.components.find((entry) => entry.id === config.id);
155
+ if (!ownEntry?.port) return [];
156
+ const blockRange = getPortBlockRange(ownEntry.port);
157
+ return registry.components
158
+ .filter((entry) => entry.id !== config.id)
159
+ .flatMap((entry) => {
160
+ const siblingRoot = getSiblingRoot(config.root, entry);
161
+ if (!siblingRoot || !existsSync(join(siblingRoot, 'package.json'))) return [];
162
+ return loadRepoPorts(siblingRoot)
163
+ .filter(({ port }) => checkIsInRange(port, blockRange))
164
+ .map(({ port, file }) => ({ id: entry.id, port, file }));
165
+ });
166
+ }
167
+
168
+ /** This repo's own configs naming a port outside its block (a hand-written vite.config before migration). */
169
+ export function loadOwnPortsOutsideBlock(config, portBlock) {
170
+ const blockRange = getPortBlockRange(portBlock);
171
+ return loadRepoPorts(config.root).filter(({ port, file }) => !checkIsInRange(port, blockRange) && !file.startsWith('package.json#kmwc'));
172
+ }
173
+
174
+ // ── analytics ────────────────────────────────────────────────────────────────
175
+
176
+ /** A repo's Plausible site id: kmwc.analytics.plausibleSiteId, else the legacy snippet's `pa-…` script. */
177
+ export function loadPlausibleSiteId(repoRoot) {
178
+ const packageJson = JSON.parse(readText(join(repoRoot, 'package.json')) || '{}');
179
+ if (packageJson.kmwc?.analytics?.plausibleSiteId) return packageJson.kmwc.analytics.plausibleSiteId;
180
+ return readText(join(repoRoot, PLAUSIBLE_SNIPPET_FILE))?.match(/\/js\/(pa-[\w-]+)\.js/)?.[1] || null;
181
+ }
182
+
183
+ /** Siblings using the same Plausible site id (their traffic would be counted as this site's) → [id]. */
184
+ export function loadSharedPlausibleSiteIds(config, registry, siteId) {
185
+ if (!siteId) return [];
186
+ return registry.components
187
+ .filter((entry) => entry.id !== config.id)
188
+ .filter((entry) => {
189
+ const siblingRoot = getSiblingRoot(config.root, entry);
190
+ return siblingRoot && existsSync(join(siblingRoot, 'package.json')) && loadPlausibleSiteId(siblingRoot) === siteId;
191
+ })
192
+ .map((entry) => entry.id);
193
+ }
194
+
195
+ // ── page CSS the kit already covers ──────────────────────────────────────────
196
+
197
+ /** Page-local selectors with a kit replacement (key → advice). */
198
+ export const KIT_REPLACED_SELECTORS = {
199
+ '.kbd': 'use <kbd> — the kit styles it',
200
+ kbd: 'the kit styles <kbd>',
201
+ mark: 'the kit styles <mark>',
202
+ '.mt-1': 'the kit has .mt-1',
203
+ '.push-end': 'the kit has .push-end',
204
+ '.mono': 'the kit has .mono',
205
+ };
206
+
207
+ /** The kit's page vocabulary — every unprefixed class its stylesheets define. */
208
+ export function loadKitClassNames() {
209
+ const cssDir = join(DEVKIT_ROOT, 'src', 'examples', 'css');
210
+ const cssText = readdirSync(cssDir).map((name) => readFileSync(join(cssDir, name), 'utf8')).join('\n');
211
+ return new Set([...cssText.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/\.([a-z][a-z0-9-]*)/g)].map((match) => match[1]).filter((name) => !name.startsWith('kmwc')));
212
+ }
213
+
214
+ /** Top-level selectors of a stylesheet (one per comma part, comments and at-rule wrappers ignored). Pure. */
215
+ export function getRuleSelectors(cssText) {
216
+ const plainText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
217
+ return [...plainText.matchAll(/(^|[{};])\s*([^{};@]+?)\s*\{/g)].flatMap((match) => match[2].split(',').map((part) => part.trim())).filter(Boolean);
218
+ }
219
+
220
+ /** Map an inline width to the nearest kit width helper (4 / 6 / 9 / 12rem). */
221
+ function getWidthClass(value, unit) {
222
+ const rem = unit === 'px' ? value / 16 : value;
223
+ return rem <= 5 ? 'w-xs' : rem <= 7.5 ? 'w-sm' : rem <= 10.5 ? 'w-md' : 'w-lg';
224
+ }
225
+
226
+ /**
227
+ * What in one page (or examples-local.css) re-implements the kit: rules on a
228
+ * kit-owned selector and single-declaration inline styles a kit class covers.
229
+ * Returns [{ found, advice }]. Pure — `kitClassNames` from loadKitClassNames().
230
+ */
231
+ export function getKitDuplicates(pageText, kitClassNames, { isStylesheet = false } = {}) {
232
+ const duplicates = [];
233
+ const styleText = isStylesheet ? pageText : [...pageText.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]).join('\n');
234
+ for (const selector of getUniqueSorted(getRuleSelectors(styleText))) {
235
+ if (KIT_REPLACED_SELECTORS[selector]) duplicates.push({ found: `${selector} { … }`, advice: KIT_REPLACED_SELECTORS[selector] });
236
+ else if (/^\.[a-z][a-z0-9-]*$/.test(selector) && kitClassNames.has(selector.slice(1))) {
237
+ duplicates.push({ found: `${selector} { … }`, advice: `restyles the kit's ${selector} for this page — scope it (e.g. .my-demo ${selector}) or drop it` });
238
+ }
239
+ }
240
+ if (isStylesheet) return duplicates;
241
+ for (const match of pageText.matchAll(/<(\w+)\b[^>]*\sstyle="\s*([^";]+?)\s*;?\s*"/g)) {
242
+ const [, tagName, declaration] = match;
243
+ const width = declaration.match(/^width\s*:\s*(\d+(?:\.\d+)?)(rem|px)$/i);
244
+ if (width && /^(input|select)$/i.test(tagName)) duplicates.push({ found: `<${tagName} style="${declaration}">`, advice: `class="${getWidthClass(Number(width[1]), width[2])}"` });
245
+ else if (/^margin-(left|inline-start)\s*:\s*auto$/i.test(declaration)) duplicates.push({ found: `style="${declaration}"`, advice: 'class="push-end"' });
246
+ else if (/^margin-top\s*:\s*1rem$/i.test(declaration)) duplicates.push({ found: `style="${declaration}"`, advice: 'class="mt-1"' });
247
+ }
248
+ return duplicates;
249
+ }
@@ -69,14 +69,14 @@ const hasAnyFile = (root, fileNames) => fileNames.some((fileName) => existsSync(
69
69
  export function getStandardScripts(config) {
70
70
  const scripts = {
71
71
  dev: 'vite',
72
- build: 'vite build',
72
+ ...(config.library.shouldBuild ? { build: 'vite build' } : {}),
73
73
  'build:examples': 'vite build --mode examples',
74
74
  'preview:examples': 'vite preview --mode examples',
75
75
  'clean:dist': generateRemoveCommand(['dist']),
76
76
  clean: generateRemoveCommand(['dist', config.examples.outDir]),
77
77
  doctor: 'kmwc doctor',
78
78
  };
79
- if (existsSync(join(config.root, config.cssVariables.manifest))) scripts['check:vars'] = 'kmwc check-vars';
79
+ if (config.cssVariables.isEnabled && existsSync(join(config.root, config.cssVariables.manifest))) scripts['check:vars'] = 'kmwc check-vars';
80
80
  if (existsSync(join(config.root, CEM_CONFIG_FILE))) scripts.analyze = 'cem analyze';
81
81
  if (hasAnyFile(config.root, PLAYWRIGHT_CONFIG_FILES)) {
82
82
  Object.assign(scripts, {
@@ -84,6 +84,7 @@ export function getStandardScripts(config) {
84
84
  'test:e2e:ui': 'playwright test --ui',
85
85
  'test:e2e:headed': 'playwright test --headed',
86
86
  'test:e2e:install': 'playwright install chromium',
87
+ 'check:pages': 'kmwc check-pages',
87
88
  });
88
89
  }
89
90
  return scripts;
package/src/config.d.ts CHANGED
@@ -10,7 +10,12 @@ export interface KmwcPackageConfig {
10
10
  tag?: string;
11
11
  /** Reserved CSS prefix (base-variables.md "Existing prefixes"), e.g. `ms`. */
12
12
  prefix?: string;
13
- library?: {
13
+ /** Registry kind; defaults to the components.json entry's kind (else `component`). A `library` runs only the doctor checks that apply to it. */
14
+ kind?: 'component' | 'library' | 'showcase' | 'docs' | 'test';
15
+ /** The library build (`vite build`). `false` = the repo builds its package itself or ships none: plain `vite build` refuses to run, only `--mode examples` builds. */
16
+ library?: false | {
17
+ /** `false` = same as `library: false`, keeping `entry` for the example pages. */
18
+ shouldBuild?: boolean;
14
19
  entry?: string;
15
20
  /** UMD global name. */
16
21
  globalName?: string;
@@ -51,14 +56,15 @@ export interface KmwcPackageConfig {
51
56
  packageJsonPaths?: string[];
52
57
  installCommand?: string;
53
58
  };
54
- cssVariables?: {
59
+ /** `false` (or `checkMode: 'off'`): no variables manifest check — sync adds no `check:vars`, `kmwc check-vars` skips. */
60
+ cssVariables?: false | {
55
61
  manifest?: string;
56
62
  /** Directories (stylesheets scanned) or explicit files (any type, e.g. a built bundle). */
57
63
  sources?: string[];
58
64
  /** Prefixed names (without `--`) that are runtime internals, not theming knobs. */
59
65
  internalVariables?: string[];
60
66
  /** `declared`: manifest ⇔ `--x:` declarations; `consumed`: manifest ⇔ `var(--x)` reads. */
61
- checkMode?: 'declared' | 'consumed';
67
+ checkMode?: 'declared' | 'consumed' | 'off';
62
68
  };
63
69
  rulebook?: {
64
70
  /** Path to BlissFramework `guidelines/web-components` (env KMWC_RULEBOOK_PATH wins). */
@@ -75,6 +81,7 @@ export interface KmwcConfig {
75
81
  packageJson: Record<string, any>;
76
82
  id: string;
77
83
  packageName: string;
84
+ kind: string;
78
85
  version: string;
79
86
  title: string;
80
87
  tagline: string;
@@ -85,6 +92,7 @@ export interface KmwcConfig {
85
92
  author: string;
86
93
  license: string;
87
94
  library: {
95
+ shouldBuild: boolean;
88
96
  entry: string;
89
97
  globalName: string;
90
98
  fileName: string;
@@ -118,7 +126,8 @@ export interface KmwcConfig {
118
126
  manifest: string;
119
127
  sources: string[];
120
128
  internalVariables: string[];
121
- checkMode: 'declared' | 'consumed';
129
+ checkMode: 'declared' | 'consumed' | 'off';
130
+ isEnabled: boolean;
122
131
  };
123
132
  rulebook: { path: string; skippedChecks: string[] };
124
133
  sync: { skippedFiles: string[] };
@@ -137,6 +146,7 @@ export const PORT_RANGES: Array<{ kinds: string[]; firstBlock: number; lastBlock
137
146
  export const PREVIEW_PORT_OFFSET: number;
138
147
  export const IMAGE_PORT_OFFSET: number;
139
148
  export function getPortsForBlock(portBlock: number): { dev: number; preview: number; image: number };
149
+ export function getRegistryKind(id: string, registry?: { components: Array<{ id: string; kind: string }> }): string;
140
150
  export function getPortRange(kind: string): { kinds: string[]; firstBlock: number; lastBlock: number };
141
151
  export function getNextPortBlock(registry?: { components: Array<Record<string, any>> }, kind?: string): number;
142
152
  export function getPeerModule(root: string, moduleId: string): any;
package/src/config.js CHANGED
@@ -139,6 +139,11 @@ export function getRegistry() {
139
139
  return getJsonFile(join(DEVKIT_ROOT, 'components.json'));
140
140
  }
141
141
 
142
+ /** The registry kind of `id` (`component`, `library` …); `component` when it isn't registered. */
143
+ export function getRegistryKind(id, registry = getRegistry()) {
144
+ return registry.components.find((entry) => entry.id === id)?.kind || 'component';
145
+ }
146
+
142
147
  /** Strip the npm scope: `@keenmate/web-multiselect` → `web-multiselect`. */
143
148
  export function getUnscopedName(packageName) {
144
149
  return String(packageName || '').replace(/^@[^/]+\//, '');
@@ -182,13 +187,16 @@ export function getKmwcConfig(root = process.cwd()) {
182
187
  export function mapKmwcConfig(packageJson, root = process.cwd()) {
183
188
  const packageConfig = packageJson.kmwc || {};
184
189
  const id = packageConfig.id || getUnscopedName(packageJson.name) || 'component';
185
- const library = packageConfig.library || {};
190
+ // `library: false` — the repo builds its package itself (tsc …) or ships none;
191
+ // Vite serves and builds only the examples site.
192
+ const library = packageConfig.library === false ? { shouldBuild: false } : packageConfig.library || {};
186
193
  const examples = packageConfig.examples || {};
187
194
  const ports = packageConfig.ports || {};
188
195
  const image = packageConfig.image || {};
189
196
  const theme = packageConfig.theme || {};
190
197
  const analytics = packageConfig.analytics || {};
191
- const cssVariables = packageConfig.cssVariables || {};
198
+ // `cssVariables: false` — no variables manifest to check (no check:vars script).
199
+ const cssVariables = packageConfig.cssVariables === false ? { checkMode: 'off' } : packageConfig.cssVariables || {};
192
200
  const rulebook = packageConfig.rulebook || {};
193
201
  const devPort = ports.dev ?? 5173;
194
202
 
@@ -198,6 +206,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
198
206
  packageJson,
199
207
  id,
200
208
  packageName: packageJson.name || id,
209
+ kind: packageConfig.kind || getRegistryKind(id),
201
210
  version: packageJson.version || '0.1.0',
202
211
  title: packageConfig.title || mapToTitleCase(id),
203
212
  tagline: packageConfig.tagline || packageJson.description || '',
@@ -208,6 +217,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
208
217
  author: typeof packageJson.author === 'string' ? packageJson.author : packageJson.author?.name || 'Keenmate s.r.o.',
209
218
  license: packageJson.license || 'MIT',
210
219
  library: {
220
+ shouldBuild: library.shouldBuild ?? true,
211
221
  entry: library.entry || 'src/index.ts',
212
222
  globalName: library.globalName || mapToPascalCase(id),
213
223
  fileName: library.fileName || id,
@@ -254,6 +264,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
254
264
  sources: cssVariables.sources || ['src/css'],
255
265
  internalVariables: cssVariables.internalVariables || [],
256
266
  checkMode: cssVariables.checkMode || 'declared',
267
+ isEnabled: cssVariables.checkMode !== 'off',
257
268
  },
258
269
  rulebook: {
259
270
  path:
@@ -161,6 +161,15 @@
161
161
  margin-bottom: 0.75rem;
162
162
  }
163
163
 
164
+ /* A heading naming an API (`EV05 · getMonthDataCallback & beforeMonthChangeCallback`)
165
+ has no break opportunity — let it break rather than widen the page on a phone. */
166
+ h1,
167
+ h2,
168
+ h3,
169
+ h4 {
170
+ overflow-wrap: anywhere;
171
+ }
172
+
164
173
  ul {
165
174
  line-height: 1.8;
166
175
  }
@@ -207,6 +216,25 @@
207
216
  }
208
217
  }
209
218
 
219
+ /* A small phone (320–400px): a component with a fixed minimum width — an inline
220
+ calendar is ~250px — still fits inside a card. */
221
+ @media (max-width: 400px) {
222
+ body {
223
+ padding: 0.75rem;
224
+ }
225
+
226
+ .card,
227
+ .example-section {
228
+ padding: 1rem;
229
+ }
230
+
231
+ /* Boxes nested in a card (a demo card holding a demo area) add up. */
232
+ .demo-card,
233
+ .demo-area {
234
+ padding: 0.75rem;
235
+ }
236
+ }
237
+
210
238
  @media (prefers-reduced-motion: reduce) {
211
239
  html {
212
240
  scroll-behavior: auto;
@@ -120,6 +120,9 @@
120
120
  font-size: 0.9em;
121
121
  color: var(--kmwc-accent-color);
122
122
  font-family: var(--kmwc-mono-font-family);
123
+ /* A long identifier has no break opportunity — break it anywhere
124
+ rather than widen the page on a phone. */
125
+ overflow-wrap: anywhere;
123
126
  }
124
127
 
125
128
  pre code,
@@ -130,6 +133,35 @@
130
133
  border-radius: 0;
131
134
  color: inherit;
132
135
  font-size: inherit;
136
+ overflow-wrap: inherit;
137
+ }
138
+
139
+ /* A key in a keyboard map — the spotlight's key-hint look. */
140
+ kbd {
141
+ display: inline-block;
142
+ padding: 0.1rem 0.35rem;
143
+ background-color: var(--kmwc-elevated-bg);
144
+ color: var(--kmwc-text-color-1);
145
+ border: 1px solid var(--kmwc-border-color);
146
+ border-bottom-width: 2px;
147
+ border-radius: var(--kmwc-radius-sm);
148
+ font-family: var(--kmwc-font-family);
149
+ font-weight: 600;
150
+ font-size: 0.8em;
151
+ line-height: 1.2;
152
+ white-space: nowrap;
153
+ }
154
+
155
+ /* A search match — the spotlight's match highlight. */
156
+ mark {
157
+ background-color: var(--kmwc-accent-light-bg-hover);
158
+ color: var(--kmwc-accent-color);
159
+ font-weight: 600;
160
+ /* No padding: a match inside a word ("Town|send") must not split it. */
161
+ padding: 0;
162
+ border-radius: var(--kmwc-radius-sm);
163
+ box-decoration-break: clone;
164
+ -webkit-box-decoration-break: clone;
133
165
  }
134
166
 
135
167
  /* ==========================================================================
@@ -228,6 +260,18 @@
228
260
  margin-top: 1rem;
229
261
  }
230
262
 
263
+ /* Push an item to the end of a flex row (`.controls`, `.btn-group` …). */
264
+ .push-end {
265
+ margin-inline-start: auto;
266
+ }
267
+
268
+ /* Field widths: a native field is ~20 characters wide; these size a short
269
+ one (a page size, a caption) without an inline style. */
270
+ .w-xs { width: 4rem; max-width: 100%; }
271
+ .w-sm { width: 6rem; max-width: 100%; }
272
+ .w-md { width: 9rem; max-width: 100%; }
273
+ .w-lg { width: 12rem; max-width: 100%; }
274
+
231
275
  @media (max-width: 768px) {
232
276
  .grid,
233
277
  .grid-2,
@@ -74,7 +74,11 @@
74
74
  cursor: not-allowed;
75
75
  }
76
76
 
77
+ /* A number is short: 7rem holds six digits and the spin buttons (a
78
+ .form-group still stretches it to the row; .w-* sizes it). */
77
79
  :where(input[type="number"]):not(:where(.kmwc-native, .kmwc-native *)) {
80
+ width: 7rem;
81
+ max-width: 100%;
78
82
  padding-inline-end: 0.25rem; /* room for the spin buttons */
79
83
  }
80
84
 
@@ -114,5 +114,9 @@
114
114
  .kmwc-index__header {
115
115
  padding: 1.75rem 1.5rem;
116
116
  }
117
+
118
+ .kmwc-index__header h1 {
119
+ font-size: 2rem;
120
+ }
117
121
  }
118
122
  }
@@ -29,7 +29,8 @@
29
29
  --kmwc-log-info-color: var(--kmwc-info-color);
30
30
  --kmwc-log-warn-color: var(--kmwc-warning-color);
31
31
  --kmwc-log-err-color: var(--kmwc-danger-color);
32
- --kmwc-log-muted-color: var(--kmwc-text-color-3);
32
+ /* text-color-3 is 2.8:1 on the light panel — under WCAG AA's 4.5. */
33
+ --kmwc-log-muted-color: var(--kmwc-text-color-2);
33
34
  --kmwc-log-name-color: var(--kmwc-accent-color);
34
35
  --kmwc-log-rule-color: var(--kmwc-border-color);
35
36
  font-family: var(--kmwc-mono-font-family);
@@ -94,7 +95,9 @@
94
95
  }
95
96
 
96
97
  /* Timeline: a line with cells is a row; the cells keep their width and the
97
- detail after them wraps in its own column. */
98
+ detail after them wraps in its own column. When the cells leave the detail
99
+ less than ~14rem (a phone, a long source name) it drops under them at full
100
+ width instead of being squeezed to a letter per line. */
98
101
  .log-line:has(> :is(.log-line__seq, .log-line__time, .log-line__source, .log-line__name)),
99
102
  .event-entry:has(> :is(.event-time, .event-name)) {
100
103
  display: flex;
@@ -103,8 +106,12 @@
103
106
  border-bottom: 1px solid var(--kmwc-log-rule-color);
104
107
  }
105
108
 
109
+ .log-line:has(> :is(.log-line__seq, .log-line__time, .log-line__source, .log-line__name)) {
110
+ flex-wrap: wrap;
111
+ }
112
+
106
113
  .log-line:has(> :is(.log-line__seq, .log-line__time, .log-line__source, .log-line__name)) > :last-child:not(.log-line__name) {
107
- flex: 1 1 0;
114
+ flex: 1 1 14rem;
108
115
  min-width: 0;
109
116
  }
110
117
 
@@ -115,6 +122,7 @@
115
122
  .event-time,
116
123
  .event-name {
117
124
  flex: none;
125
+ max-width: 100%;
118
126
  }
119
127
 
120
128
  .log-line__seq,
@@ -179,7 +187,7 @@
179
187
  display: flex;
180
188
  flex-wrap: wrap;
181
189
  gap: 0.4rem;
182
- margin: 0.25rem 0 1rem;
190
+ margin: 0.75rem 0 1rem; /* it follows the demo it measures */
183
191
  }
184
192
 
185
193
  .css-readout code { font-size: 0.78rem; }
package/src/vite/index.js CHANGED
@@ -20,7 +20,7 @@ import { readdirSync, existsSync, cpSync } from 'node:fs';
20
20
  import { join, resolve } from 'node:path';
21
21
  import { getKmwcConfig, getDevkitPackageJson, generateBuildDefines, loadPeerModule, DEVKIT_ROOT, NON_PAGE_HTML_FILES } from '../config.js';
22
22
  import { renderThemeBootstrapScript } from '../examples/theme-switch.js';
23
- import { composePage } from './page-composer.js';
23
+ import { composePage, insertIntoHead, insertBeforeBodyEnd } from './page-composer.js';
24
24
  import { generateChapterIndex } from './chapter-index.js';
25
25
 
26
26
  const AUTO_ENTRY_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'auto.js');
@@ -162,11 +162,7 @@ export function createExamplesPlugin(pluginConfig = {}) {
162
162
  const kitImports = isBuild ? `import '${KIT_STYLESHEET_PATH}'; import '${VIRTUAL_AUTO_ID}';` : `import '${VIRTUAL_AUTO_ID}';`;
163
163
  const bodyHtml = `<script type="module">${kitImports}</script>\n`;
164
164
 
165
- html = /<head[^>]*>/i.test(html)
166
- ? html.replace(/<head[^>]*>/i, (headTag) => `${headTag}\n${headHtml}`)
167
- : headHtml + html;
168
- html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${bodyHtml}</body>`) : html + bodyHtml;
169
- return html;
165
+ return insertBeforeBodyEnd(insertIntoHead(html, headHtml), bodyHtml);
170
166
  },
171
167
  },
172
168
 
@@ -199,9 +195,16 @@ export function defineComponentConfig(presetConfig = {}) {
199
195
  const config = getKmwcConfig(presetConfig.root);
200
196
  // The component's own vite — the one running this config (see loadPeerModule).
201
197
  const { mergeConfig, searchForWorkspaceRoot } = await loadPeerModule(config.root, 'vite');
202
- const library = { ...config.library, ...(presetConfig.library || {}) };
198
+ const library = { ...config.library, ...(presetConfig.library === false ? { shouldBuild: false } : presetConfig.library || {}) };
203
199
  const isExamplesMode = env.mode === EXAMPLES_MODE;
204
200
  const isLibraryBuild = env.command === 'build' && !isExamplesMode;
201
+ if (isLibraryBuild && !library.shouldBuild) {
202
+ // The library build empties dist/ — it would wipe a package the repo builds itself.
203
+ throw new Error(
204
+ `${config.id}: kmwc.library is false — plain \`vite build\` has nothing to build. ` +
205
+ 'Build the package with its own script (npm run build), the examples site with `vite build --mode examples`.'
206
+ );
207
+ }
205
208
 
206
209
  const plugins = [createExamplesPlugin({ root: config.root }), ...(presetConfig.plugins || [])];
207
210
 
@@ -64,7 +64,36 @@ export function mapOutsideComments(html, mapper, skippedPattern = HTML_COMMENT_P
64
64
  return mappedHtml + mapper(html.slice(lastIndex));
65
65
  }
66
66
 
67
- const mapToCamelCase = (kebabName) => kebabName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
67
+ // The real tag only — `<head[^>]*>` also matches `<header>`.
68
+ const HEAD_TAG_PATTERN = /<head(?=[\s>])[^>]*>/i;
69
+ const BODY_END_TAG = '</body>';
70
+
71
+ /**
72
+ * Insert `headHtml` right after the page's <head> tag (prepend when there is none).
73
+ * The first <head> outside comments is the real one: the text "<head>" in a tooltip
74
+ * or a script comment comes later, and a snippet landing there breaks the page
75
+ * (an HTML comment inside a module script throws).
76
+ */
77
+ export function insertIntoHead(html, headHtml) {
78
+ let isInserted = false;
79
+ const insertedHtml = mapOutsideComments(html, (text) => {
80
+ if (isInserted || !HEAD_TAG_PATTERN.test(text)) return text;
81
+ isInserted = true;
82
+ return text.replace(HEAD_TAG_PATTERN, (headTag) => `${headTag}\n${headHtml}`);
83
+ });
84
+ return isInserted ? insertedHtml : headHtml + html;
85
+ }
86
+
87
+ /**
88
+ * Insert `bodyHtml` before the LAST </body> (append when there is none): an
89
+ * earlier one is text — e.g. a comment inside an inline script.
90
+ */
91
+ export function insertBeforeBodyEnd(html, bodyHtml) {
92
+ const bodyEndIndex = html.toLowerCase().lastIndexOf(BODY_END_TAG);
93
+ return bodyEndIndex < 0 ? html + bodyHtml : html.slice(0, bodyEndIndex) + bodyHtml + html.slice(bodyEndIndex);
94
+ }
95
+
96
+ const mapToCamelCase =(kebabName) => kebabName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
68
97
 
69
98
  const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
70
99
  const mapToEscapedHtml = (text) => String(text).replace(/[&<>"]/g, (character) => HTML_ESCAPES[character]);