@transclude/core 0.3.0 → 0.5.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, readLibraries, refuseSpriteClash, spritePath } 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,39 @@ 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, and the build ends there rather than shipping
360
+ // icons that render wrong.
361
+ //
362
+ // One file per library. A downloaded icon set is a directory here, so the count
363
+ // this reports is two numbers: an icon total says nothing about how much any one
364
+ // page fetches, and the sheets are what a page fetches.
365
+
366
+ const iconsSrc = config.iconsDir ? path.join(root, config.appDir, config.iconsDir) : null;
367
+ let iconCount = 0;
368
+ let libraryCount = 0;
369
+
370
+ if (iconsSrc) {
371
+ const libraries = readLibraries(iconsSrc, root);
372
+ refuseSpriteClash(publicSrc, libraries);
373
+
374
+ if (libraries.length) fs.mkdirSync(publicOut, { recursive: true });
375
+
376
+ for (const { name, icons } of libraries) {
377
+ const file = path.join(publicOut, path.basename(spritePath(name)));
378
+ fs.writeFileSync(file, buildSprite(icons));
379
+ iconCount += icons.length;
380
+ }
381
+ libraryCount = libraries.length;
382
+ }
383
+
350
384
  // ---- assets, for runtimes with no filesystem ------------------------------
351
385
  //
352
386
  // The Node server reads `dist` off a disk. A worker cannot, so the same bytes are
@@ -470,6 +504,12 @@ const summary = [
470
504
  `${dynamic.length} route${dynamic.length === 1 ? '' : 's'} left to the server`,
471
505
  `${assets.size} client entr${assets.size === 1 ? 'y' : 'ies'}`,
472
506
  ...(publicFiles ? [`${publicFiles} public file${publicFiles === 1 ? '' : 's'}`] : []),
507
+ ...(iconCount
508
+ ? [
509
+ `${iconCount} icon${iconCount === 1 ? '' : 's'} in ` +
510
+ `${libraryCount} sheet${libraryCount === 1 ? '' : 's'}`,
511
+ ]
512
+ : []),
473
513
  ];
474
514
  console.log(`\n${summary.join(', ')}`);
475
515
  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, readLibraries, refuseSpriteClash } from '../src/icons.js';
10
11
  import { createServer as createViteServer } from 'vite';
11
12
  import {
12
13
  ACTION_METHODS,
@@ -62,6 +63,44 @@ 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
+ * One library's sprite, built per request rather than read off disk.
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
+ * is returned as text rather than thrown, so a missing viewBox reads the same
74
+ * here as the message that would stop the build.
75
+ *
76
+ * A name no library answers to is a 404, not an empty sprite. `/lucdie.svg` is a
77
+ * typo, and a blank icon is a worse way to find that out than a missing file.
78
+ */
79
+ function sprite(name) {
80
+ try {
81
+ const libraries = readLibraries(iconsRoot, root);
82
+ refuseSpriteClash(publicRoot, libraries);
83
+
84
+ const library = libraries.find((entry) => entry.name === name);
85
+ if (!library) {
86
+ const known = libraries.map((entry) => entry.name).join(', ') || 'none';
87
+ return {
88
+ status: 404,
89
+ type: 'text/plain; charset=utf-8',
90
+ body: `no icon library "${name}". There is: ${known}`,
91
+ };
92
+ }
93
+
94
+ return {
95
+ status: 200,
96
+ type: 'image/svg+xml; charset=utf-8',
97
+ body: buildSprite(library.icons),
98
+ };
99
+ } catch (error) {
100
+ return { status: 500, type: 'text/plain; charset=utf-8', body: error.message };
101
+ }
102
+ }
103
+
65
104
  // Built before Vite, because Vite needs it: in middleware mode with no `hmr`
66
105
  // option Vite starts its own WebSocket server on another port, the browser
67
106
  // refuses that socket as cross-origin, and every edit needs a manual reload.
@@ -260,6 +299,19 @@ async function buildApp() {
260
299
  middleware: await loadMiddleware(),
261
300
  });
262
301
 
302
+ // A public file at this URL is refused rather than raced, so registering after
303
+ // `baseApp` costs nothing: the public handler can only fall through to here.
304
+ if (iconsRoot) {
305
+ // Any `/name.svg` at the root, because a library is named by a directory the
306
+ // author made and dev has no list of them until it reads the disk. The public
307
+ // handler ran first, so an .svg the author wrote still wins.
308
+ app.get('/:file{[^/]+\\.svg}', (c) => {
309
+ const name = c.req.param('file').slice(0, -'.svg'.length);
310
+ const { status, type, body } = sprite(name);
311
+ return c.body(body, status, { 'content-type': type });
312
+ });
313
+ }
314
+
263
315
  // Already ordered most-specific first, so registration order is deterministic
