@transclude/core 0.2.0 → 0.4.0

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/README.md CHANGED
@@ -1,8 +1,24 @@
1
- # transclude
1
+ <h1 align="center">transclude</h1>
2
+
3
+ <p align="center">An HTML-first server-side web framework.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/transclude-dev/transclude/actions/workflows/ci.yml"
7
+ ><img alt="CI" src="https://github.com/transclude-dev/transclude/actions/workflows/ci.yml/badge.svg"
8
+ /></a>
9
+ <a href="https://www.npmjs.com/package/@transclude/core"
10
+ ><img alt="npm" src="https://img.shields.io/npm/v/%40transclude%2Fcore?color=0b7285"
11
+ /></a>
12
+ <a href="https://transclude.dev/docs/runtimes"
13
+ ><img alt="node" src="https://img.shields.io/node/v/%40transclude%2Fcore?color=0b7285"
14
+ /></a>
15
+ <a href="https://github.com/transclude-dev/transclude/blob/main/LICENSE"
16
+ ><img alt="MIT" src="https://img.shields.io/npm/l/%40transclude%2Fcore?color=0b7285"
17
+ /></a>
18
+ </p>
2
19
 
3
20
  HTML is the product. A page is an `.html` file, the directory tree is the route
4
- table, and any fragment of a page is a URL of its own. Nothing has to run in the
5
- browser for the page to be correct.
21
+ table, and any fragment of a page is a URL of its own.
6
22
 
7
23
  The same app runs on Node, Bun, Deno and workerd, the runtime behind
8
24
  Cloudflare Workers, and ships no client JavaScript by default.
@@ -52,7 +68,7 @@ so a swap cannot drift from the page it replaces part of.
52
68
 
53
69
  ## What is in it
54
70
 
55
- - **Pages and endpoints.** An `.html` file answers GET; its `POST`, `PUT`,
71
+ - **Pages and endpoints.** An `.html` file responds to GET; its `POST`, `PUT`,
56
72
  `PATCH` and `DELETE` exports answer the rest, so a plain `<form method="post">`
57
73
  works. A `.js` file in the same tree returns a `Response`.
58
74
  - **Fragments.** Mark an element `fragment` and it has a URL of its own. htmx,
@@ -106,6 +122,7 @@ npm run htmx # the same, driven by htmx, on http://localhost:1965
106
122
  npm run includes # transclusion on http://localhost:1966
107
123
  npm run auth # a guarded section on http://localhost:1967
108
124
  npm run live # server-sent events on http://localhost:1968
125
+ npm run elements # light and shadow elements on http://localhost:1969
109
126
  npm run check:src # type-check the framework itself
110
127
  ```
111
128
 
@@ -115,7 +132,8 @@ prerendered site with a sitemap and a feed, `search` swaps a fragment into a
115
132
  page that works without it, `htmx` does the same with htmx and the
116
133
  `HX-Target` header, `includes` shows transclusion from three sources,
117
134
  `auth` guards a section with a layout and a signed cookie, `live` pushes
118
- updates over server-sent events, and `showcase` uses every feature and is where the
135
+ updates over server-sent events, `elements` puts a light and a shadow element
136
+ side by side, and `showcase` uses every feature and is where the
119
137
  browser checks live, because those need an app to run against. `www/` is the site at transclude.dev: a landing page, the
120
138
  documentation under `/docs`, and itself built with the framework.
121
139
 
package/bin/build.js CHANGED
@@ -16,12 +16,13 @@ import { pathToFileURL } from 'node:url';
16
16
  import { build } from 'vite';
17
17
  import transclude from '../src/plugin.js';
18
18
  import { loadProject } from '../src/project.js';
19
- import { absoluteFrom, renderRoute, responseOf } from '../src/document.js';
19
+ import { absoluteFrom, renderRoute, responseOf, urlFor } from '../src/document.js';
20
20
  import { feed, feedPath } from '../src/feed.js';
21
21
  import { includeContext } from '../src/include.js';
22
22
  import { nodeLookup } from '../src/lookup.js';
23
23
  import { sitemap } from '../src/sitemap.js';
24
24
  import { etagOf, loadAssets, loadStatic } from '../src/static-cache.js';
25
+ import { buildSprite, readIcons, refuseSpriteClash, SPRITE_PATH } from '../src/icons.js';
25
26
  import { PRECACHE_PATH, precacheDocument, precacheList } from '../src/precache.js';
26
27
  import { cookiesOf } from '../src/cookies.js';
27
28
  import { pool } from '../src/pool.js';
@@ -111,9 +112,15 @@ await build({
111
112
  },
112
113
  });
113
114
 
115
+ // A worker entry imports this bundle, and an editor set to check the app's JS
116
+ // then checks a file nobody wrote. Twenty errors in the showcase, all of them
117
+ // about generated code. The banner is what keeps it out of that program.
118
+ const entry = path.join(dist, 'server/entry.js');
119
+ fs.writeFileSync(entry, `// @ts-nocheck\n${fs.readFileSync(entry, 'utf8')}`);
120
+
114
121
  // ---- prerender ------------------------------------------------------------
