@transclude/core 0.1.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 +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joe Dakroub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# transclude
|
|
2
|
+
|
|
3
|
+
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. Nothing has to run in the
|
|
5
|
+
browser for the page to be correct.
|
|
6
|
+
|
|
7
|
+
The same app runs on Node, Bun, Deno and workerd, and ships no client JavaScript
|
|
8
|
+
by default.
|
|
9
|
+
|
|
10
|
+
**[transclude.dev](https://transclude.dev)** has the documentation.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm create @transclude my-app
|
|
14
|
+
cd my-app
|
|
15
|
+
npm install
|
|
16
|
+
npm run dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What a page looks like
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<script server>
|
|
23
|
+
import { notes } from '../data/notes.js';
|
|
24
|
+
|
|
25
|
+
// Answers GET. Whatever it returns is what the template reads.
|
|
26
|
+
export default async () => ({ notes: notes.all() });
|
|
27
|
+
|
|
28
|
+
// Answers everything else. A <form method="post"> reaches this.
|
|
29
|
+
export const POST = async ({ request, url }) => {
|
|
30
|
+
notes.add((await request.formData()).get('text'));
|
|
31
|
+
// 303, so a reload is a GET and does not submit again.
|
|
32
|
+
return Response.redirect(new URL(url).origin + '/notes', 303);
|
|
33
|
+
};
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<title>Notes</title>
|
|
37
|
+
|
|
38
|
+
<form method="post">
|
|
39
|
+
<input name="text" required />
|
|
40
|
+
<button>Add</button>
|
|
41
|
+
</form>
|
|
42
|
+
|
|
43
|
+
<!-- An id plus `fragment` makes this a resource: /notes?fragment=list -->
|
|
44
|
+
<ul id="list" fragment>
|
|
45
|
+
<li each="note of notes">${note.text}</li>
|
|
46
|
+
</ul>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That page works with JavaScript turned off. It also answers
|
|
50
|
+
`GET /notes?fragment=list` with just the `<ul>`, from the same compiled markup,
|
|
51
|
+
so a swap cannot drift from the page it replaces part of.
|
|
52
|
+
|
|
53
|
+
## What is in it
|
|
54
|
+
|
|
55
|
+
- **Pages and endpoints.** An `.html` file answers GET; its `POST`, `PUT`,
|
|
56
|
+
`PATCH` and `DELETE` exports answer the rest, so a plain `<form method="post">`
|
|
57
|
+
works. A `.js` file in the same tree returns a `Response`.
|
|
58
|
+
- **Fragments.** Mark an element `fragment` and it has a URL of its own. htmx,
|
|
59
|
+
Turbo or a short `fetch` swaps it in. The framework ships nothing that does.
|
|
60
|
+
- **Includes.** `<transclude src="#id">` puts a fragment in a second place,
|
|
61
|
+
`src="/other#id"` reads another route of the app, and `src="https://…#id"`
|
|
62
|
+
reads a document somebody else wrote, through an allowlist.
|
|
63
|
+
- **Elements.** An `.html` file in `app/elements/` becomes a custom element.
|
|
64
|
+
Light DOM by default: no boundary, page CSS reaches it, `<label for>` works,
|
|
65
|
+
and it ships no JavaScript. `export const shadow = true` opts into a shadow
|
|
66
|
+
root and a re-render on an attribute change.
|
|
67
|
+
- **Types without writing TypeScript.** `npm run check` catches a misspelled
|
|
68
|
+
field, an unknown prop and a wrong-typed one, from the shapes your loaders
|
|
69
|
+
return. Annotations are optional.
|
|
70
|
+
- **A build that is files.** Prerendered pages, compressed once at rest, with a
|
|
71
|
+
strong ETag per encoding. `dist/static` is self-contained.
|
|
72
|
+
|
|
73
|
+
## What it does not do
|
|
74
|
+
|
|
75
|
+
- **No client-side router, and no swapper.** Every link is a document request
|
|
76
|
+
unless you bring something that swaps. That is a decision, not a gap.
|
|
77
|
+
- **No streaming.** The body is buffered so it can be hashed, which is what buys
|
|
78
|
+
the ETag. A `Link: rel=preload` goes out first, so a proxy can turn it into a
|
|
79
|
+
103 while the page is still being made.
|
|
80
|
+
- **No session store and no database opinion.** Signed cookies are the building
|
|
81
|
+
block.
|
|
82
|
+
- **No byte ranges on workerd.** A Range request gets 200 rather than 206.
|
|
83
|
+
Ranges are what a filesystem buys, and a worker has none.
|
|
84
|
+
- **`@scope` is soft scoping.** A light element's styles lose to page CSS of
|
|
85
|
+
equal specificity: right for content, a hazard for widgets.
|
|
86
|
+
|
|
87
|
+
## The packages
|
|
88
|
+
|
|
89
|
+
| | |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| [`@transclude/core`](https://www.npmjs.com/package/@transclude/core) | the framework |
|
|
92
|
+
| [`@transclude/create`](https://www.npmjs.com/package/@transclude/create) | `npm create @transclude` |
|
|
93
|
+
|
|
94
|
+
## Working on it
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
npm install
|
|
98
|
+
npm test # the framework's own, and they need no app
|
|
99
|
+
npm run test:examples # the demo's, against a build
|
|
100
|
+
npm run showcase # the demo on http://localhost:1961
|
|
101
|
+
npm run check:src # type-check the framework itself
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`examples/showcase` is an app built against this package the same way any other
|
|
105
|
+
project would be. It is where the browser checks live, because those need an app
|
|
106
|
+
to run against, and it is what the four runtimes are checked with. `docs/` is the
|
|
107
|
+
site at transclude.dev, and is itself built with the framework.
|
|
108
|
+
|
|
109
|
+
### Trying the CLI against this checkout
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
npm link # once, puts create-transclude on PATH
|
|
113
|
+
create-transclude my-app --template blank --link
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`--link` points the new project at this checkout rather than the registry, which
|
|
117
|
+
is what you want while changing the framework: an edit here is an edit there.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
package/bin/build.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Production build.
|
|
3
|
+
//
|
|
4
|
+
// dist/client hashed, minified client entries, one per route that needs JS
|
|
5
|
+
// dist/server the SSR bundle, plain ESM, no Vite at runtime
|
|
6
|
+
// dist/static prerendered HTML for every route whose URLs are knowable
|
|
7
|
+
// dist/routes.json what is left for the server to render on demand
|
|
8
|
+
//
|
|
9
|
+
// Page and layout CSS is inlined into <head> by the document assembler, and
|
|
10
|
+
// component CSS lives inside each shadow root, so there are no stylesheet
|
|
11
|
+
// assets to emit or link.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
import { build } from 'vite';
|
|
17
|
+
import transclude from '../src/plugin.js';
|
|
18
|
+
import { loadProject } from '../src/project.js';
|
|
19
|
+
import { absoluteFrom, renderRoute, responseOf } from '../src/document.js';
|
|
20
|
+
import { feed, feedPath } from '../src/feed.js';
|
|
21
|
+
import { includeContext } from '../src/include.js';
|
|
22
|
+
import { nodeLookup } from '../src/lookup.js';
|
|
23
|
+
import { sitemap } from '../src/sitemap.js';
|
|
24
|
+
import { etagOf, loadAssets, loadStatic } from '../src/static-cache.js';
|
|
25
|
+
import { PRECACHE_PATH, precacheDocument, precacheList } from '../src/precache.js';
|
|
26
|
+
import { cookiesOf } from '../src/cookies.js';
|
|
27
|
+
import { pool } from '../src/pool.js';
|
|
28
|
+
import { precompress } from '../src/compress.js';
|
|
29
|
+
|
|
30
|
+
const { root, config } = await loadProject();
|
|
31
|
+
const dist = path.join(root, config.outDir);
|
|
32
|
+
|
|
33
|
+
const plugin = transclude(config);
|
|
34
|
+
plugin.api.configure({ root });
|
|
35
|
+
const manifest = plugin.api.manifest();
|
|
36
|
+
|
|
37
|
+
fs.rmSync(dist, { recursive: true, force: true });
|
|
38
|
+
|
|
39
|
+
// ---- client ---------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
// `needed` is decided in the plugin, which is also what the dev server asks. Two
|
|
42
|
+
// copies of that rule is two servers that disagree about which pages ship JS.
|
|
43
|
+
const clientRoutes = [...manifest.routes, manifest.notFound, manifest.error]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.filter((route) => route.client.needed);
|
|
46
|
+
|
|
47
|
+
const assets = new Map();
|
|
48
|
+
|
|
49
|
+
// A prerendered page reads its sources once, here, and carries the result. That
|
|
50
|
+
// is the whole reason includes are resolved before the render rather than during
|
|
51
|
+
// it: a page written to a file cannot fetch anything later.
|
|
52
|
+
const include = includeContext({
|
|
53
|
+
config,
|
|
54
|
+
routes: manifest.routes ?? [],
|
|
55
|
+
pageFor: (id) => pages[id],
|
|
56
|
+
lookup: nodeLookup(),
|
|
57
|
+
});
|
|
58
|
+
let stylesheet = null;
|
|
59
|
+
|
|
60
|
+
const clientInput = Object.fromEntries(
|
|
61
|
+
clientRoutes.map((route) => [route.id, `virtual:transclude-client/${route.id}`]),
|
|
62
|
+
);
|
|
63
|
+
// The site stylesheet is an entry of its own, so Vite processes it and rollup
|
|
64
|
+
// hashes it. One cacheable file shared by every page.
|
|
65
|
+
if (config.stylesheet) clientInput.__global = path.join(root, config.stylesheet);
|
|
66
|
+
|
|
67
|
+
if (Object.keys(clientInput).length) {
|
|
68
|
+
const output = await build({
|
|
69
|
+
root,
|
|
70
|
+
logLevel: 'warn',
|
|
71
|
+
plugins: [transclude(config)],
|
|
72
|
+
// Copied once, here, into dist/public. Vite would otherwise put a copy in both
|
|
73
|
+
// the client and the SSR output, and neither is where it is served from.
|
|
74
|
+
publicDir: false,
|
|
75
|
+
build: {
|
|
76
|
+
outDir: `${config.outDir}/client`,
|
|
77
|
+
emptyOutDir: true,
|
|
78
|
+
rollupOptions: { input: clientInput },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const chunks = (Array.isArray(output) ? output[0] : output).output;
|
|
83
|
+
for (const chunk of chunks) {
|
|
84
|
+
if (chunk.type === 'asset' && chunk.fileName.endsWith('.css')) {
|
|
85
|
+
stylesheet = `/${chunk.fileName}`;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (chunk.isEntry && chunk.name && chunk.name !== '__global') {
|
|
89
|
+
assets.set(chunk.name, `/${chunk.fileName}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- server ---------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
await build({
|
|
97
|
+
root,
|
|
98
|
+
logLevel: 'warn',
|
|
99
|
+
plugins: [transclude(config)],
|
|
100
|
+
publicDir: false,
|
|
101
|
+
build: {
|
|
102
|
+
outDir: `${config.outDir}/server`,
|
|
103
|
+
emptyOutDir: true,
|
|
104
|
+
// `ssr: true` rather than a path: Vite resolves a string entry against the
|
|
105
|
+
// project root before any plugin sees it, which a virtual id cannot survive.
|
|
106
|
+
ssr: true,
|
|
107
|
+
rollupOptions: {
|
|
108
|
+
input: { entry: 'virtual:transclude-server' },
|
|
109
|
+
output: { entryFileNames: '[name].js' },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---- prerender ------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const { pages } = await import(pathToFileURL(path.join(dist, 'server/entry.js')).href);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A static route has one URL. A dynamic route has as many as its `paths` export
|
|
120
|
+
* names, and none at all if it does not export one, in which case it stays a
|
|
121
|
+
* server render.
|
|
122
|
+
*
|
|
123
|
+
* `export const prerender = false` is the third case: a page whose output
|
|
124
|
+
* depends on something no build can know, which is almost always the query
|
|
125
|
+
* string. A prerendered file is one file for every URL that resolves to it, so
|
|
126
|
+
* `?q=` cannot change it.
|
|
127
|
+
*/
|
|
128
|
+
async function urlsFor(route) {
|
|
129
|
+
if (pages[route.id]?.prerender === false) return [];
|
|
130
|
+
if (!route.params.length) return [{ url: route.pattern, params: {} }];
|
|
131
|
+
|
|
132
|
+
const paths = pages[route.id]?.paths;
|
|
133
|
+
if (typeof paths !== 'function') return [];
|
|
134
|
+
|
|
135
|
+
const listed = (await paths()) ?? [];
|
|
136
|
+
return listed.map((params) => ({
|
|
137
|
+
url: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
|
|
138
|
+
params,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A prerendered file has no status and no headers. It is a file. So a loader
|
|
144
|
+
* that answered with a Response, or set a status other than 200, is saying this
|
|
145
|
+
* URL is not a page you can write down, and the build says so rather than
|
|
146
|
+
* writing a file that lies about it.
|
|
147
|
+
*/
|
|
148
|
+
async function render(route, { url, params }) {
|
|
149
|
+
const response = responseOf();
|
|
150
|
+
const ctx = {
|
|
151
|
+
url: `http://localhost${url}`,
|
|
152
|
+
params,
|
|
153
|
+
route: { id: route.id, pattern: route.pattern ?? '', path: url },
|
|
154
|
+
request: null,
|
|
155
|
+
fragment: null,
|
|
156
|
+
action: null,
|
|
157
|
+
response,
|
|
158
|
+
cookies: cookiesOf(null, response, config.cookieSecret),
|
|
159
|
+
absolute: absoluteFrom(config.metadataBase, null),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const html = await renderRoute(pages[route.id], ctx, {
|
|
163
|
+
clientEntry: assets.get(route.id) ?? null,
|
|
164
|
+
stylesheet,
|
|
165
|
+
csp: config.csp,
|
|
166
|
+
lang: config.lang,
|
|
167
|
+
include,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (html instanceof Response) {
|
|
171
|
+
throw new Error(`answered with ${html.status} instead of markup, so it cannot be prerendered`);
|
|
172
|
+
}
|
|
173
|
+
if (ctx.response.status !== 200) {
|
|
174
|
+
throw new Error(`answered ${ctx.response.status}, which no file can carry`);
|
|
175
|
+
}
|
|
176
|
+
// A file carries no headers either. A Set-Cookie or a Cache-Control written here
|
|
177
|
+
// would be thrown away, which is worse than being told.
|
|
178
|
+
const [header] = [...ctx.response.headers.keys()];
|
|
179
|
+
if (header) {
|
|
180
|
+
throw new Error(`set a ${header} header, which no file can carry`);
|
|
181
|
+
}
|
|
182
|
+
// Reading a cookie is what makes a page personal, and there is no request
|
|
183
|
+
// here to read one from. Whatever this file says about the reader is what a
|
|
184
|
+
// reader with no cookies would have seen, and every visitor gets that copy.
|
|
185
|
+
// A layout or an included route can do this without the page mentioning it,
|
|
186
|
+
// which is what makes it worth saying out loud.
|
|
187
|
+
if (ctx.cookies.personal) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`read a cookie, so it is different for each visitor and cannot be one file. ` +
|
|
190
|
+
`Give it \`export const prerender = false\`, or stop reading the cookie ` +
|
|
191
|
+
`here or in what it includes`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return html;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const write = (relative, html) => {
|
|
198
|
+
const file = path.join(dist, 'static', relative);
|
|
199
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
200
|
+
fs.writeFileSync(file, html);
|
|
201
|
+
return relative;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Ask every route for its URLs first, so the render pass is one flat list and
|
|
205
|
+
// can run wide rather than route by route.
|
|
206
|
+
const dynamic = [];
|
|
207
|
+
const targets = [];
|
|
208
|
+
|
|
209
|
+
for (const route of manifest.routes) {
|
|
210
|
+
const urls = await urlsFor(route);
|
|
211
|
+
if (!urls.length) {
|
|
212
|
+
dynamic.push(route);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
for (const target of urls) targets.push({ route, target });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (manifest.notFound) {
|
|
219
|
+
targets.push({
|
|
220
|
+
route: { ...manifest.notFound, pattern: '' },
|
|
221
|
+
target: { url: '/404', params: {} },
|
|
222
|
+
file: '404.html',
|
|
223
|
+
label: '404.html (not-found page)',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (manifest.error) {
|
|
228
|
+
targets.push({
|
|
229
|
+
route: { ...manifest.error, pattern: '' },
|
|
230
|
+
target: { url: '/500', params: {} },
|
|
231
|
+
file: '500.html',
|
|
232
|
+
label: '500.html (error page)',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const CONCURRENCY = Number(process.env.TRANSCLUDE_BUILD_CONCURRENCY ?? 8);
|
|
237
|
+
|
|
238
|
+
const outcomes = await pool(targets, CONCURRENCY, async ({ route, target, file, label }) => {
|
|
239
|
+
const rel =
|
|
240
|
+
file ?? (target.url === '/' ? 'index.html' : `${target.url.replace(/^\//, '')}/index.html`);
|
|
241
|
+
try {
|
|
242
|
+
write(rel, await render(route, target));
|
|
243
|
+
return { url: label ?? target.url, ok: true };
|
|
244
|
+
} catch (err) {
|
|
245
|
+
// One bad page should not take the build down without saying which.
|
|
246
|
+
return { url: target.url, ok: false, error: err };
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const failures = outcomes.filter((outcome) => !outcome.ok);
|
|
251
|
+
const prerendered = outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.url);
|
|
252
|
+
|
|
253
|
+
// A file, like every other page. The served route answers the same document, but
|
|
254
|
+
// `dist/static` is meant to be servable by a host that runs none of this, and a
|
|
255
|
+
// site with no sitemap there would be missing one only on the host that needs it
|
|
256
|
+
// written down most.
|
|
257
|
+
if (config.sitemap) {
|
|
258
|
+
write('sitemap.xml', await sitemap({ routes: manifest.routes }, pages, config.sitemap));
|
|
259
|
+
prerendered.push('/sitemap.xml');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (config.feed) {
|
|
263
|
+
const at = feedPath(config.feed);
|
|
264
|
+
write(at.replace(/^\//, ''), await feed(config.feed));
|
|
265
|
+
prerendered.push(at);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (failures.length) {
|
|
269
|
+
console.error(`\n${failures.length} page${failures.length === 1 ? '' : 's'} failed to render:`);
|
|
270
|
+
for (const failure of failures) {
|
|
271
|
+
console.error(` ${failure.url}\n ${failure.error.message}`);
|
|
272
|
+
}
|
|
273
|
+
process.exitCode = 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fs.writeFileSync(
|
|
277
|
+
path.join(dist, 'routes.json'),
|
|
278
|
+
JSON.stringify(
|
|
279
|
+
{
|
|
280
|
+
dynamic: dynamic.map((route) => ({
|
|
281
|
+
id: route.id,
|
|
282
|
+
pattern: route.pattern,
|
|
283
|
+
params: route.params,
|
|
284
|
+
client: assets.get(route.id) ?? null,
|
|
285
|
+
})),
|
|
286
|
+
// Every route, prerendered or not. A fragment is rendered on demand even
|
|
287
|
+
// where the document it belongs to was written to a file at build time. Its
|
|
288
|
+
// data is a request away, and its URL is a query on the same path.
|
|
289
|
+
routes: manifest.routes.map((route) => ({
|
|
290
|
+
id: route.id,
|
|
291
|
+
pattern: route.pattern,
|
|
292
|
+
params: route.params,
|
|
293
|
+
client: assets.get(route.id) ?? null,
|
|
294
|
+
})),
|
|
295
|
+
// Never prerendered: an endpoint answers with a Response, and a file
|
|
296
|
+
// cannot carry one.
|
|
297
|
+
endpoints: manifest.endpoints.map((route) => ({
|
|
298
|
+
id: route.id,
|
|
299
|
+
pattern: route.pattern,
|
|
300
|
+
params: route.params,
|
|
301
|
+
})),
|
|
302
|
+
notFound: manifest.notFound ? { id: manifest.notFound.id } : null,
|
|
303
|
+
error: manifest.error ? { id: manifest.error.id } : null,
|
|
304
|
+
stylesheet,
|
|
305
|
+
},
|
|
306
|
+
null,
|
|
307
|
+
2,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// ---- public ---------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
const publicSrc = config.publicDir
|
|
314
|
+
? path.join(root, config.appDir, config.publicDir)
|
|
315
|
+
: null;
|
|
316
|
+
const publicOut = path.join(dist, 'public');
|
|
317
|
+
let publicFiles = 0;
|
|
318
|
+
|
|
319
|
+
if (publicSrc && fs.existsSync(publicSrc)) {
|
|
320
|
+
fs.cpSync(publicSrc, publicOut, { recursive: true });
|
|
321
|
+
publicFiles = countFiles(publicOut);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function countFiles(dir) {
|
|
325
|
+
let total = 0;
|
|
326
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
327
|
+
total += entry.isDirectory() ? countFiles(path.join(dir, entry.name)) : 1;
|
|
328
|
+
}
|
|
329
|
+
return total;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- assets, for runtimes with no filesystem ------------------------------
|
|
333
|
+
//
|
|
334
|
+
// The Node server reads `dist` off a disk. A worker cannot, so the same bytes are
|
|
335
|
+
// emitted as a module it can import. Plain bytes only, with no `.br` or `.gz`. An
|
|
336
|
+
// edge runtime compresses for you, and shipping three copies of every file into a
|
|
337
|
+
// bundle with a size limit is the wrong trade.
|
|
338
|
+
|
|
339
|
+
function assetModule() {
|
|
340
|
+
const encode = (map, urlFor) => {
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const [url, entry] of map) {
|
|
343
|
+
const body = entry.body ?? fs.readFileSync(entry.file);
|
|
344
|
+
out.push(
|
|
345
|
+
` ${JSON.stringify(urlFor ? urlFor(url) : url)}: ` +
|
|
346
|
+
`{ type: ${JSON.stringify(entry.type)}, body: ${JSON.stringify(body.toString('base64'))} },`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return `{\n${out.join('\n')}\n}`;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const publicMap = new Map();
|
|
353
|
+
if (fs.existsSync(publicOut)) {
|
|
354
|
+
for (const file of walkAll(publicOut)) {
|
|
355
|
+
if (file.endsWith('.br') || file.endsWith('.gz')) continue;
|
|
356
|
+
const url = '/' + path.relative(publicOut, file).split(path.sep).join('/');
|
|
357
|
+
publicMap.set(url, { body: fs.readFileSync(file), type: mimeOf(file) });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const page = (name) => {
|
|
362
|
+
const file = path.join(dist, 'static', name);
|
|
363
|
+
if (!fs.existsSync(file)) return 'null';
|
|
364
|
+
return (
|
|
365
|
+
`{ type: "text/html; charset=utf-8", ` +
|
|
366
|
+
`body: ${JSON.stringify(fs.readFileSync(file).toString('base64'))} }`
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
return `// Generated by \`npm run build\`. Bytes for a runtime that cannot read a disk.
|
|
371
|
+
export const statics = ${encode(loadStatic(path.join(dist, 'static')).entries)};
|
|
372
|
+
|
|
373
|
+
export const assets = ${encode(loadAssets(path.join(dist, 'client')).entries)};
|
|
374
|
+
|
|
375
|
+
export const publicFiles = ${encode(publicMap)};
|
|
376
|
+
|
|
377
|
+
export const precache = ${precacheJson === null ? 'null' : JSON.stringify(precacheJson)};
|
|
378
|
+
|
|
379
|
+
export const notFound = ${page('404.html')};
|
|
380
|
+
export const errorPage = ${page('500.html')};
|
|
381
|
+
`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function walkAll(dir, out = []) {
|
|
385
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
386
|
+
const full = path.join(dir, entry.name);
|
|
387
|
+
if (entry.isDirectory()) walkAll(full, out);
|
|
388
|
+
else out.push(full);
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const MIMES = {
|
|
394
|
+
'.html': 'text/html; charset=utf-8',
|
|
395
|
+
'.css': 'text/css; charset=utf-8',
|
|
396
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
397
|
+
'.json': 'application/json; charset=utf-8',
|
|
398
|
+
'.svg': 'image/svg+xml',
|
|
399
|
+
'.ico': 'image/x-icon',
|
|
400
|
+
'.png': 'image/png',
|
|
401
|
+
'.jpg': 'image/jpeg',
|
|
402
|
+
'.webp': 'image/webp',
|
|
403
|
+
'.woff2': 'font/woff2',
|
|
404
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
405
|
+
};
|
|
406
|
+
const mimeOf = (file) => MIMES[path.extname(file)] ?? 'application/octet-stream';
|
|
407
|
+
|
|
408
|
+
// ---- precache manifest ----------------------------------------------------
|
|
409
|
+
//
|
|
410
|
+
// Written before the asset module, so a runtime with no disk carries it too, and
|
|
411
|
+
// before compression, so it ships with a .br and a .gz like anything else.
|
|
412
|
+
|
|
413
|
+
let precacheJson = null;
|
|
414
|
+
if (config.precache) {
|
|
415
|
+
const files = fs.existsSync(publicOut)
|
|
416
|
+
? walkAll(publicOut)
|
|
417
|
+
.filter((file) => !file.endsWith('.br') && !file.endsWith('.gz'))
|
|
418
|
+
.map((file) => [
|
|
419
|
+
`/${path.relative(publicOut, file).split(path.sep).join('/')}`,
|
|
420
|
+
{ etag: etagOf(fs.readFileSync(file)) },
|
|
421
|
+
])
|
|
422
|
+
: [];
|
|
423
|
+
|
|
424
|
+
// Every entry read, so every one has an ETag. The default budget leaves a
|
|
425
|
+
// large file on disk with no hash, and `precacheList` refuses that rather than
|
|
426
|
+
// calling it immutable.
|
|
427
|
+
const entries = precacheList({
|
|
428
|
+
pages: loadStatic(path.join(dist, 'static'), { maxBytes: Infinity }).entries,
|
|
429
|
+
assets: loadAssets(path.join(dist, 'client'), { maxBytes: Infinity }).entries,
|
|
430
|
+
files,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
precacheJson = precacheDocument(entries, etagOf(JSON.stringify(entries)).replaceAll('"', ''));
|
|
434
|
+
write(PRECACHE_PATH.replace(/^\//, ''), precacheJson);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
fs.writeFileSync(path.join(dist, 'server/assets.js'), assetModule());
|
|
438
|
+
|
|
439
|
+
// ---- compress -------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
// `precompress` only touches extensions it knows are worth compressing, so a
|
|
442
|
+
// public directory full of images costs nothing here.
|
|
443
|
+
const compressed = await precompress([
|
|
444
|
+
path.join(dist, 'static'),
|
|
445
|
+
path.join(dist, 'client'),
|
|
446
|
+
publicOut,
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
const summary = [
|
|
450
|
+
`${prerendered.length} page${prerendered.length === 1 ? '' : 's'} prerendered`,
|
|
451
|
+
`${CONCURRENCY} at a time`,
|
|
452
|
+
`${dynamic.length} route${dynamic.length === 1 ? '' : 's'} left to the server`,
|
|
453
|
+
`${assets.size} client entr${assets.size === 1 ? 'y' : 'ies'}`,
|
|
454
|
+
...(publicFiles ? [`${publicFiles} public file${publicFiles === 1 ? '' : 's'}`] : []),
|
|
455
|
+
];
|
|
456
|
+
console.log(`\n${summary.join(', ')}`);
|
|
457
|
+
for (const url of prerendered) console.log(` ${url}`);
|
|
458
|
+
for (const route of dynamic) console.log(` ${route.pattern} (server-rendered)`);
|
|
459
|
+
|
|
460
|
+
if (compressed.files) {
|
|
461
|
+
const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
|
|
462
|
+
const pct = (n) => `${Math.round((1 - n / compressed.raw) * 100)}%`;
|
|
463
|
+
console.log(
|
|
464
|
+
`\n${compressed.files} files precompressed: ${kb(compressed.raw)} raw, ` +
|
|
465
|
+
`${kb(compressed.gzip)} gzip (−${pct(compressed.gzip)}), ` +
|
|
466
|
+
`${kb(compressed.brotli)} brotli (−${pct(compressed.brotli)})`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (!dynamic.length) console.log('\ndist/static is self-contained. Any static host will serve it.');
|
package/bin/check.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npm run check`. Type checks every .html file through TypeScript.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import ts from 'typescript';
|
|
7
|
+
import { createChecker, positionAt } from '../src/typecheck.js';
|
|
8
|
+
import { emitTypes } from '../src/compiler/types.js';
|
|
9
|
+
import { loadProject } from '../src/project.js';
|
|
10
|
+
|
|
11
|
+
const { root, config } = await loadProject();
|
|
12
|
+
const checker = createChecker({ root, ...config });
|
|
13
|
+
|
|
14
|
+
// transclude-env.d.ts is an output, not an input: the shims are self-contained, so the
|
|
15
|
+
// types can be written from what tsc made of them rather than the other way
|
|
16
|
+
// round. Nothing downstream reads it. It exists for the author and the editor.
|
|
17
|
+
const types = path.join(root, config.typesFile);
|
|
18
|
+
const next = emitTypes(checker.describe());
|
|
19
|
+
if (!fs.existsSync(types) || fs.readFileSync(types, 'utf8') !== next) {
|
|
20
|
+
fs.writeFileSync(types, next);
|
|
21
|
+
console.log(`wrote ${path.relative(root, types)}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Nothing downstream reads this file, so nothing else would notice it being
|
|
25
|
+
// wrong. Parse what we just wrote, or a bad identifier ships silently.
|
|
26
|
+
const emitted = ts.createProgram([types], { noEmit: true, skipLibCheck: true });
|
|
27
|
+
const broken = [
|
|
28
|
+
...emitted.getSyntacticDiagnostics(),
|
|
29
|
+
...emitted.getSemanticDiagnostics(),
|
|
30
|
+
];
|
|
31
|
+
if (broken.length) {
|
|
32
|
+
console.error(`\n${path.relative(root, types)} is not valid TypeScript:`);
|
|
33
|
+
for (const diagnostic of broken.slice(0, 5)) {
|
|
34
|
+
const at = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
|
|
35
|
+
console.error(
|
|
36
|
+
` ${at ? `line ${at.line + 1}: ` : ''}${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (broken.length > 5) console.error(` …and ${broken.length - 5} more`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const files = checker.files();
|
|
43
|
+
|
|
44
|
+
let errors = 0;
|
|
45
|
+
let warnings = 0;
|
|
46
|
+
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const diagnostics = checker.check(file);
|
|
49
|
+
if (!diagnostics.length) continue;
|
|
50
|
+
|
|
51
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
52
|
+
const lines = source.split('\n');
|
|
53
|
+
const relative = path.relative(root, file);
|
|
54
|
+
|
|
55
|
+
for (const diagnostic of diagnostics) {
|
|
56
|
+
const { line, column } = positionAt(source, diagnostic.offset);
|
|
57
|
+
if (diagnostic.severity === 'error') errors++;
|
|
58
|
+
else warnings++;
|
|
59
|
+
|
|
60
|
+
console.log(`\n${relative}:${line}:${column + 1} ${diagnostic.severity} TS${diagnostic.code}`);
|
|
61
|
+
console.log(` ${diagnostic.message}`);
|
|
62
|
+
|
|
63
|
+
const text = lines[line - 1] ?? '';
|
|
64
|
+
const trimmed = text.replace(/^\s+/, '');
|
|
65
|
+
const shift = text.length - trimmed.length;
|
|
66
|
+
console.log(`\n ${trimmed}`);
|
|
67
|
+
console.log(` ${' '.repeat(Math.max(0, column - shift))}${'~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)))}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const total = errors + warnings;
|
|
72
|
+
console.log(
|
|
73
|
+
total
|
|
74
|
+
? `\n${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} in ${files.length} files`
|
|
75
|
+
: `\nNo type errors in ${files.length} files.`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
process.exitCode = errors ? 1 : 0;
|