264
316
  // rather than something to reason about per-router.
265
317
  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.5.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,11 @@ 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
+ svg-icon.html # scaffolded by npm create, yours to edit
41
+ icons/ # one SVG file per icon, compiled to /icons.svg
42
+ check.svg # <use href="/icons.svg#check">
43
+ lucide/ # a subdirectory is a library: /lucide.svg
44
+ check.svg # <use href="/lucide.svg#check">
41
45
  public/ # copied to the site root as-is
42
46
  transclude.config.js
43
47
  ```
@@ -157,6 +161,37 @@ into `<slot>`. Layouts nest, and each one loads its own data.
157
161
  <footer>${year}</footer>
158
162
  ```
159
163
 
164
+ ## Icons
165
+
166
+ `app/icons/` holds one SVG file per icon. The build compiles them into a single
167
+ `/icons.svg` of `<symbol>`s, so a page fetches one file however many icons it
168
+ shows. The file name is the id.
169
+
170
+ ```html
171
+ <svg width="16" height="16"><use href="/icons.svg#check"></use></svg>
172
+ ```
173
+
174
+ A subdirectory is a library of its own, served under its name. Put a downloaded
175
+ icon set in whole and reference it by library and name:
176
+
177
+ ```html
178
+ <svg width="16" height="16"><use href="/lucide.svg#check"></use></svg>
179
+ ```
180
+
181
+ Every icon file needs a `viewBox`. A library is one flat directory, so a
182
+ directory inside one is refused. Two libraries may each have a `check`.
183
+
184
+ A new project has `app/elements/svg-icon.html`, which wraps the `<use>` and gets
185
+ the two aria spellings right. It is the project's file, not the framework's.
186
+
187
+ ```html
188
+ <svg-icon name="check"></svg-icon>
189
+ <svg-icon library="lucide" name="check" label="Mark as done"></svg-icon>
190
+ ```
191
+
192
+ Most apps wrap this in a light element so a page names an icon instead of a URL.
193
+ See [references/elements.md](references/elements.md).
194
+
160
195
  ## Commands
161
196
 
