@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 +1 -2
- package/bin/build.js +40 -0
- package/bin/dev.js +52 -0
- package/bin/release.js +53 -5
- package/package.json +1 -1
- package/skills/transclude/SKILL.md +37 -2
- package/skills/transclude/references/elements.md +49 -0
- package/skills/transclude/references/server.md +1 -0
- package/src/icons.js +243 -0
- package/src/project.js +1 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
"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.
|
|
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
|
+
}
|