115
122
 
116
- const { pages } = await import(pathToFileURL(path.join(dist, 'server/entry.js')).href);
123
+ const { pages } = await import(pathToFileURL(entry).href);
117
124
 
118
125
  /**
119
126
  * A static route has one URL. A dynamic route has as many as its `paths` export
@@ -134,7 +141,7 @@ async function urlsFor(route) {
134
141
 
135
142
  const listed = (await paths()) ?? [];
136
143
  return listed.map((params) => ({
137
- url: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
144
+ url: urlFor(route, params),
138
145
  params,
139
146
  }));
140
147
  }
@@ -341,6 +348,30 @@ function countFiles(dir) {
341
348
  return total;
342
349
  }
343
350
 
351
+ // ---- icons ----------------------------------------------------------------
352
+ //
353
+ // Written into `dist/public` after the author's own files are copied there, so
354
+ // everything below that reads the directory picks the sprite up: the asset
355
+ // module a runtime with no disk imports, the precache list, and precompression.
356
+ // It is not counted as a public file, because the author did not write it.
357
+ //
358
+ // An icon named for a file the author can see is worth a stop: `buildSprite`
359
+ // throws on a missing viewBox or two files claiming one name, and the build ends
360
+ // there rather than shipping icons that render wrong.
361
+
362
+ const iconsSrc = config.iconsDir ? path.join(root, config.appDir, config.iconsDir) : null;
363
+ let iconCount = 0;
364
+
365
+ if (iconsSrc) {
366
+ const icons = readIcons(iconsSrc, root);
367
+ if (icons.length) {
368
+ refuseSpriteClash(publicSrc);
369
+ fs.mkdirSync(publicOut, { recursive: true });
370
+ fs.writeFileSync(path.join(publicOut, path.basename(SPRITE_PATH)), buildSprite(icons));
371
+ iconCount = icons.length;
372
+ }
373
+ }
374
+
344
375
  // ---- assets, for runtimes with no filesystem ------------------------------
345
376
  //
346
377
  // The Node server reads `dist` off a disk. A worker cannot, so the same bytes are
@@ -464,6 +495,7 @@ const summary = [
464
495
  `${dynamic.length} route${dynamic.length === 1 ? '' : 's'} left to the server`,
465
496
  `${assets.size} client entr${assets.size === 1 ? 'y' : 'ies'}`,
466
497
  ...(publicFiles ? [`${publicFiles} public file${publicFiles === 1 ? '' : 's'}`] : []),
498
+ ...(iconCount ? [`${iconCount} icon${iconCount === 1 ? '' : 's'}`] : []),
467
499
  ];
468
500
  console.log(`\n${summary.join(', ')}`);
469
501
  for (const url of prerendered) console.log(` ${url}`);
package/bin/check.js CHANGED
@@ -23,7 +23,22 @@ if (!fs.existsSync(types) || fs.readFileSync(types, 'utf8') !== next) {
23
23
 
24
24
  // Nothing downstream reads this file, so nothing else would notice it being
25
25
  // wrong. Parse what we just wrote, or a bad identifier ships silently.
26
- const emitted = ts.createProgram([types], { noEmit: true, skipLibCheck: true });
26
+ //
27
+ // `skipLibCheck` has to be off, and it was on. This is a .d.ts, which is the one
28
+ // kind of file that flag skips, so the guard checked nothing at all: every
29
+ // project shipped a file naming `__Cookies` and declaring it nowhere. An editor
30
+ // missed it too, because a jsconfig.json implies the same flag.
31
+ //
32
+ // `types: []` keeps it to this file: whatever `@types` a project happens to have
33
+ // installed is not what is being checked here, and one of them failing to
34
+ // resolve its own dependency would read as our file being broken.
35
+ const emitted = ts.createProgram([types], {
36
+ noEmit: true,
37
+ skipLibCheck: false,
38
+ types: [],
39
+ target: ts.ScriptTarget.ESNext,
40
+ lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
41
+ });
27
42
  const broken = [
28
43
  ...emitted.getSyntacticDiagnostics(),
29
44
  ...emitted.getSemanticDiagnostics(),
@@ -63,16 +78,23 @@ for (const file of files) {
63
78
  const text = lines[line - 1] ?? '';
64
79
  const trimmed = text.replace(/^\s+/, '');
65
80
  const shift = text.length - trimmed.length;
81
+ // The caret line is drawn under the trimmed source, so the column moves left
82
+ // by however much indentation was cut. A run is capped so one long span does
83
+ // not wrap the terminal.
84
+ const pad = ' '.repeat(Math.max(0, column - shift));
85
+ const run = '~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)));
86
+
66
87
  console.log(`\n ${trimmed}`);
67
- console.log(` ${' '.repeat(Math.max(0, column - shift))}${'~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)))}`);
88
+ console.log(` ${pad}${run}`);
68
89
  }
69
90
  }
70
91
 
71
- const total = errors + warnings;
72
- console.log(
73
- total
74
- ? `\n${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} in ${files.length} files`
75
- : `\nNo type errors in ${files.length} files.`,
76
- );
92
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
93
+
94
+ if (errors + warnings) {
95
+ console.log(`\n${plural(errors, 'error')}, ${plural(warnings, 'warning')} in ${files.length} files`);
96
+ } else {
97
+ console.log(`\nNo type errors in ${files.length} files.`);
98
+ }
77
99
 
78
100
  process.exitCode = errors ? 1 : 0;
package/bin/dev.js CHANGED
@@ -7,6 +7,7 @@ import http from 'node:http';
7
7
  import path from 'node:path';
8
8
  import { getRequestListener } from '@hono/node-server';
9
9
  import { publicFiles as publicHandler } from '../src/public-files.js';
10
+ import { buildSprite, readIcons, refuseSpriteClash, SPRITE_PATH } from '../src/icons.js';
10
11
  import { createServer as createViteServer } from 'vite';
11
12
  import {
12
13
  ACTION_METHODS,
@@ -19,7 +20,7 @@ import {
19
20
  runAction,
20
21
  withEnvelope,
21
22
  } from '../src/document.js';
22
- import { clientEntryUrl, pageModuleId } from '../src/plugin.js';
23
+ import transclude, { clientEntryUrl, pageModuleId } from '../src/plugin.js';
23
24
  import { resolveRoutesDir, scanRoutes } from '../src/routes.js';
24
25
  import { baseApp, endpointMethods, runEndpoint, SERVER_FILE } from '../src/server.js';
25
26
  import { randomBytes } from 'node:crypto';
@@ -62,6 +63,26 @@ const publicFiles =
62
63
  ? publicHandler(path.relative(process.cwd(), publicRoot) || '.')
63
64
  : null;
64
65
 
66
+ const iconsRoot = config.iconsDir ? path.join(root, config.appDir, config.iconsDir) : null;
67
+
68
+ /**
69
+ * The sprite the build writes, built per request instead.
70
+ *
71
+ * Reading a directory of small files on every request is what the rest of dev
72
+ * already does, and it is what makes adding an icon show up on reload. A refusal
73
+ * from `buildSprite` is returned as text rather than thrown, so a missing
74
+ * viewBox reads the same here as the message that would stop the build.
75
+ */
76
+ function sprite() {
77
+ try {
78
+ const icons = readIcons(iconsRoot, root);
79
+ refuseSpriteClash(publicRoot);
80
+ return { status: 200, type: 'image/svg+xml; charset=utf-8', body: buildSprite(icons) };
81
+ } catch (error) {
82
+ return { status: 500, type: 'text/plain; charset=utf-8', body: error.message };
83
+ }
84
+ }
85
+
65
86
  // Built before Vite, because Vite needs it: in middleware mode with no `hmr`
