@transclude/core 0.8.0 → 0.9.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/LICENSE +1 -1
- package/README.md +24 -10
- package/bin/build.js +34 -0
- package/bin/dev.js +58 -0
- package/package.json +1 -1
- package/skills/transclude/references/server.md +15 -4
- package/src/compiler/index.js +25 -0
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
<a href="https://github.com/transclude-dev/transclude/blob/main/LICENSE"
|
|
16
16
|
><img alt="MIT" src="https://img.shields.io/npm/l/%40transclude%2Fcore?color=0b7285"
|
|
17
17
|
/></a>
|
|
18
|
+
<a href="https://bsky.app/profile/transclude.dev" rel="me"
|
|
19
|
+
><img alt="Bluesky" src="https://img.shields.io/badge/Bluesky-transclude.dev-0b7285"
|
|
20
|
+
/></a>
|
|
18
21
|
</p>
|
|
19
22
|
|
|
20
23
|
HTML is the product. A page is an `.html` file, the directory tree is the route
|
|
@@ -23,7 +26,10 @@ table, and any fragment of a page is a URL of its own.
|
|
|
23
26
|
The same app runs on Node, Bun, Deno and workerd, the runtime behind
|
|
24
27
|
Cloudflare Workers, and ships no client JavaScript by default.
|
|
25
28
|
|
|
26
|
-
**[transclude.dev](https://transclude.dev)** has the documentation
|
|
29
|
+
**[transclude.dev](https://transclude.dev)** has the documentation, and
|
|
30
|
+
[every example is running](https://transclude.dev/docs/examples).
|
|
31
|
+
[todomvc.transclude.dev](https://todomvc.transclude.dev) ships zero bytes of
|
|
32
|
+
JavaScript, which is easier to believe with the page open.
|
|
27
33
|
|
|
28
34
|
```sh
|
|
29
35
|
npm create @transclude my-app
|
|
@@ -127,15 +133,23 @@ npm run check:src # type-check the framework itself
|
|
|
127
133
|
```
|
|
128
134
|
|
|
129
135
|
`examples/` holds apps built against this package the same way any other project
|
|
130
|
-
would be.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
would be. Every one of them is deployed, from the same source you would clone.
|
|
137
|
+
|
|
138
|
+
| Example | What it proves | Running |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| `todomvc` | Seven actions, one POST handler, zero bytes of JavaScript | [todomvc.transclude.dev](https://todomvc.transclude.dev) |
|
|
141
|
+
| `search` | A fragment that is a substring of the document it came from | [search.transclude.dev](https://search.transclude.dev) |
|
|
142
|
+
| `blog` | Markdown to files, with a sitemap and a feed | [blog.transclude.dev](https://blog.transclude.dev) |
|
|
143
|
+
| `htmx` | htmx and this framework, each doing its own half | [htmx.transclude.dev](https://htmx.transclude.dev) |
|
|
144
|
+
| `includes` | One piece of markup rendered in more than one place | [includes.transclude.dev](https://includes.transclude.dev) |
|
|
145
|
+
| `auth` | A section behind a layout and a signed cookie | [auth.transclude.dev](https://auth.transclude.dev) |
|
|
146
|
+
| `live` | Server-sent events into a fragment | [live.transclude.dev](https://live.transclude.dev) |
|
|
147
|
+
| `elements` | A light element and a shadow one, side by side | [elements.transclude.dev](https://elements.transclude.dev) |
|
|
148
|
+
| `showcase` | Every feature at once, and where the browser checks live | [showcase.transclude.dev](https://showcase.transclude.dev) |
|
|
149
|
+
|
|
150
|
+
The browser checks are in `showcase` because they need an app to run against.
|
|
151
|
+
`www/` is the site at transclude.dev: a landing page, the documentation under
|
|
152
|
+
`/docs`, the writing under `/blog`, and itself built with the framework.
|
|
139
153
|
|
|
140
154
|
### Trying the CLI against this checkout
|
|
141
155
|
|
package/bin/build.js
CHANGED
|
@@ -123,6 +123,29 @@ fs.writeFileSync(entry, `// @ts-nocheck\n${fs.readFileSync(entry, 'utf8')}`);
|
|
|
123
123
|
|
|
124
124
|
const { pages } = await import(pathToFileURL(entry).href);
|
|
125
125
|
|
|
126
|
+
// ---- drafts ---------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `export const draft = true` keeps a page out of the build.
|
|
130
|
+
*
|
|
131
|
+
* The dev server reads the directory, so a draft is a page there and you can
|
|
132
|
+
* open it, reload it and read it on a phone. This is the only place that knows
|
|
133
|
+
* the difference, and it takes the route out of the one list every step below
|
|
134
|
+
* reads: nothing is prerendered for it, no pattern reaches `routes.json`, and
|
|
135
|
+
* the sitemap never hears of it. In production the URL is a 404.
|
|
136
|
+
*
|
|
137
|
+
* Assigned back onto the manifest rather than carried alongside it. Three steps
|
|
138
|
+
* below read `manifest.routes`, and a fourth added later would publish drafts
|
|
139
|
+
* without anyone noticing. One list is the only version of this that stays true.
|
|
140
|
+
*
|
|
141
|
+
* The build says what it skipped, at the end, next to everything else it wrote.
|
|
142
|
+
* A page that is missing from production and silent about it is an afternoon.
|
|
143
|
+
*/
|
|
144
|
+
const isDraft = (route) => pages[route.id]?.draft === true;
|
|
145
|
+
const drafts = manifest.routes.filter(isDraft);
|
|
146
|
+
|
|
147
|
+
manifest.routes = manifest.routes.filter((route) => !isDraft(route));
|
|
148
|
+
|
|
126
149
|
/**
|
|
127
150
|
* A static route has one URL. A dynamic route has as many as its `paths` export
|
|
128
151
|
* names, and none at all if it does not export one, in which case it stays a
|
|
@@ -517,6 +540,17 @@ console.log(`\n${summary.join(', ')}`);
|
|
|
517
540
|
for (const url of prerendered) console.log(` ${url}`);
|
|
518
541
|
for (const route of dynamic) console.log(` ${route.pattern} (server-rendered)`);
|
|
519
542
|
|
|
543
|
+
// Last, and never silent. A draft is the one thing here that is absent from
|
|
544
|
+
// production on purpose, and the only way to tell a page that was skipped from
|
|
545
|
+
// a page that broke is to be told.
|
|
546
|
+
if (drafts.length) {
|
|
547
|
+
console.log(
|
|
548
|
+
`\n${drafts.length} draft${drafts.length === 1 ? '' : 's'} skipped, ` +
|
|
549
|
+
`and served by \`npm run dev\`:`,
|
|
550
|
+
);
|
|
551
|
+
for (const route of drafts) console.log(` ${route.pattern}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
520
554
|
if (compressed.files) {
|
|
521
555
|
const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
|
|
522
556
|
const pct = (n) => `${Math.round((1 - n / compressed.raw) * 100)}%`;
|
package/bin/dev.js
CHANGED
|
@@ -28,6 +28,10 @@ import { cookiesOf } from '../src/cookies.js';
|
|
|
28
28
|
import { loadProject, portOf } from '../src/project.js';
|
|
29
29
|
import { includeContext } from '../src/include.js';
|
|
30
30
|
import { nodeLookup } from '../src/lookup.js';
|
|
31
|
+
import { feed, feedPath, feedType } from '../src/feed.js';
|
|
32
|
+
import { sitemap } from '../src/sitemap.js';
|
|
33
|
+
import { documentStore, PROXY_PATH, proxyHandler } from '../src/proxy.js';
|
|
34
|
+
import { afterFor } from '../src/after.js';
|
|
31
35
|
|
|
32
36
|
const { root, config } = await loadProject();
|
|
33
37
|
const routesDir = resolveRoutesDir(path.join(root, config.appDir), config.routesDir);
|
|
@@ -139,6 +143,17 @@ const contextFor = (route, c, extra = {}) => ({
|
|
|
139
143
|
// is right for a form, and wrong for a caller that asked for markup.
|
|
140
144
|
fragment: fragmentOf(c),
|
|
141
145
|
action: null,
|
|
146
|
+
|
|
147
|
+
// Nothing is held between requests here, so there is nothing to drop. A
|
|
148
|
+
// no-op rather than an omission: an action calling this is correct code, and
|
|
149
|
+
// it should not throw in dev and work in production.
|
|
150
|
+
revalidateTag: () => {},
|
|
151
|
+
|
|
152
|
+
// Node keeps running after a response, so this only has to handle the
|
|
153
|
+
// rejection. `console.error` rather than `config.onError`, which the dev
|
|
154
|
+
// server does not use for anything else either.
|
|
155
|
+
after: afterFor(c, (error) => console.error('[transclude] ctx.after:', error)),
|
|
156
|
+
|
|
142
157
|
...withResponse(c, extra),
|
|
143
158
|
});
|
|
144
159
|
|
|
@@ -312,6 +327,49 @@ async function buildApp() {
|
|
|
312
327
|
});
|
|
313
328
|
}
|
|
314
329
|
|
|
330
|
+
// Before the route table, the same way production registers them, so a
|
|
331
|
+
// catch-all route cannot answer for one of these.
|
|
332
|
+
//
|
|
333
|
+
// They were missing here entirely. `createApp` mounts them and the dev server
|
|
334
|
+
// builds its own app, so `/feed.xml` and `/sitemap.xml` were 404 in dev and
|
|
335
|
+
// correct in the build. That is the worst shape a difference can take: the
|
|
336
|
+
// thing you check by hand is the thing that was never wired.
|
|
337
|
+
if (config.sitemap) {
|
|
338
|
+
app.get('/sitemap.xml', async (c) => {
|
|
339
|
+
// Only a parameter route ever reads its module, for `paths`. Loading the
|
|
340
|
+
// rest would compile the whole site to answer one request.
|
|
341
|
+
const pages = {};
|
|
342
|
+
for (const route of routes) {
|
|
343
|
+
if (route.params.length) pages[route.id] = await vite.ssrLoadModule(pageModuleId(route.id));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const xml = await sitemap({ routes }, pages, config.sitemap, c.req.query('p') ?? null);
|
|
347
|
+
return c.body(xml, 200, { 'Content-Type': 'application/xml; charset=utf-8' });
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (config.feed) {
|
|
352
|
+
app.get(feedPath(config.feed), async (c) => {
|
|
353
|
+
const xml = await feed(config.feed);
|
|
354
|
+
return c.body(xml, 200, { 'Content-Type': feedType(config.feed) });
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The browser calls this one, so a page using an external include worked in
|
|
359
|
+
// the build and 404ed here. Default deny is the config's doing either way: no
|
|
360
|
+
// `proxy` key, no route.
|
|
361
|
+
//
|
|
362
|
+
// `/precache.json` is deliberately not here. It names hashed asset filenames,
|
|
363
|
+
// which only the build knows, and a service worker holding anything in dev is
|
|
364
|
+
// a bug rather than a feature. It is build output and stays that way.
|
|
365
|
+
if (config.proxy) {
|
|
366
|
+
const handler = proxyHandler(config.proxy, {
|
|
367
|
+
lookup: config.proxy.lookup ?? nodeLookup(),
|
|
368
|
+
store: documentStore(config.proxy.cache),
|
|
369
|
+
});
|
|
370
|
+
app.get(PROXY_PATH, (c) => handler(c.req.raw));
|
|
371
|
+
}
|
|
372
|
+
|
|
315
373
|
// Already ordered most-specific first, so registration order is deterministic
|
|
316
374
|
// rather than something to reason about per-router.
|
|
317
375
|
for (const route of routes) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -141,6 +141,11 @@ this to a page that has to run for each request:
|
|
|
141
141
|
export const prerender = false;
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
**`export const draft = true` keeps a page out of the build.** `npm run dev`
|
|
145
|
+
serves it, because the dev server reads the directory. The build writes no file,
|
|
146
|
+
puts no route in the manifest and no line in the sitemap, and prints what it
|
|
147
|
+
skipped. Deployed, the URL is a 404. Publishing is deleting the line.
|
|
148
|
+
|
|
144
149
|
`prerender` is read off the page, never off its layouts. A layout that reads a
|
|
145
150
|
cookie makes every page under it request-dependent, and nothing says so.
|
|
146
151
|
|
|
@@ -182,12 +187,18 @@ import that.
|
|
|
182
187
|
|
|
183
188
|
```js
|
|
184
189
|
// app/lib/bindings.js — no `node:` imports, this ends up in the worker bundle
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
// A symbol in the global registry, not a module variable. The build inlines a
|
|
191
|
+
// copy of this file into the server bundle and wrangler bundles `worker.js`
|
|
192
|
+
// with a second one, so a module variable is written in one copy and read in
|
|
193
|
+
// the other.
|
|
194
|
+
const SLOT = Symbol.for('app.bindings');
|
|
195
|
+
|
|
196
|
+
export const hold = (env) => {
|
|
197
|
+
globalThis[SLOT] = env;
|
|
198
|
+
};
|
|
189
199
|
|
|
190
200
|
export const bindings = () => {
|
|
201
|
+
const current = globalThis[SLOT];
|
|
191
202
|
if (!current) throw new Error('No bindings: this app is running off workerd.');
|
|
192
203
|
return current;
|
|
193
204
|
};
|
package/src/compiler/index.js
CHANGED
|
@@ -91,6 +91,25 @@ export function readFlags(source, label = 'element') {
|
|
|
91
91
|
return resolveFlags(fromProps, fromClient, label);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Script types the browser executes. Everything else is a data block.
|
|
96
|
+
*
|
|
97
|
+
* The list is the HTML spec's, minus the legacy spellings nobody writes. An
|
|
98
|
+
* absent `type` is a classic script, `module` is a module, and both are code.
|
|
99
|
+
* `importmap` is not executed either, but the browser reads it, which is the
|
|
100
|
+
* same reason to emit it rather than compile it.
|
|
101
|
+
*
|
|
102
|
+
* @param {{ attrs?: Array<{ name: string, value: string }> }} node
|
|
103
|
+
* @returns {boolean} whether this script holds data rather than code
|
|
104
|
+
*/
|
|
105
|
+
function isDataBlock(node) {
|
|
106
|
+
const type = node.attrs?.find((attr) => attr.name === 'type')?.value;
|
|
107
|
+
if (type === undefined) return false;
|
|
108
|
+
|
|
109
|
+
const JS = ['', 'module', 'text/javascript', 'application/javascript'];
|
|
110
|
+
return !JS.includes(type.trim().toLowerCase());
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
/**
|
|
95
114
|
* Top-level <script>/<style> blocks are pulled out; everything else is template.
|
|
96
115
|
*
|
|
@@ -162,6 +181,12 @@ export function splitBlocks(source) {
|
|
|
162
181
|
// with nothing said. A nested `<script src>` was always markup; only a
|
|
163
182
|
// top-level one went missing.
|
|
164
183
|
else if (attrs.has('src')) out.nodes.push(node);
|
|
184
|
+
// A type the browser does not run as JavaScript makes this a data block,
|
|
185
|
+
// which is markup for the same reason a `src` is: there is no code here to
|
|
186
|
+
// compile. Import maps, JSON-LD, speculation rules written by hand and
|
|
187
|
+
// whatever a library reads out of the document were all swallowed, because
|
|
188
|
+
// anything without a marker was read as a client module.
|
|
189
|
+
else if (isDataBlock(node)) out.nodes.push(node);
|
|
165
190
|
else out.client.push(block);
|
|
166
191
|
continue;
|
|
167
192
|
}
|