@keenmate/web-components-devkit 1.0.0-rc02 → 1.0.0-rc04

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,107 @@
1
+ /**
2
+ * The site-wide chapter index behind the spotlight: every <h2> of every
3
+ * example page, read at build time (and on demand in dev) from the COMPOSED
4
+ * page — includes resolved, so a heading from a partial counts too.
5
+ *
6
+ * Served as the virtual module `virtual:kmwc-chapters` (see index.js), which
7
+ * the spotlight imports lazily on first open.
8
+ *
9
+ * Each entry links by section code when the heading has one (`#th07` — the
10
+ * kit resolves it on arrival, see chapter-links.js), else by the slug of its
11
+ * heading text, the same slug the chapter nav assigns. Pure string work, like
12
+ * page-composer.js.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { basename } from 'node:path';
16
+ import { composePage } from './page-composer.js';
17
+ import { generateSlug, getChapterCode } from '../examples/sections.js';
18
+ import { CHAPTER_CODE_PATTERN } from '../examples/constants.js';
19
+
20
+ /** Pages that are not part of the chapter index (the landing page has cards, not chapters). */
21
+ export const UNINDEXED_PAGE_NAMES = ['index'];
22
+
23
+ const COMMENT_SCRIPT_OR_STYLE_PATTERN = /<!--[\s\S]*?-->|<script\b[^>]*>[\s\S]*?<\/script>|<style\b[^>]*>[\s\S]*?<\/style>/gi;
24
+ const HEADING_PATTERN = /<h2\b[^>]*>([\s\S]*?)<\/h2>/gi;
25
+ const PAGE_HEADING_PATTERN = /<h1\b[^>]*>([\s\S]*?)<\/h1>/i;
26
+ const DOCUMENT_TITLE_PATTERN = /<title\b[^>]*>([\s\S]*?)<\/title>/i;
27
+ // The chips sections.js strips from a label (HEADING_CHIP_SELECTOR: `.badge`).
28
+ const BADGE_PATTERN = /<span\b[^>]*\bclass="[^"]*\bbadge\b[^"]*"[^>]*>[\s\S]*?<\/span>/gi;
29
+ const TAG_PATTERN = /<[^>]+>/g;
30
+
31
+ const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', middot: '·', mdash: '—', ndash: '–', hellip: '…', rarr: '→', larr: '←' };
32
+
33
+ export function mapToDecodedText(html) {
34
+ return html.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
35
+ if (name[0] === '#') {
36
+ const codePoint = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
37
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
38
+ }
39
+ return NAMED_ENTITIES[name.toLowerCase()] ?? entity;
40
+ });
41
+ }
42
+
43
+ /** Heading HTML → the label the kit shows (badges dropped, tags stripped, entities decoded). */
44
+ export function getHeadingText(headingHtml) {
45
+ const text = mapToDecodedText(headingHtml.replace(BADGE_PATTERN, '').replace(TAG_PATTERN, ''));
46
+ return text.replace(/\s+/g, ' ').trim();
47
+ }
48
+
49
+ /**
50
+ * Chapters of one composed page.
51
+ * @param {string} html the composed page
52
+ * @param {{ pageHref: string }} pageInfo
53
+ */
54
+ export function getPageChapters(html, { pageHref }) {
55
+ const visibleHtml = html.replace(COMMENT_SCRIPT_OR_STYLE_PATTERN, '');
56
+ // The <h1> (the layout's page title); <title> carries the site name after " — ".
57
+ const headingTitleMatch = visibleHtml.match(PAGE_HEADING_PATTERN);
58
+ const documentTitleMatch = visibleHtml.match(DOCUMENT_TITLE_PATTERN);
59
+ const pageTitle =
60
+ (headingTitleMatch && getHeadingText(headingTitleMatch[1])) ||
61
+ (documentTitleMatch && getHeadingText(documentTitleMatch[1]).split(' — ')[0]) ||
62
+ pageHref;
63
+
64
+ const usedAnchors = new Set();
65
+ const chapters = [];
66
+ for (const headingMatch of visibleHtml.matchAll(HEADING_PATTERN)) {
67
+ const label = getHeadingText(headingMatch[1]);
68
+ if (!label) continue;
69
+ const code = getChapterCode(label);
70
+ let anchor = code ? code.toLowerCase() : generateSlug(label) || `section-${chapters.length + 1}`;
71
+ if (usedAnchors.has(anchor)) {
72
+ let suffix = 2;
73
+ while (usedAnchors.has(`${anchor}-${suffix}`)) suffix++;
74
+ anchor = `${anchor}-${suffix}`;
75
+ }
76
+ usedAnchors.add(anchor);
77
+ chapters.push({
78
+ code,
79
+ title: code ? label.replace(CHAPTER_CODE_PATTERN, '') : label,
80
+ label,
81
+ pageHref,
82
+ pageTitle,
83
+ href: `${pageHref}#${anchor}`,
84
+ });
85
+ }
86
+ return chapters;
87
+ }
88
+
89
+ /**
90
+ * Chapters of every example page, in page order (alphabetical, as Vite builds them).
91
+ * @param {import('../config.d.ts').KmwcConfig} config
92
+ * @param {Record<string, string>} pages page name → absolute path (getExamplePages)
93
+ */
94
+ export function generateChapterIndex(config, pages, readFile = (path) => readFileSync(path, 'utf8')) {
95
+ return Object.entries(pages)
96
+ .filter(([pageName]) => !UNINDEXED_PAGE_NAMES.includes(pageName))
97
+ .sort(([firstName], [secondName]) => firstName.localeCompare(secondName))
98
+ .flatMap(([, pagePath]) => {
99
+ const pageHref = basename(pagePath);
100
+ try {
101
+ const composedHtml = composePage(readFile(pagePath), { config, pagePath: `/${pageHref}` });
102
+ return getPageChapters(composedHtml, { pageHref });
103
+ } catch {
104
+ return []; // a broken page reports its own error when served — never break the index
105
+ }
106
+ });
107
+ }
package/src/vite/index.js CHANGED
@@ -21,9 +21,13 @@ 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
23
  import { composePage } from './page-composer.js';