66
87
  // option Vite starts its own WebSocket server on another port, the browser
67
88
  // refuses that socket as cross-origin, and every edit needs a manual reload.
@@ -71,6 +92,11 @@ const server = http.createServer();
71
92
  const vite = await createViteServer({
72
93
  root,
73
94
  appType: 'custom',
95
+ // Passed here rather than left to the project's own `vite.config.js`, which is
96
+ // where dev used to get it. A project needs no Vite config at all, and the one
97
+ // built here is the one `loadProject` filled in, so dev compiles against the
98
+ // same config the build does. `configResolved` ignores a second registration.
99
+ plugins: [transclude(config)],
74
100
  server: { middlewareMode: true, hmr: { server } },
75
101
  // Vite would serve these itself, ahead of Hono, and production would serve
76
102
  // them a different way, which is how dev and production come to disagree. One
@@ -229,7 +255,10 @@ async function loadMiddleware() {
229
255
  if (!fs.existsSync(serverFile)) return null;
230
256
 
231
257
  const url = `/${config.appDir}/${SERVER_FILE}`;
232
- const node = await vite.moduleGraph.getModuleByUrl(url, true);
258
+ // Vite's second argument is `ssr`. This module is only ever loaded through
259
+ // `ssrLoadModule`, so the SSR graph is the one holding it.
260
+ const ssr = true;
261
+ const node = await vite.moduleGraph.getModuleByUrl(url, ssr);
233
262
  if (node) vite.moduleGraph.invalidateModule(node);
234
263
 
235
264
  const mod = await vite.ssrLoadModule(url);
@@ -252,6 +281,15 @@ async function buildApp() {
252
281
  middleware: await loadMiddleware(),
253
282
  });
254
283
 
284
+ // A public file at this URL is refused rather than raced, so registering after
285
+ // `baseApp` costs nothing: the public handler can only fall through to here.
286
+ if (iconsRoot) {
287
+ app.get(SPRITE_PATH, (c) => {
288
+ const { status, type, body } = sprite();
289
+ return c.body(body, status, { 'content-type': type });
290
+ });
291
+ }
292
+
255
293
  // Already ordered most-specific first, so registration order is deterministic
256
294
  // rather than something to reason about per-router.
257
295
  for (const route of routes) {
package/bin/release.js CHANGED
@@ -23,14 +23,44 @@ const read = (rel) => JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
23
23
 
24
24
  function usage() {
25
25
  return [
26
- 'Usage: node bin/release.js <version|major|minor|patch> [--dry-run]',
26
+ 'Usage: node bin/release.js <version|major|minor|patch> --notes <file> [--dry-run]',
27
27
  '',
28
28
  ' Sets the version in both packages, verifies, commits and tags.',
29
+ ' The notes become the tag message, and CI makes the release page from it.',
29
30
  ' Pushing the tag is what publishes. Nothing here talks to a registry.',
30
31
  '',
31
32
  ].join('\n');
32
33
  }
33
34
 
35
+ /**
36
+ * The release notes, which are the tag's message and nothing else's.
37
+ *
38
+ * They used to be typed into the GitHub release form after the fact, which is
39
+ * a step with nothing holding it: v0.1.0 and v0.1.1 went to npm with no release
40
+ * page at all. Held in the tag, they are written before the thing that publishes
41
+ * exists, and `publish.yml` reads them back rather than asking anyone.
42
+ *
43
+ * @param {string|undefined} file
44
+ * @returns {string} the notes, trimmed
45
+ * @throws when there is no file, it is missing, or it says nothing
46
+ */
47
+ function notesFrom(file) {
48
+ if (!file) {
49
+ throw new Error(
50
+ 'no --notes <file>. The notes are the release page, so a release without ' +
51
+ 'them is one nobody can read. Write them, then pass the file.',
52
+ );
53
+ }
54
+
55
+ const full = path.resolve(root, file);
56
+ if (!fs.existsSync(full)) throw new Error(`no notes file at ${full}`);
57
+
58
+ const notes = fs.readFileSync(full, 'utf8').trim();
59
+ if (!notes) throw new Error(`${full} is empty`);
60
+
61
+ return notes;
62
+ }
63
+
34
64
  /** `1.2.3`, or what `major`/`minor`/`patch` makes of the current one. */
35
65
  function nextVersion(current, asked) {
36
66
  if (/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(asked)) return asked;
@@ -135,7 +165,12 @@ function packed() {
135
165
  function main() {
136
166
  const args = process.argv.slice(2);
137
167
  const dryRun = args.includes('--dry-run');
138
- const asked = args.find((a) => !a.startsWith('-'));
168
+
169
+ const notesAt = args.indexOf('--notes');
170
+ // Its value, or nowhere. Written out because `notesAt + 1` is 0 when there is
171
+ // no `--notes`, which is the first argument and is the version.
172
+ const notesValueAt = notesAt === -1 ? -1 : notesAt + 1;
173
+ const asked = args.find((arg, i) => !arg.startsWith('-') && i !== notesValueAt);
139
174
 
140
175
  if (!asked || args.includes('--help')) {
141
176
  process.stdout.write(usage());
@@ -145,6 +180,11 @@ function main() {
145
180
  const current = assertReleasable();
146
181
  const version = nextVersion(current, asked);
147
182
  assertUntagged(version);
183
+
184
+ // Read before the verify, which takes minutes. A missing notes file should
185
+ // cost a second, the way every other refusal here does.
186
+ const notes = notesFrom(args[notesValueAt]);
187
+
148
188
  process.stdout.write(`\n${current} -> ${version}\n\n`);
149
189
 
150
190
  setVersion(version);
@@ -171,14 +211,22 @@ function main() {
171
211
  const staged = run('git', ['diff', '--cached', '--name-only']).trim();
172
212
  if (staged) run('git', ['commit', '-m', `Release ${version}`]);
173
213
 
174
- run('git', ['tag', '-a', `v${version}`, '-m', `Release ${version}`]);
214
+ // The notes are the message, not `Release x.y.z`. `publish.yml` reads them
215
+ // back with `git tag -l --format=%(contents)` and makes the release page from
216
+ // them, so this is the only copy.
217
+ //
218
+ // `--cleanup=verbatim` because the default strips every line beginning with
219
+ // `#` as a comment. Release notes are markdown, so that silently deletes each
220
+ // heading and leaves the paragraphs under it, which reads as a formatting bug
221
+ // on the release page and is a git default doing what it was asked.
222
+ run('git', ['tag', '-a', `v${version}`, '--cleanup=verbatim', '-m', notes]);
175
223
 
176
224
  process.stdout.write(
177
225
  [
178
226
  `\nTagged v${version}. Nothing has been published yet.\n\n`,
179
227
  ' git push --follow-tags\n\n',
180
- 'That is what publishes. The workflow builds from the tag and signs\n',
181
- 'provenance against it.\n\n',
228
+ 'That is what publishes. The workflow builds from the tag, signs\n',
229
+ 'provenance against it, and writes the release page from its message.\n\n',
182
230
  ].join(''),
183
231
  );
184
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "An HTML-first server framework. A page is an .html file, the directory tree is the route table, and any fragment of a page is a URL of its own. Runs on Node, Bun, Deno and workerd, and ships no client JavaScript by default.",
5
5
  "keywords": [
6
6
  "html",
@@ -57,7 +57,7 @@
57
57
  ],
58
58
  "scripts": {
59
59
  "test": "node --test \"test/**/*.test.js\"",
60
- "test:examples": "npm test --prefix examples/showcase && npm test --prefix examples/todomvc && npm test --prefix examples/blog && npm test --prefix examples/search && npm test --prefix examples/htmx && npm test --prefix examples/includes && npm test --prefix examples/auth && npm test --prefix examples/live",
60
+ "test:examples": "npm test --prefix examples/showcase && npm test --prefix examples/todomvc && npm test --prefix examples/blog && npm test --prefix examples/search && npm test --prefix examples/htmx && npm test --prefix examples/includes && npm test --prefix examples/auth && npm test --prefix examples/live && npm test --prefix examples/elements",
61
61
  "test:www": "npm test --prefix www",
62
62
  "showcase": "npm run dev --prefix examples/showcase",
63
63
  "todomvc": "npm run dev --prefix examples/todomvc",
@@ -67,6 +67,7 @@
67
67
  "includes": "npm run dev --prefix examples/includes",
68
68
  "auth": "npm run dev --prefix examples/auth",
69
69
  "live": "npm run dev --prefix examples/live",
70
+ "elements": "npm run dev --prefix examples/elements",
70
71
  "www": "npm run dev --prefix www",
71
72
  "check:src": "tsc -p tsconfig.src.json",
72
73
  "release": "node bin/release.js"
@@ -10,8 +10,7 @@ metadata:
10
10
  # transclude
11
11
 
12
12
  HTML is the product. A page is an `.html` file, the server renders it, and what
13
- arrives is markup a browser already knows how to display. Nothing has to run in
14
- the browser for the page to be correct.
13
+ arrives is markup a browser already knows how to display.
15
14
 
16
15
  The directory tree is the route table. The same app runs on Node, Bun, Deno and
17
16
  workerd.
@@ -38,6 +37,8 @@ app/
38
37
  api/_shared.js # not a route, the _ prefix says so
39
38
  elements/ # every custom element, one file each
40
39
  note-card.html # <note-card>, the name needs a dash
40
+ icons/ # one SVG file per icon, compiled to /icons.svg
41
+ check.svg # <use href="/icons.svg#check">
41
42
  public/ # copied to the site root as-is
42
43
  transclude.config.js
43
44
  ```
@@ -96,7 +97,7 @@ the render, which is how a layout does a login redirect.
96
97
 
97
98
  ## Forms and actions
98
99
 
99
- A page answers GET with its loader. Other verbs are named exports on the same
100
+ A page responds to GET with its loader. Other verbs are named exports on the same
100
101
  file.
101
102
 
102
103
  ```html
@@ -157,6 +158,22 @@ into `<slot>`. Layouts nest, and each one loads its own data.
157
158
  <footer>${year}</footer>
158
159
  ```
159
160
 
161
+ ## Icons
162
+
163
+ `app/icons/` holds one SVG file per icon. The build compiles them into a single
164
+ `/icons.svg` of `<symbol>`s, so a page fetches one file however many icons it
165
+ shows. The file name is the id.
166
+
167
+ ```html
168
+ <svg width="16" height="16"><use href="/icons.svg#check"></use></svg>
169
+ ```
170
+
171
+ Every icon file needs a `viewBox`, and two files cannot share a name. The build
172
+ refuses either rather than shipping an icon that renders wrong.
173
+
174
+ Most apps wrap this in a light element so a page names an icon instead of a URL.
175
+ See [references/elements.md](references/elements.md).
176
+
160
177
  ## Commands
161
178
 
162
179
  ```sh
@@ -186,6 +186,49 @@ document.addEventListener(
186
186
  The element is then a real form field: it submits, resets and validates with the
187
187
  rest of them.
188
188
 
189
+ ## An icon element
190
+
191
+ The framework compiles `app/icons/` into one `/icons.svg` and ships no element
192
+ for it. This is the one most apps write, and it is worth copying rather than
193
+ inventing.
194
+
195
+ ```html
196
+ <script properties>
197
+ export default {
198
+ name: '',
199
+ label: '',
200
+ };
201
+ </script>
202
+
203
+ <style>
204
+ :scope {
205
+ display: inline-flex;
206
+ vertical-align: -0.125em;
207
+ }
208
+ svg {
209
+ width: 1em;
210
+ height: 1em;
211
+ }
212
+ </style>
213
+
214
+ <svg if="label" role="img" aria-label="${label}"><use href="/icons.svg#${name}"></use></svg>
215
+ <svg else aria-hidden="true"><use href="/icons.svg#${name}"></use></svg>
216
+ ```
217
+
218
+ `<svg-icon name="check">` is decorative and hidden from a screen reader, which is
219
+ right when the icon sits beside its own label. `<svg-icon name="check"
220
+ label="Mark as done">` is announced, which is what a control holding nothing but
221
+ an icon needs. Do not pass a label for a decorative icon: `aria-hidden` and a
222
+ label together leave a screen reader nothing to say.
223
+
224
+ Set no `fill` or `stroke` here. Each symbol carries what its own file declared,
225
+ and an attribute on the symbol beats a value inherited from the element, so
226
+ setting them wins for some icon sets and loses for others. `1em` and
227
+ `currentColor` put size and color under the surrounding text instead.
228
+
229
+ Put a space between text and an icon with a `gap`, not a text node. A space is
230
+ underlined by a link and the icon is not, which reads as a typo.
231
+
189
232
  ## Traps
190
233
 
191
234
  **A light element cannot `if` or `each` over a value that changes.** It does not
@@ -85,6 +85,7 @@ Source is JavaScript with JSDoc. Do not convert it to TypeScript.
85
85
  | `appDir` | `'app'` | Where the app lives, relative to the project root. |
86
86
  | `routesDir` | `'routes'` | Pages and endpoints. Relative to `appDir`. |
87
87
  | `elementsDir` | `'elements'` | Custom elements. Relative to `appDir`. |
88
+ | `iconsDir` | `'icons'` | One SVG file per icon, compiled to `/icons.svg`. Relative to `appDir`. |
88
89
  | `publicDir` | `'public'` | Copied to the site root as-is. Relative to `appDir`. |
89
90
  | `outDir` | `'dist'` | Where the build writes. |
90
91
  | `stylesheet` | — | One global stylesheet, relative to the project root. |
package/src/address.js CHANGED
@@ -69,8 +69,15 @@ export function parseV6(text) {
69
69
  const halves = body.split('::');
70
70
  if (halves.length > 2) return null;
71
71
 
72
- const read = (part) =>
73
- part === '' ? [] : part.split(':').map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN));
72
+ // Each half of a `::` is groups of up to four hex digits. A group that is not
73
+ // becomes NaN, which the caller checks for rather than throwing here.
74
+ const read = (part) => {
75
+ if (part === '') return [];
76
+ return part.split(':').map((group) => {
77
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return NaN;
78
+ return parseInt(group, 16);
79
+ });
80
+ };
74
81
 
75
82
  const head = read(halves[0]);
76
83
  const rest = halves.length === 2 ? read(halves[1]) : [];