@transclude/core 0.4.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/bin/build.js +24 -10
- package/bin/dev.js +32 -10
- package/package.json +1 -1
- package/skills/transclude/SKILL.md +20 -2
- package/skills/transclude/references/elements.md +11 -5
- package/skills/transclude/references/server.md +1 -1
- package/src/icons.js +101 -32
package/bin/build.js
CHANGED
|
@@ -22,7 +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,
|
|
25
|
+
import { buildSprite, readLibraries, refuseSpriteClash, spritePath } from '../src/icons.js';
|
|
26
26
|
import { PRECACHE_PATH, precacheDocument, precacheList } from '../src/precache.js';
|
|
27
27
|
import { cookiesOf } from '../src/cookies.js';
|
|
28
28
|
import { pool } from '../src/pool.js';
|
|
@@ -356,20 +356,29 @@ function countFiles(dir) {
|
|
|
356
356
|
// It is not counted as a public file, because the author did not write it.
|
|
357
357
|
//
|
|
358
358
|
// An icon named for a file the author can see is worth a stop: `buildSprite`
|
|
359
|
-
// throws on a missing viewBox
|
|
360
|
-
//
|
|
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.
|
|
361
365
|
|
|
362
366
|
const iconsSrc = config.iconsDir ? path.join(root, config.appDir, config.iconsDir) : null;
|
|
363
367
|
let iconCount = 0;
|
|
368
|
+
let libraryCount = 0;
|
|
364
369
|
|
|
365
370
|
if (iconsSrc) {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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;
|
|
372
380
|
}
|
|
381
|
+
libraryCount = libraries.length;
|
|
373
382
|
}
|
|
374
383
|
|
|
375
384
|
// ---- assets, for runtimes with no filesystem ------------------------------
|
|
@@ -495,7 +504,12 @@ const summary = [
|
|
|
495
504
|
`${dynamic.length} route${dynamic.length === 1 ? '' : 's'} left to the server`,
|
|
496
505
|
`${assets.size} client entr${assets.size === 1 ? 'y' : 'ies'}`,
|
|
497
506
|
...(publicFiles ? [`${publicFiles} public file${publicFiles === 1 ? '' : 's'}`] : []),
|
|
498
|
-
...(iconCount
|
|
507
|
+
...(iconCount
|
|
508
|
+
? [
|
|
509
|
+
`${iconCount} icon${iconCount === 1 ? '' : 's'} in ` +
|
|
510
|
+
`${libraryCount} sheet${libraryCount === 1 ? '' : 's'}`,
|
|
511
|
+
]
|
|
512
|
+
: []),
|
|
499
513
|
];
|
|
500
514
|
console.log(`\n${summary.join(', ')}`);
|
|
501
515
|
for (const url of prerendered) console.log(` ${url}`);
|
package/bin/dev.js
CHANGED
|
@@ -7,7 +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,
|
|
10
|
+
import { buildSprite, readLibraries, refuseSpriteClash } from '../src/icons.js';
|
|
11
11
|
import { createServer as createViteServer } from 'vite';
|
|
12
12
|
import {
|
|
13
13
|
ACTION_METHODS,
|
|
@@ -66,18 +66,36 @@ const publicFiles =
|
|
|
66
66
|
const iconsRoot = config.iconsDir ? path.join(root, config.appDir, config.iconsDir) : null;
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
69
|
+
* One library's sprite, built per request rather than read off disk.
|
|
70
70
|
*
|
|
71
71
|
* Reading a directory of small files on every request is what the rest of dev
|
|
72
72
|
* already does, and it is what makes adding an icon show up on reload. A refusal
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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.
|
|
75
78
|
*/
|
|
76
|
-
function sprite() {
|
|
79
|
+
function sprite(name) {
|
|
77
80
|
try {
|
|
78
|
-
const
|
|
79
|
-
refuseSpriteClash(publicRoot);
|
|
80
|
-
|
|
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
|
+
};
|
|
81
99
|
} catch (error) {
|
|
82
100
|
return { status: 500, type: 'text/plain; charset=utf-8', body: error.message };
|
|
83
101
|
}
|
|
@@ -284,8 +302,12 @@ async function buildApp() {
|
|
|
284
302
|
// A public file at this URL is refused rather than raced, so registering after
|
|
285
303
|
// `baseApp` costs nothing: the public handler can only fall through to here.
|
|
286
304
|
if (iconsRoot) {
|
|
287
|
-
|
|
288
|
-
|
|
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);
|
|
289
311
|
return c.body(body, status, { 'content-type': type });
|
|
290
312
|
});
|
|
291
313
|
}
|
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",
|
|
@@ -37,8 +37,11 @@ app/
|
|
|
37
37
|
api/_shared.js # not a route, the _ prefix says so
|
|
38
38
|
elements/ # every custom element, one file each
|
|
39
39
|
note-card.html # <note-card>, the name needs a dash
|
|
40
|
+
svg-icon.html # scaffolded by npm create, yours to edit
|
|
40
41
|
icons/ # one SVG file per icon, compiled to /icons.svg
|
|
41
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">
|
|
42
45
|
public/ # copied to the site root as-is
|
|
43
46
|
transclude.config.js
|
|
44
47
|
```
|
|
@@ -168,8 +171,23 @@ shows. The file name is the id.
|
|
|
168
171
|
<svg width="16" height="16"><use href="/icons.svg#check"></use></svg>
|
|
169
172
|
```
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
```
|
|
173
191
|
|
|
174
192
|
Most apps wrap this in a light element so a page names an icon instead of a URL.
|
|
175
193
|
See [references/elements.md](references/elements.md).
|
|
@@ -188,13 +188,15 @@ rest of them.
|
|
|
188
188
|
|
|
189
189
|
## An icon element
|
|
190
190
|
|
|
191
|
-
The framework compiles `app/icons/` into one `/icons.svg` and
|
|
192
|
-
for it.
|
|
193
|
-
|
|
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.
|
|
194
195
|
|
|
195
196
|
```html
|
|
196
197
|
<script properties>
|
|
197
198
|
export default {
|
|
199
|
+
library: 'icons',
|
|
198
200
|
name: '',
|
|
199
201
|
label: '',
|
|
200
202
|
};
|
|
@@ -211,10 +213,14 @@ inventing.
|
|
|
211
213
|
}
|
|
212
214
|
</style>
|
|
213
215
|
|
|
214
|
-
<svg if="label" role="img" aria-label="${label}"><use href="
|
|
215
|
-
<svg else aria-hidden="true"><use href="
|
|
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>
|
|
216
218
|
```
|
|
217
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
|
+
|
|
218
224
|
`<svg-icon name="check">` is decorative and hidden from a screen reader, which is
|
|
219
225
|
right when the icon sits beside its own label. `<svg-icon name="check"
|
|
220
226
|
label="Mark as done">` is announced, which is what a control holding nothing but
|
|
@@ -85,7 +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
|
+
| `iconsDir` | `'icons'` | One SVG file per icon, compiled to `/icons.svg`. A subdirectory is a library at `/<name>.svg`. Relative to `appDir`. |
|
|
89
89
|
| `publicDir` | `'public'` | Copied to the site root as-is. Relative to `appDir`. |
|
|
90
90
|
| `outDir` | `'dist'` | Where the build writes. |
|
|
91
91
|
| `stylesheet` | — | One global stylesheet, relative to the project root. |
|
package/src/icons.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
// A directory of SVG files, as one sprite.
|
|
1
|
+
// A directory of SVG files, as one sprite. A directory of those, as several.
|
|
2
2
|
//
|
|
3
3
|
// An icon stays a file the author manages: `app/icons/check.svg` is a whole SVG
|
|
4
4
|
// document they can open, edit and diff. What a browser wants is the other
|
|
5
5
|
// shape, one file of `<symbol>`s, so `<use href="/icons.svg#check">` costs one
|
|
6
6
|
// cached request however many icons a page shows.
|
|
7
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
|
+
//
|
|
8
14
|
// Build-time only, like `public-files.js` and outside the portable core: the
|
|
9
15
|
// sprite is bytes on disk by the time any server answers for it. `buildSprite`
|
|
10
16
|
// takes contents rather than a directory anyway, so the half that decides what
|
|
11
17
|
// the markup is can be tested without fixtures.
|
|
12
18
|
//
|
|
13
|
-
// The dev server and the build both call `
|
|
14
|
-
// used to be the same two lines written twice, which is how `/icons.svg`
|
|
15
|
-
// in production and 404'd in dev.
|
|
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.
|
|
16
22
|
|
|
17
23
|
import fs from 'node:fs';
|
|
18
24
|
import path from 'node:path';
|
|
@@ -20,8 +26,23 @@ import { parse, serializeOuter } from 'parse5';
|
|
|
20
26
|
|
|
21
27
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
22
28
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
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`;
|
|
25
46
|
|
|
26
47
|
/**
|
|
27
48
|
* Root attributes that must not survive into a `<symbol>`.
|
|
@@ -122,53 +143,101 @@ export function buildSprite(icons) {
|
|
|
122
143
|
}
|
|
123
144
|
|
|
124
145
|
/**
|
|
125
|
-
* Refuses a hand-written public file at
|
|
146
|
+
* Refuses a hand-written public file at a library's URL.
|
|
126
147
|
*
|
|
127
|
-
* Two things would answer for `/
|
|
148
|
+
* Two things would answer for `/lucide.svg`, and the two servers pick different
|
|
128
149
|
* winners: the build copies the public directory first and writes the sprite
|
|
129
150
|
* over it, while dev asks the public handler first and never reaches the sprite.
|
|
130
151
|
* Rather than pick one, neither runs until the author has.
|
|
131
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
|
+
*
|
|
132
157
|
* @param {string|null} publicDir the author's public directory, not the copy
|
|
133
|
-
* @
|
|
158
|
+
* @param {Array<{ name: string }>} libraries
|
|
159
|
+
* @throws when a file already sits at a library's URL
|
|
134
160
|
*/
|
|
135
|
-
export function refuseSpriteClash(publicDir) {
|
|
161
|
+
export function refuseSpriteClash(publicDir, libraries) {
|
|
136
162
|
if (!publicDir) return;
|
|
137
163
|
|
|
138
|
-
const
|
|
139
|
-
|
|
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;
|
|
140
182
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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;
|
|
145
191
|
}
|
|
146
192
|
|
|
147
193
|
/**
|
|
148
|
-
* Every
|
|
194
|
+
* Every library under `dir`, each ready for `buildSprite`.
|
|
149
195
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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.
|
|
153
203
|
*
|
|
154
204
|
* @param {string} dir
|
|
155
205
|
* @param {string} [root] what the reported file paths are relative to
|
|
156
|
-
* @returns {Array<{ id: string, file: string, svg: string }>}
|
|
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
|
|
157
209
|
*/
|
|
158
|
-
export function
|
|
210
|
+
export function readLibraries(dir, root = dir) {
|
|
159
211
|
if (!fs.existsSync(dir)) return [];
|
|
160
212
|
|
|
161
|
-
const
|
|
213
|
+
const libraries = [];
|
|
214
|
+
|
|
215
|
+
const loose = iconsIn(dir, root);
|
|
216
|
+
if (loose.length) libraries.push({ name: DEFAULT_LIBRARY, icons: loose });
|
|
217
|
+
|
|
162
218
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
219
|
+
if (!entry.isDirectory()) continue;
|
|
220
|
+
|
|
163
221
|
const full = path.join(dir, entry.name);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
);
|
|
172
242
|
}
|
|
173
|
-
return icons;
|
|
174
243
|
}
|