162
197
  ```sh
@@ -186,6 +186,55 @@ 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 defines no element
192
+ for it. `npm create @transclude` writes this file into a new project, so it is
193
+ already there and it belongs to the project. Reproduced here for a project that
194
+ predates it, or one that deleted it.
195
+
196
+ ```html
197
+ <script properties>
198
+ export default {
199
+ library: 'icons',
200
+ name: '',
201
+ label: '',
202
+ };
203
+ </script>
204
+
205
+ <style>
206
+ :scope {
207
+ display: inline-flex;
208
+ vertical-align: -0.125em;
209
+ }
210
+ svg {
211
+ width: 1em;
212
+ height: 1em;
213
+ }
214
+ </style>
215
+
216
+ <svg if="label" role="img" aria-label="${label}"><use href="/${library}.svg#${name}"></use></svg>
217
+ <svg else aria-hidden="true"><use href="/${library}.svg#${name}"></use></svg>
218
+ ```
219
+
220
+ `library` is the directory in `app/icons/`, and `icons` is the files loose at the
221
+ top. `<svg-icon library="lucide" name="check">` draws
222
+ `app/icons/lucide/check.svg`.
223
+
224
+ `<svg-icon name="check">` is decorative and hidden from a screen reader, which is
225
+ right when the icon sits beside its own label. `<svg-icon name="check"
226
+ label="Mark as done">` is announced, which is what a control holding nothing but
227
+ an icon needs. Do not pass a label for a decorative icon: `aria-hidden` and a
228
+ label together leave a screen reader nothing to say.
229
+
230
+ Set no `fill` or `stroke` here. Each symbol carries what its own file declared,
231
+ and an attribute on the symbol beats a value inherited from the element, so
232
+ setting them wins for some icon sets and loses for others. `1em` and
233
+ `currentColor` put size and color under the surrounding text instead.
234
+
235
+ Put a space between text and an icon with a `gap`, not a text node. A space is
236
+ underlined by a link and the icon is not, which reads as a typo.
237
+
189
238
  ## Traps
190
239
 
191
240
  **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`. A subdirectory is a library at `/<name>.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,243 @@
1
+ // A directory of SVG files, as one sprite. A directory of those, as several.
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
+ // A subdirectory is a library, and it is the case people arrive with: an icon
9
+ // set is downloaded as a folder of files, and the way to use it should be to put
10
+ // the folder here. `app/icons/lucide/check.svg` is `/lucide.svg#check`, and
11
+ // nothing was renamed to get there. Loose files at the top are the `icons`
12
+ // library, which is why the default sheet keeps the name it had.
13
+ //
14
+ // Build-time only, like `public-files.js` and outside the portable core: the
15
+ // sprite is bytes on disk by the time any server answers for it. `buildSprite`
16
+ // takes contents rather than a directory anyway, so the half that decides what
17
+ // the markup is can be tested without fixtures.
18
+ //
19
+ // The dev server and the build both call `readLibraries` then `buildSprite`.
20
+ // They used to be the same two lines written twice, which is how `/icons.svg`
21
+ // served in production and 404'd in dev.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { parse, serializeOuter } from 'parse5';
26
+
27
+ const SVG_NS = 'http://www.w3.org/2000/svg';
28
+
29
+ /**
30
+ * What loose files at the top of `app/icons/` are called.
31
+ *
32
+ * It is the name the one sheet already had, so a project that never makes a
33
+ * subdirectory sees the URL it always saw.
34
+ */
35
+ export const DEFAULT_LIBRARY = 'icons';
36
+
37
+ /**
38
+ * Where a library is served. At the site root, beside the author's public files,
39
+ * because `<use href>` is written by hand and `/lucide.svg` is what someone
40
+ * guesses.
41
+ *
42
+ * @param {string} library
43
+ * @returns {string}
44
+ */
45
+ export const spritePath = (library) => `/${library}.svg`;
46
+
47
+ /**
48
+ * Root attributes that must not survive into a `<symbol>`.
49
+ *
50
+ * `width` and `height` are the ones that matter: an icon file carries them so it
51
+ * renders on its own, and inside a sprite they fight whatever CSS sizes the
52
+ * `<use>`. Everything else here would be a second element's identity or a
53
+ * document's namespace, neither of which means anything on a symbol.
54
+ *
55
+ * Presentation attributes are deliberately not listed. `fill="none"
56
+ * stroke="currentColor"` on the root is how most icon sets say what they are,
57
+ * and dropping those turns every icon into a black blob.
58
+ */
59
+ const DROPPED = new Set(['width', 'height', 'xmlns', 'xmlns:xlink', 'version', 'id', 'role']);
60
+
61
+ const kept = (attr) => !DROPPED.has(attr.name) && !attr.name.startsWith('aria-');
62
+
63
+ /** The `<svg>` a file starts with, or null. Parsed as HTML, which is where SVG lives. */
64
+ function rootSvgOf(source) {
65
+ const find = (node) => {
66
+ for (const child of node.childNodes ?? []) {
67
+ if (child.tagName === 'svg' && child.namespaceURI === SVG_NS) return child;
68
+ const found = find(child);
69
+ if (found) return found;
70
+ }
71
+ return null;
72
+ };
73
+ return find(parse(source));
74
+ }
75
+
76
+ /**
77
+ * One file as one `<symbol>`.
78
+ *
79
+ * The parsed node is renamed and re-serialized rather than rebuilt from strings.
80
+ * parse5 already knows how to escape an attribute value and which SVG attributes
81
+ * keep their capitals, and a second hand-written serializer here would get
82
+ * `viewBox` wrong first.
83
+ *
84
+ * @param {{ id: string, file: string, svg: string }} icon
85
+ * @returns {string}
86
+ * @throws when the file is not an SVG, or has no `viewBox`
87
+ */
88
+ function symbolFor({ id, file, svg }) {
89
+ const root = rootSvgOf(svg);
90
+ if (!root) {
91
+ throw new Error(`[transclude] ${file} has no <svg> in it, so it is not an icon.`);
92
+ }
93
+
94
+ // Refused rather than warned. Without a viewBox the symbol has no coordinate
95
+ // system to scale into, so the icon renders at some other size and nothing
96
+ // says why. That is the failure this check exists for.
97
+ const viewBox = root.attrs.find((attr) => attr.name === 'viewBox');
98
+ if (!viewBox) {
99
+ throw new Error(
100
+ `[transclude] ${file} has no viewBox. A symbol scales by its viewBox, so ` +
101
+ `without one the icon renders at the wrong size and says nothing. ` +
102
+ `Add viewBox="0 0 24 24", with the numbers the artwork was drawn at.`,
103
+ );
104
+ }
105
+
106
+ root.tagName = 'symbol';
107
+ root.nodeName = 'symbol';
108
+ root.attrs = [{ name: 'id', value: id }, ...root.attrs.filter(kept)];
109
+
110
+ return serializeOuter(root);
111
+ }
112
+
113
+ /**
114
+ * Every icon as one SVG document.
115
+ *
116
+ * Sorted by id, so two builds of the same directory produce the same bytes and
117
+ * an ETag means what it says.
118
+ *
119
+ * @param {Array<{ id: string, file: string, svg: string }>} icons
120
+ * @returns {string} an SVG document of `<symbol>`s
121
+ * @throws when two files claim one id
122
+ */
123
+ export function buildSprite(icons) {
124
+ const byId = new Map();
125
+ for (const icon of icons) {
126
+ const first = byId.get(icon.id);
127
+ if (first) {
128
+ throw new Error(
129
+ `[transclude] ${first.file} and ${icon.file} would both be #${icon.id}. ` +
130
+ `An icon is named by its file, so two files cannot share a name even in ` +
131
+ `different directories. Rename one.`,
132
+ );
133
+ }
134
+ byId.set(icon.id, icon);
135
+ }
136
+
137
+ const sorted = [...icons].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
138
+ const symbols = sorted.map(symbolFor).join('');
139
+
140
+ // No `display:none` and no `<defs>`. A `<symbol>` renders nothing on its own,
141
+ // which is the whole reason the sprite is symbols rather than groups.
142
+ return `<svg xmlns="${SVG_NS}">${symbols}</svg>`;
143
+ }
144
+
145
+ /**
146
+ * Refuses a hand-written public file at a library's URL.
147
+ *
148
+ * Two things would answer for `/lucide.svg`, and the two servers pick different
149
+ * winners: the build copies the public directory first and writes the sprite
150
+ * over it, while dev asks the public handler first and never reaches the sprite.
151
+ * Rather than pick one, neither runs until the author has.
152
+ *
153
+ * Every library is checked, not just the default one. A library is named by a
154
+ * directory the author made, so the set of URLs this claims grows with their
155
+ * tree rather than being one name written down here.
156
+ *
157
+ * @param {string|null} publicDir the author's public directory, not the copy
158
+ * @param {Array<{ name: string }>} libraries
159
+ * @throws when a file already sits at a library's URL
160
+ */
161
+ export function refuseSpriteClash(publicDir, libraries) {
162
+ if (!publicDir) return;
163
+
164
+ for (const { name } of libraries) {
165
+ const url = spritePath(name);
166
+ const clash = path.join(publicDir, path.basename(url));
167
+ if (!fs.existsSync(clash)) continue;
168
+
169
+ throw new Error(
170
+ `[transclude] ${clash} and the ${name} icons both answer for ${url}. ` +
171
+ `The sprite is built from the icons, so rename the public file or delete it.`,
172
+ );
173
+ }
174
+ }
175
+
176
+ /** The icon files directly inside `dir`, ignoring anything that is not an SVG. */
177
+ function iconsIn(dir, root) {
178
+ const icons = [];
179
+
180
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
181
+ if (entry.isDirectory() || !entry.name.endsWith('.svg')) continue;
182
+
183
+ const full = path.join(dir, entry.name);
184
+ icons.push({
185
+ id: path.basename(entry.name, '.svg'),
186
+ file: path.relative(root, full),
187
+ svg: fs.readFileSync(full, 'utf8'),
188
+ });
189
+ }
190
+ return icons;
191
+ }
192
+
193
+ /**
194
+ * Every library under `dir`, each ready for `buildSprite`.
195
+ *
196
+ * Loose files at the top are the default library. Each subdirectory is a library
197
+ * of its own, named by the directory, which is what makes dropping a downloaded
198
+ * icon set in here the whole of using it.
199
+ *
200
+ * One level. A directory inside a library is refused rather than flattened or
201
+ * skipped: flattening would give two files one id, and skipping loses icons
202
+ * without saying so. Sorted, so a build reads the same on any filesystem.
203
+ *
204
+ * @param {string} dir
205
+ * @param {string} [root] what the reported file paths are relative to
206
+ * @returns {Array<{ name: string, icons: Array<{ id: string, file: string, svg: string }> }>}
207
+ * empty if `dir` is absent, and a library with no icons in it is not one
208
+ * @throws when a library holds a directory
209
+ */
210
+ export function readLibraries(dir, root = dir) {
211
+ if (!fs.existsSync(dir)) return [];
212
+
213
+ const libraries = [];
214
+
215
+ const loose = iconsIn(dir, root);
216
+ if (loose.length) libraries.push({ name: DEFAULT_LIBRARY, icons: loose });
217
+
218
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
219
+ if (!entry.isDirectory()) continue;
220
+
221
+ const full = path.join(dir, entry.name);
222
+ refuseNesting(full, root);
223
+
224
+ const icons = iconsIn(full, root);
225
+ if (icons.length) libraries.push({ name: entry.name, icons });
226
+ }
227
+
228
+ return libraries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
229
+ }
230
+
231
+ /** A library is a flat directory. Anything deeper has no name to be served under. */
232
+ function refuseNesting(dir, root) {
233
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
234
+ if (!entry.isDirectory()) continue;
235
+
236
+ const inner = path.relative(root, path.join(dir, entry.name));
237
+ throw new Error(
238
+ `[transclude] ${inner} is a directory inside a library, and a library is ` +
239
+ `one flat directory of icons. Move it up to be a library of its own, or ` +
240
+ `flatten it into the one it is in.`,
241
+ );
242
+ }
243
+ }
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,