24
+ import { generateChapterIndex } from './chapter-index.js';
24
25
 
25
26
  const AUTO_ENTRY_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'auto.js');
26
27
  const VIRTUAL_AUTO_ID = 'virtual:kmwc-examples';
28
+ // The spotlight's chapter index (chapter-index.js); spotlight.js imports it by this literal id.
29
+ const VIRTUAL_CHAPTERS_ID = 'virtual:kmwc-chapters';
30
+ const RESOLVED_CHAPTERS_ID = '\0' + VIRTUAL_CHAPTERS_ID;
27
31
  const KIT_STYLESHEET_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'css', 'style.css').replace(/\\/g, '/');
28
32
  // Vite's dev-server URL for a file outside the project root.
29
33
  const KIT_STYLESHEET_DEV_URL = `/@fs/${KIT_STYLESHEET_PATH.replace(/^\//, '')}`;
@@ -79,13 +83,26 @@ export function createExamplesPlugin(pluginConfig = {}) {
79
83
  },
80
84
 
81
85
  resolveId(moduleId) {
82
- return moduleId === VIRTUAL_AUTO_ID ? AUTO_ENTRY_PATH : null;
86
+ if (moduleId === VIRTUAL_AUTO_ID) return AUTO_ENTRY_PATH;
87
+ if (moduleId === VIRTUAL_CHAPTERS_ID) return RESOLVED_CHAPTERS_ID;
88
+ return null;
89
+ },
90
+
91
+ load(moduleId) {
92
+ if (moduleId !== RESOLVED_CHAPTERS_ID) return null;
93
+ const chapters = config.examples.isSpotlightEnabled ? generateChapterIndex(config, getExamplePages(config)) : [];
94
+ return `export default ${JSON.stringify(chapters)};`;
83
95
  },
84
96
 
85
97
  // A partial isn't a module, so Vite doesn't know which pages use it —
