@transclude/core 0.3.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
@@ -18,8 +18,7 @@
18
18
  </p>
19
19
 
20
20
  HTML is the product. A page is an `.html` file, the directory tree is the route
21
- table, and any fragment of a page is a URL of its own. Nothing has to run in the
22
- browser for the page to be correct.
21
+ table, and any fragment of a page is a URL of its own.
23
22
 
24
23
  The same app runs on Node, Bun, Deno and workerd, the runtime behind
25
24
  Cloudflare Workers, and ships no client JavaScript by default.
package/bin/build.js CHANGED
@@ -22,6 +22,7 @@ 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';
@@ -347,6 +348,30 @@ function countFiles(dir) {
347
348
  return total;
348
349
  }
349
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
+
350
375
  // ---- assets, for runtimes with no filesystem ------------------------------
351
376
  //
352
377
  // The Node server reads `dist` off a disk. A worker cannot, so the same bytes are
@@ -470,6 +495,7 @@ const summary = [
470
495
  `${dynamic.length} route${dynamic.length === 1 ? '' : 's'} left to the server`,
471
496
  `${assets.size} client entr${assets.size === 1 ? 'y' : 'ies'}`,
472
497
  ...(publicFiles ? [`${publicFiles} public file${publicFiles === 1 ? '' : 's'}`] : []),
498
+ ...(iconCount ? [`${iconCount} icon${iconCount === 1 ? '' : 's'}`] : []),
473
499
  ];
474
500
  console.log(`\n${summary.join(', ')}`);
475
501
  for (const url of prerendered) console.log(` ${url}`);
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,
@@ -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.
@@ -260,6 +281,15 @@ async function buildApp() {
260
281
  middleware: await loadMiddleware(),
261
282
  });
262
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
+
263
293
  // Already ordered most-specific first, so registration order is deterministic
264
294
  // rather than something to reason about per-router.
