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