86
98
  // reload the page on any non-root HTML change (partials/…).
87
99
  handleHotUpdate({ file, server }) {
88
100
  const relativePath = file.replace(/\\/g, '/').replace(config.root.replace(/\\/g, '/') + '/', '');
101
+ // Any page or partial may change a heading — rebuild the chapter index on its next import.
102
+ if (relativePath.endsWith('.html')) {
103
+ const chaptersModule = server.moduleGraph.getModuleById(RESOLVED_CHAPTERS_ID);
104
+ if (chaptersModule) server.moduleGraph.invalidateModule(chaptersModule);
105
+ }
89
106
  if (relativePath.endsWith('.html') && relativePath.includes('/') && !relativePath.startsWith('test/')) {
90
107
  server.ws.send({ type: 'full-reload' });
91
108
  return [];
@@ -118,6 +135,7 @@ export function createExamplesPlugin(pluginConfig = {}) {
118
135
  mode: isBuild ? 'build' : 'dev',
119
136
  canonicalOrigin: config.examples.canonicalOrigin,
120
137
  storagePrefix: config.examples.storagePrefix,
138
+ isSpotlightEnabled: config.examples.isSpotlightEnabled,
121
139
  repository: config.repository,
122
140
  homepage: config.homepage,
123
141
  devkitVersion: devkitPackageJson.version,
@@ -21,12 +21,19 @@ locally — every local fork is how they drifted apart.
21
21
  - **Example page styles:** shared rules are in `@layer kmwc` and read
22
22
  `--base-*` tokens (pages support light / dark / auto via the theme switch), so
23
23
  any unlayered rule wins. Put component-only rules in `examples-local.css`.
24
- Re-theme with `kmwc.theme.accentColor`, never by copying CSS. If a second
25
- component needs a pattern, it belongs in the devkit.
24
+ Re-theme with `kmwc.theme.accentColor`, never by copying CSS. Native form
25
+ controls (inputs, selects, checkboxes, radios, `role="switch"`, buttons) and
26
+ tables are already styled by the kit (`.reference-table`, `.table-striped`,
27
+ `.table-compact`, …) — don't restyle them per page; `kmwc-native` opts an
28
+ element out. If a second component needs a pattern, it belongs in the devkit.
26
29
  - **Example pages are fragments:** no `<html>`/`<head>`/`<header>` — start with
27
30
  `<meta name="kmwc:title" content="…">`, the devkit layout adds the rest. Shared text
28
31
  goes in `partials/` via `<kmwc-include>`; show code with `<kmwc-demo>` (the live
29
32
  markup IS the snippet) — never hand-copy a demo into a `.code-block`.
33
+ - **Every example has a permanent code:** each `<h2>` starts `TH07 · Title`
34
+ (page prefix + number; a plain `Summary` is exempt). New examples take the
35
+ next free number; never renumber or reuse one — the rules are the rulebook's
36
+ `example-pages.md`.
30
37
  - **Controls persistence:**
31
38
  `import { attachControlsPersistence } from '@keenmate/web-components-devkit/examples'`.
32
39
  **Landing page:** `renderExamplesIndex({ pages })` from the same module.
@@ -36,8 +36,10 @@
36
36
  display: none;
37
37
  }
38
38
 
39
- *,
40
- *::before,
41
- *::after {
39
+ /* Scoped to :host — the whole file also reaches the page through index.ts's
40
+ CSS import, and a bare `*` would reset box-sizing on the host page too. */
41
+ :host *,
42
+ :host *::before,
43
+ :host *::after {
42
44
  box-sizing: inherit;
43
45
  }
@@ -35,9 +35,11 @@
35
35
  display: none;
36
36
  }
37
37
 
38
- *,
39
- *::before,
40
- *::after {
38
+ /* Scoped to :host — the whole file also reaches the page through index.ts's
39
+ CSS import, and a bare `*` would reset box-sizing on the host page too. */
40
+ :host *,
41
+ :host *::before,
42
+ :host *::after {
41
43
  box-sizing: inherit;
42
44
  }
43
45