265
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.3.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",
@@ -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
  ```
@@ -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/icons.js ADDED
@@ -0,0 +1,174 @@
1
+ // A directory of SVG files, as one sprite.
2
+ //
3
+ // An icon stays a file the author manages: `app/icons/check.svg` is a whole SVG
4
+ // document they can open, edit and diff. What a browser wants is the other
5
+ // shape, one file of `<symbol>`s, so `<use href="/icons.svg#check">` costs one
6
+ // cached request however many icons a page shows.
7
+ //
8
+ // Build-time only, like `public-files.js` and outside the portable core: the
9
+ // sprite is bytes on disk by the time any server answers for it. `buildSprite`
10
+ // takes contents rather than a directory anyway, so the half that decides what
11
+ // the markup is can be tested without fixtures.
12
+ //
13
+ // The dev server and the build both call `readIcons` then `buildSprite`. They
14
+ // used to be the same two lines written twice, which is how `/icons.svg` served
15
+ // in production and 404'd in dev.
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { parse, serializeOuter } from 'parse5';
20
+
21
+ const SVG_NS = 'http://www.w3.org/2000/svg';
22
+
23
+ /** Where the sprite is served. Fixed, because `<use href>` is written by hand. */
24
+ export const SPRITE_PATH = '/icons.svg';
25
+
26
+ /**
27
+ * Root attributes that must not survive into a `<symbol>`.
28
+ *
29
+ * `width` and `height` are the ones that matter: an icon file carries them so it
30
+ * renders on its own, and inside a sprite they fight whatever CSS sizes the
31
+ * `<use>`. Everything else here would be a second element's identity or a
32
+ * document's namespace, neither of which means anything on a symbol.
33
+ *
34
+ * Presentation attributes are deliberately not listed. `fill="none"
35
+ * stroke="currentColor"` on the root is how most icon sets say what they are,
36
+ * and dropping those turns every icon into a black blob.
37
+ */
38
+ const DROPPED = new Set(['width', 'height', 'xmlns', 'xmlns:xlink', 'version', 'id', 'role']);
39
+
40
+ const kept = (attr) => !DROPPED.has(attr.name) && !attr.name.startsWith('aria-');
41
+
42
+ /** The `<svg>` a file starts with, or null. Parsed as HTML, which is where SVG lives. */
43
+ function rootSvgOf(source) {
44
+ const find = (node) => {
45
+ for (const child of node.childNodes ?? []) {
46
+ if (child.tagName === 'svg' && child.namespaceURI === SVG_NS) return child;
47
+ const found = find(child);
48
+ if (found) return found;
49
+ }
50
+ return null;
51
+ };
52
+ return find(parse(source));
53
+ }
54
+
55
+ /**
56
+ * One file as one `<symbol>`.
57
+ *
58
+ * The parsed node is renamed and re-serialized rather than rebuilt from strings.
59
+ * parse5 already knows how to escape an attribute value and which SVG attributes
60
+ * keep their capitals, and a second hand-written serializer here would get
61
+ * `viewBox` wrong first.
62
+ *
63
+ * @param {{ id: string, file: string, svg: string }} icon
64
+ * @returns {string}
65
+ * @throws when the file is not an SVG, or has no `viewBox`
66
+ */
67
+ function symbolFor({ id, file, svg }) {
68
+ const root = rootSvgOf(svg);
69
+ if (!root) {
70
+ throw new Error(`[transclude] ${file} has no <svg> in it, so it is not an icon.`);
71
+ }
72
+
73
+ // Refused rather than warned. Without a viewBox the symbol has no coordinate
74
+ // system to scale into, so the icon renders at some other size and nothing
75
+ // says why. That is the failure this check exists for.
76
+ const viewBox = root.attrs.find((attr) => attr.name === 'viewBox');
77
+ if (!viewBox) {
78
+ throw new Error(
79
+ `[transclude] ${file} has no viewBox. A symbol scales by its viewBox, so ` +
80
+ `without one the icon renders at the wrong size and says nothing. ` +
81
+ `Add viewBox="0 0 24 24", with the numbers the artwork was drawn at.`,
82
+ );
83
+ }
84
+
85
+ root.tagName = 'symbol';
86
+ root.nodeName = 'symbol';
87
+ root.attrs = [{ name: 'id', value: id }, ...root.attrs.filter(kept)];
88
+
89
+ return serializeOuter(root);
90
+ }
91
+
92
+ /**
93
+ * Every icon as one SVG document.
94
+ *
95
+ * Sorted by id, so two builds of the same directory produce the same bytes and
96
+ * an ETag means what it says.
97
+ *
98
+ * @param {Array<{ id: string, file: string, svg: string }>} icons
99
+ * @returns {string} an SVG document of `<symbol>`s
100
+ * @throws when two files claim one id
101
+ */
102
+ export function buildSprite(icons) {
103
+ const byId = new Map();
104
+ for (const icon of icons) {
105
+ const first = byId.get(icon.id);
106
+ if (first) {
107
+ throw new Error(
108
+ `[transclude] ${first.file} and ${icon.file} would both be #${icon.id}. ` +
109
+ `An icon is named by its file, so two files cannot share a name even in ` +
110
+ `different directories. Rename one.`,
111
+ );
112
+ }
113
+ byId.set(icon.id, icon);
114
+ }
115
+
116
+ const sorted = [...icons].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
117
+ const symbols = sorted.map(symbolFor).join('');
118
+
119
+ // No `display:none` and no `<defs>`. A `<symbol>` renders nothing on its own,
120
+ // which is the whole reason the sprite is symbols rather than groups.
121
+ return `<svg xmlns="${SVG_NS}">${symbols}</svg>`;
122
+ }
123
+
124
+ /**
125
+ * Refuses a hand-written public file at the sprite's URL.
126
+ *
127
+ * Two things would answer for `/icons.svg`, and the two servers pick different
128
+ * winners: the build copies the public directory first and writes the sprite
129
+ * over it, while dev asks the public handler first and never reaches the sprite.
130
+ * Rather than pick one, neither runs until the author has.
131
+ *
132
+ * @param {string|null} publicDir the author's public directory, not the copy
133
+ * @throws when a file already sits at the sprite's URL
134
+ */
135
+ export function refuseSpriteClash(publicDir) {
136
+ if (!publicDir) return;
137
+
138
+ const clash = path.join(publicDir, path.basename(SPRITE_PATH));
139
+ if (!fs.existsSync(clash)) return;
140
+
141
+ throw new Error(
142
+ `[transclude] ${clash} and the icons directory both answer for ${SPRITE_PATH}. ` +
143
+ `The sprite is built from the icons, so rename the public file or delete it.`,
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Every `.svg` under `dir`, ready for `buildSprite`.
149
+ *
150
+ * Nested directories are read, and an icon is still named by its file alone, so
151
+ * `ui/check.svg` and `nav/check.svg` collide. `buildSprite` says so by name.
152
+ * Sorting is left to it, so one directory reads the same on any filesystem.
153
+ *
154
+ * @param {string} dir
155
+ * @param {string} [root] what the reported file paths are relative to
156
+ * @returns {Array<{ id: string, file: string, svg: string }>} empty if `dir` is absent
157
+ */
158
+ export function readIcons(dir, root = dir) {
159
+ if (!fs.existsSync(dir)) return [];
160
+
161
+ const icons = [];
162
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
163
+ const full = path.join(dir, entry.name);
164
+ if (entry.isDirectory()) icons.push(...readIcons(full, root));
165
+ else if (entry.name.endsWith('.svg')) {
166
+ icons.push({
167
+ id: path.basename(entry.name, '.svg'),
168
+ file: path.relative(root, full),
169
+ svg: fs.readFileSync(full, 'utf8'),
170
+ });
171
+ }
172
+ }
173
+ return icons;
174
+ }
package/src/project.js CHANGED
@@ -86,6 +86,7 @@ const DEFAULTS = {
86
86
  routesDir: 'routes',
87
87
  elementsDir: 'elements',
88
88
  publicDir: 'public',
89
+ iconsDir: 'icons',
89
90
  outDir: 'dist',
90
91
  typesFile: 'app/transclude-env.d.ts',
91
92
  stylesheet: null,