@transclude/core 0.10.2 → 0.11.1
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 +4 -0
- package/bin/build.js +28 -7
- package/bin/check.js +10 -2
- package/bin/dev.js +4 -2
- package/bin/release.js +80 -1
- package/package.json +3 -2
- package/skills/transclude/SKILL.md +48 -2
- package/src/compiler/codegen.js +6 -1
- package/src/compiler/interp.js +3 -2
- package/src/defaults.js +5 -0
- package/src/gate.js +70 -0
- package/src/markdown.js +52 -0
- package/src/plugin.js +16 -4
- package/src/routes.js +12 -2
- package/src/sitemap.js +8 -0
- package/src/typecheck.js +26 -8
package/README.md
CHANGED
|
@@ -86,6 +86,9 @@ so a swap cannot drift from the page it replaces part of.
|
|
|
86
86
|
Light DOM by default: no boundary, page CSS reaches it, `<label for>` works,
|
|
87
87
|
and it ships no JavaScript. `export const shadow = true` opts into a shadow
|
|
88
88
|
root and a re-render on an attribute change.
|
|
89
|
+
- **Markdown pages.** A `.md` file under `app/routes/` is a page, with the same
|
|
90
|
+
loader block, elements and type checking an `.html` one has. Set `markdown` in
|
|
91
|
+
the config to a function from source to HTML; this package ships no parser.
|
|
89
92
|
- **Types without writing TypeScript.** `npm run check` catches a misspelled
|
|
90
93
|
field, an unknown prop and a wrong-typed one, from the shapes your loaders
|
|
91
94
|
return. Annotations are optional.
|
|
@@ -129,6 +132,7 @@ npm run includes # transclusion on http://localhost:1966
|
|
|
129
132
|
npm run auth # a guarded section on http://localhost:1967
|
|
130
133
|
npm run live # server-sent events on http://localhost:1968
|
|
131
134
|
npm run elements # light and shadow elements on http://localhost:1969
|
|
135
|
+
npm run markdown # Markdown pages on http://localhost:1970
|
|
132
136
|
npm run check:src # type-check the framework itself
|
|
133
137
|
```
|
|
134
138
|
|
package/bin/build.js
CHANGED
|
@@ -18,6 +18,7 @@ import transclude from '../src/plugin.js';
|
|
|
18
18
|
import { loadProject } from '../src/project.js';
|
|
19
19
|
import { renderRoute, urlFor } from '../src/document.js';
|
|
20
20
|
import { prerenderContext, refusePrerender } from '../src/prerender.js';
|
|
21
|
+
import { isGated, readGated } from '../src/gate.js';
|
|
21
22
|
import { feed, feedPath } from '../src/feed.js';
|
|
22
23
|
import { includeContext } from '../src/include.js';
|
|
23
24
|
import { nodeLookup } from '../src/lookup.js';
|
|
@@ -121,7 +122,20 @@ fs.writeFileSync(entry, `// @ts-nocheck\n${fs.readFileSync(entry, 'utf8')}`);
|
|
|
121
122
|
|
|
122
123
|
// ---- prerender ------------------------------------------------------------
|
|
123
124
|
|
|
124
|
-
const { pages } = await import(pathToFileURL(entry).href);
|
|
125
|
+
const { pages, gated: declared } = await import(pathToFileURL(entry).href);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Paths `app/server.js` says are not public, and the routes they cover.
|
|
129
|
+
*
|
|
130
|
+
* Middleware does not run during a build, so nothing here can tell a payment
|
|
131
|
+
* gate or an auth check from an open page. Without the declaration a gated page
|
|
132
|
+
* is written to `dist/static` and served by any static host that finds it, and
|
|
133
|
+
* the build reports it as a page it prerendered.
|
|
134
|
+
*
|
|
135
|
+
* An entry matching nothing is a typo, and a typo here fails open, so it is an
|
|
136
|
+
* error rather than a shrug.
|
|
137
|
+
*/
|
|
138
|
+
const gated = readGated(declared);
|
|
125
139
|
|
|
126
140
|
// ---- drafts ---------------------------------------------------------------
|
|
127
141
|
|
|
@@ -158,16 +172,19 @@ manifest.routes = manifest.routes.filter((route) => !isDraft(route));
|
|
|
158
172
|
*/
|
|
159
173
|
async function urlsFor(route) {
|
|
160
174
|
if (pages[route.id]?.prerender === false) return [];
|
|
161
|
-
if (!route.params.length)
|
|
175
|
+
if (!route.params.length) {
|
|
176
|
+
return isGated(route.pattern, gated) ? [] : [{ url: route.pattern, params: {} }];
|
|
177
|
+
}
|
|
162
178
|
|
|
163
179
|
const paths = pages[route.id]?.paths;
|
|
164
180
|
if (typeof paths !== 'function') return [];
|
|
165
181
|
|
|
166
182
|
const listed = (await paths()) ?? [];
|
|
167
|
-
return listed
|
|
168
|
-
url: urlFor(route, params),
|
|
169
|
-
|
|
170
|
-
|
|
183
|
+
return listed
|
|
184
|
+
.map((params) => ({ url: urlFor(route, params), params }))
|
|
185
|
+
// Matched per URL, not per route: `/notes/[id]` can be open while
|
|
186
|
+
// `/notes/secret` is not, and the pattern is the same for both.
|
|
187
|
+
.filter(({ url }) => !isGated(url, gated));
|
|
171
188
|
}
|
|
172
189
|
|
|
173
190
|
/**
|
|
@@ -284,7 +301,7 @@ const prerendered = outcomes.filter((outcome) => outcome.ok).map((outcome) => ou
|
|
|
284
301
|
// site with no sitemap there would be missing one only on the host that needs it
|
|
285
302
|
// written down most.
|
|
286
303
|
if (config.sitemap) {
|
|
287
|
-
write('sitemap.xml', await sitemap({ routes: manifest.routes }, pages, config.sitemap));
|
|
304
|
+
write('sitemap.xml', await sitemap({ routes: manifest.routes, gated }, pages, config.sitemap));
|
|
288
305
|
prerendered.push('/sitemap.xml');
|
|
289
306
|
}
|
|
290
307
|
|
|
@@ -306,6 +323,10 @@ fs.writeFileSync(
|
|
|
306
323
|
path.join(dist, 'routes.json'),
|
|
307
324
|
JSON.stringify(
|
|
308
325
|
{
|
|
326
|
+
// Carried so `/sitemap.xml` at runtime leaves out what the build left out.
|
|
327
|
+
// The gate itself is `app/server.js` middleware, which runs at runtime and
|
|
328
|
+
// needs no help from here.
|
|
329
|
+
gated,
|
|
309
330
|
dynamic: dynamic.map((route) => ({
|
|
310
331
|
id: route.id,
|
|
311
332
|
pattern: route.pattern,
|
package/bin/check.js
CHANGED
|
@@ -7,6 +7,7 @@ import ts from 'typescript';
|
|
|
7
7
|
import { createChecker, positionAt } from '../src/typecheck.js';
|
|
8
8
|
import { emitTypes } from '../src/compiler/types.js';
|
|
9
9
|
import { loadProject } from '../src/project.js';
|
|
10
|
+
import { isMarkdown } from '../src/markdown.js';
|
|
10
11
|
|
|
11
12
|
const { root, config } = await loadProject();
|
|
12
13
|
const checker = createChecker({ root, ...config });
|
|
@@ -63,16 +64,23 @@ for (const file of files) {
|
|
|
63
64
|
const diagnostics = checker.check(file);
|
|
64
65
|
if (!diagnostics.length) continue;
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
// What the checker measured, not what is on disk. They are the same file for
|
|
68
|
+
// an `.html` page. For a Markdown page they are not, and reading disk here put
|
|
69
|
+
// a caret under an unrelated word several lines from the mistake.
|
|
70
|
+
const source = checker.sourceFor(file);
|
|
67
71
|
const lines = source.split('\n');
|
|
68
72
|
const relative = path.relative(root, file);
|
|
73
|
+
const converted = isMarkdown(file);
|
|
69
74
|
|
|
70
75
|
for (const diagnostic of diagnostics) {
|
|
71
76
|
const { line, column } = positionAt(source, diagnostic.offset);
|
|
72
77
|
if (diagnostic.severity === 'error') errors++;
|
|
73
78
|
else warnings++;
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
// Said plainly rather than left to be worked out. The line and column are
|
|
81
|
+
// real, and they are not positions in the file the author opens.
|
|
82
|
+
const where = converted ? `${relative} (converted HTML, line ${line})` : `${relative}:${line}:${column + 1}`;
|
|
83
|
+
console.log(`\n${where} ${diagnostic.severity} TS${diagnostic.code}`);
|
|
76
84
|
console.log(` ${diagnostic.message}`);
|
|
77
85
|
|
|
78
86
|
const text = lines[line - 1] ?? '';
|
package/bin/dev.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from '../src/document.js';
|
|
24
24
|
import transclude, { clientEntryUrl, pageModuleId } from '../src/plugin.js';
|
|
25
25
|
import { resolveRoutesDir, scanRoutes } from '../src/routes.js';
|
|
26
|
+
import { MARKDOWN_EXT } from '../src/markdown.js';
|
|
26
27
|
import { baseApp, endpointMethods, runEndpoint, SERVER_FILE } from '../src/server.js';
|
|
27
28
|
import { randomBytes } from 'node:crypto';
|
|
28
29
|
import { cookiesOf } from '../src/cookies.js';
|
|
@@ -447,11 +448,12 @@ let app = await buildApp();
|
|
|
447
448
|
// Adding or removing a page changes the route table, not just a module.
|
|
448
449
|
vite.watcher.on('all', async (event, file) => {
|
|
449
450
|
// `.js` as well as `.html`: an endpoint is a route too, and watching only for
|
|
450
|
-
// pages meant adding one needed a restart, with a 404 as the only hint.
|
|
451
|
+
// pages meant adding one needed a restart, with a 404 as the only hint. `.md`
|
|
452
|
+
// for the same reason: a Markdown page is a page.
|
|
451
453
|
const extension = path.extname(file);
|
|
452
454
|
const routing =
|
|
453
455
|
file.startsWith(routesDir) &&
|
|
454
|
-
(extension === '.html' || extension === '.js') &&
|
|
456
|
+
(extension === '.html' || extension === MARKDOWN_EXT || extension === '.js') &&
|
|
455
457
|
event !== 'change';
|
|
456
458
|
// Middleware is registered once when the app is built, so a change to it needs
|
|
457
459
|
// the app rebuilt, unlike a page, which is loaded per request.
|
package/bin/release.js
CHANGED
|
@@ -111,6 +111,85 @@ function setVersion(version) {
|
|
|
111
111
|
manifest.version = version;
|
|
112
112
|
fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
113
113
|
}
|
|
114
|
+
setLockVersions();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every lockfile that names this package, brought to what `package.json` says.
|
|
119
|
+
*
|
|
120
|
+
* This used to write the two manifests and stop. `npm install` does not run in a
|
|
121
|
+
* release, so nothing else ever corrected them: the root lockfile said 0.2.0
|
|
122
|
+
* against a package.json of 0.10.2, and the apps carried linked entries from
|
|
123
|
+
* 0.1.0, 0.1.1, 0.2.0 and 0.8.2. Eight releases of drift, and no test looked.
|
|
124
|
+
*
|
|
125
|
+
* Done here rather than by `npm install --package-lock-only`, which also
|
|
126
|
+
* rewrites the dependency tree from the registry. A release is not the place to
|
|
127
|
+
* find out that a transitive dependency moved.
|
|
128
|
+
*/
|
|
129
|
+
function setLockVersions() {
|
|
130
|
+
const core = read('package.json');
|
|
131
|
+
|
|
132
|
+
// npm writes bin paths without the leading `./`.
|
|
133
|
+
const bin = Object.fromEntries(
|
|
134
|
+
Object.entries(core.bin).map(([name, file]) => [name, file.replace(/^\.\//, '')]),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// The fields npm copies into a link entry, in the order it writes them.
|
|
138
|
+
const linked = {
|
|
139
|
+
name: core.name,
|
|
140
|
+
version: core.version,
|
|
141
|
+
license: core.license,
|
|
142
|
+
dependencies: core.dependencies,
|
|
143
|
+
bin,
|
|
144
|
+
devDependencies: core.devDependencies,
|
|
145
|
+
engines: core.engines,
|
|
146
|
+
peerDependencies: core.peerDependencies,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
for (const [rel, key] of lockfiles()) {
|
|
150
|
+
const file = path.join(root, rel);
|
|
151
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
152
|
+
const lock = JSON.parse(text);
|
|
153
|
+
|
|
154
|
+
// A file npm wrote differently is left alone rather than reformatted. The
|
|
155
|
+
// alternative is a release whose diff is every lockfile in the repository.
|
|
156
|
+
if (`${JSON.stringify(lock, null, 2)}\n` !== text) {
|
|
157
|
+
process.stdout.write(` ${rel}: npm formats this differently, left alone\n`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (key === '') {
|
|
162
|
+
lock.name = core.name;
|
|
163
|
+
lock.version = core.version;
|
|
164
|
+
lock.packages[''].name = core.name;
|
|
165
|
+
lock.packages[''].version = core.version;
|
|
166
|
+
} else if (lock.packages[key]) {
|
|
167
|
+
lock.packages[key] = linked;
|
|
168
|
+
} else {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fs.writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `[relative path, the key naming this package]` for every lockfile here. */
|
|
177
|
+
function lockfiles() {
|
|
178
|
+
const found = [['package-lock.json', '']];
|
|
179
|
+
|
|
180
|
+
if (fs.existsSync(path.join(root, 'www', 'package-lock.json'))) {
|
|
181
|
+
found.push(['www/package-lock.json', '..']);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const examples = path.join(root, 'examples');
|
|
185
|
+
if (fs.existsSync(examples)) {
|
|
186
|
+
for (const entry of fs.readdirSync(examples).sort()) {
|
|
187
|
+
const rel = `examples/${entry}/package-lock.json`;
|
|
188
|
+
if (fs.existsSync(path.join(root, rel))) found.push([rel, '../..']);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return found;
|
|
114
193
|
}
|
|
115
194
|
|
|
116
195
|
/** Everything, in the order that fails cheapest first. */
|
|
@@ -204,7 +283,7 @@ function main() {
|
|
|
204
283
|
return;
|
|
205
284
|
}
|
|
206
285
|
|
|
207
|
-
run('git', ['add', ...MANIFESTS]);
|
|
286
|
+
run('git', ['add', ...MANIFESTS, ...lockfiles().map(([rel]) => rel)]);
|
|
208
287
|
|
|
209
288
|
// A first release at the version the manifests already carry changes nothing,
|
|
210
289
|
// and an empty commit is not worth making. The tag is the release either way.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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 && npm test --prefix examples/elements",
|
|
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 && npm test --prefix examples/markdown",
|
|
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",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"auth": "npm run dev --prefix examples/auth",
|
|
69
69
|
"live": "npm run dev --prefix examples/live",
|
|
70
70
|
"elements": "npm run dev --prefix examples/elements",
|
|
71
|
+
"markdown": "npm run dev --prefix examples/markdown",
|
|
71
72
|
"www": "npm run dev --prefix www",
|
|
72
73
|
"check:src": "tsc -p tsconfig.src.json",
|
|
73
74
|
"release": "node bin/release.js"
|
|
@@ -98,6 +98,34 @@ Globals available in an expression: `html`, `json`, `Math`, `JSON`, `String`,
|
|
|
98
98
|
`absolute()`. Returning a `Response` from a loader answers the request and skips
|
|
99
99
|
the render, which is how a layout does a login redirect.
|
|
100
100
|
|
|
101
|
+
## Markdown pages
|
|
102
|
+
|
|
103
|
+
A `.md` file under `routes/` is a page. Set `markdown` in the config to a
|
|
104
|
+
function from source to HTML; this package ships no parser.
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
// transclude.config.js
|
|
108
|
+
import { Marked } from 'marked';
|
|
109
|
+
const marked = new Marked();
|
|
110
|
+
|
|
111
|
+
export default { markdown: (source, file) => marked.parse(source) };
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The loader is the same `<script server>` block, at the top of the `.md` file.
|
|
115
|
+
CommonMark passes a script block through raw, so there is no frontmatter. After
|
|
116
|
+
the conversion it is an ordinary page: interpolation, directives, elements,
|
|
117
|
+
fragments and type checking all work.
|
|
118
|
+
|
|
119
|
+
Two rules belong to Markdown rather than to this framework:
|
|
120
|
+
|
|
121
|
+
- **An HTML block ends at the next blank line, and Markdown inside one is not
|
|
122
|
+
parsed.** Put blank lines inside a block-level custom element or its content
|
|
123
|
+
arrives as literal asterisks.
|
|
124
|
+
- **`${` in a code fence would interpolate.** Escape `${` in code tokens in your
|
|
125
|
+
converter, or write `\${` by hand. `\${` is the escape and works in any page.
|
|
126
|
+
|
|
127
|
+
A diagnostic in a `.md` page reports a line in the converted HTML and says so.
|
|
128
|
+
|
|
101
129
|
## Forms and actions
|
|
102
130
|
|
|
103
131
|
A page responds to GET with its loader. Other verbs are named exports on the same
|
|
@@ -215,12 +243,17 @@ there reaches the page as written, so a value would land in code. `json(value)`
|
|
|
215
243
|
is the one way through, and only as the entire text of the script. For a style,
|
|
216
244
|
pass the value through a custom property, which is an attribute and is escaped.
|
|
217
245
|
|
|
218
|
-
**A literal `${`
|
|
219
|
-
|
|
246
|
+
**A literal `${` is written `\${`.** Passing the text in from the loader as data
|
|
247
|
+
also works, and is easier to read when a whole sample is full of them.
|
|
220
248
|
|
|
221
249
|
**Directive values are expressions, not interpolations.** Write
|
|
222
250
|
`each="note of notes"`, never `each="${notes}"`.
|
|
223
251
|
|
|
252
|
+
**A page's `<title>`, `<meta>`, `<link>` and `<base>` move into `<head>`.** Write
|
|
253
|
+
them next to the markup that needs them. The last three take `if` and `each`, and
|
|
254
|
+
the tag still lands in `<head>`. `<title>` takes neither directive: which level's
|
|
255
|
+
title wins is settled when the page compiles, not per request.
|
|
256
|
+
|
|
224
257
|
**A `fragment` element cannot carry `if`, `else` or `each`.** A fragment is one
|
|
225
258
|
element with one id, so it cannot be conditional or repeated. Put the condition
|
|
226
259
|
on something inside it.
|
|
@@ -244,6 +277,19 @@ element name. `card.html` is not, and the file is dropped.
|
|
|
244
277
|
**`<transclude>` has no self-closing form.** `<transclude src="#a" />` is read
|
|
245
278
|
as an open tag and the rest of the page becomes its fallback content.
|
|
246
279
|
|
|
280
|
+
**Middleware does not run during the build.** A page gated only by
|
|
281
|
+
`app/server.js` is prerendered to a file and served by any static host. Declare
|
|
282
|
+
the paths so the build knows:
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
// app/server.js
|
|
286
|
+
export const gated = ['/premium', '/api/*'];
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
No file is written for them and the sitemap leaves them out. They are still
|
|
290
|
+
routes. A layout guard needs no declaration: the build runs layout loaders, and
|
|
291
|
+
a guard reads a cookie.
|
|
292
|
+
|
|
247
293
|
**Reading a cookie makes a page personal.** It is then not cached and not
|
|
248
294
|
prerendered. Writing one does not do this; reading one does.
|
|
249
295
|
|
package/src/compiler/codegen.js
CHANGED
|
@@ -903,7 +903,12 @@ class Codegen {
|
|
|
903
903
|
const dynamic = parts.some((p) => p.type === 'expr');
|
|
904
904
|
|
|
905
905
|
if (!dynamic) {
|
|
906
|
-
|
|
906
|
+
// The parts, not `attr.value`. They are the same string except where a
|
|
907
|
+
// `\${` was escaped, and reading the raw value there put the backslash
|
|
908
|
+
// in the output: `title="\${name}"` in the source, `title="\${name}"` in
|
|
909
|
+
// the page. Text got this right and attributes did not.
|
|
910
|
+
const literal = parts.map((part) => part.value).join('');
|
|
911
|
+
this.s(out, literal === '' ? ` ${attr.name}` : ` ${attr.name}="${escapeAttr(literal)}"`);
|
|
907
912
|
continue;
|
|
908
913
|
}
|
|
909
914
|
this.c(
|
package/src/compiler/interp.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* Text and `${expr}` in source order.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* to
|
|
11
|
+
* `\${` is a literal `${`. It matters most to a page that documents this syntax,
|
|
12
|
+
* and to a Markdown page, whose code fences are full of shell and JavaScript
|
|
13
|
+
* that means `${` literally.
|
|
13
14
|
*
|
|
14
15
|
* @param {string} str raw text or an attribute value
|
|
15
16
|
* @returns {Part[]} empty only for an empty string
|
package/src/defaults.js
CHANGED
|
@@ -34,6 +34,11 @@ export const DEFAULTS = {
|
|
|
34
34
|
csrf: true,
|
|
35
35
|
csp: false,
|
|
36
36
|
speculate: false,
|
|
37
|
+
// `(source, file) => html`, and a `.md` page under `routes/` without one is an
|
|
38
|
+
// error naming the file. This package ships no Markdown parser: which flavor
|
|
39
|
+
// and which extensions are the app's to pick, the same way `cache` is a store
|
|
40
|
+
// the app supplies rather than a database this one depends on.
|
|
41
|
+
markdown: null,
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
/**
|
package/src/gate.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Paths an app says are not public, and whether a URL is one of them.
|
|
2
|
+
//
|
|
3
|
+
// Middleware does not run during a build, so nothing in `bin/build.js` can tell
|
|
4
|
+
// a payment gate or an auth check from an open page. `export const gated` in
|
|
5
|
+
// `app/server.js` is the only thing a build can read about a gate, and without
|
|
6
|
+
// it a gated page is written to `dist/static` and handed out by any static host,
|
|
7
|
+
// with the build reporting it as a page it prerendered.
|
|
8
|
+
//
|
|
9
|
+
// A layout guard is caught already, for a reason worth knowing: the build runs
|
|
10
|
+
// layout loaders, and a guard reads a cookie. Nothing runs `app/server.js` here.
|
|
11
|
+
//
|
|
12
|
+
// Pure. No `node:` imports: the sitemap reads this at runtime, on every runtime.
|
|
13
|
+
/**
|
|
14
|
+
* Whether a URL is one the app declared not public.
|
|
15
|
+
*
|
|
16
|
+
* `export const gated` in `app/server.js` is the only thing a build can read
|
|
17
|
+
* about a gate, because middleware does not run during one. A layout guard is
|
|
18
|
+
* caught already: the build runs layout loaders and a guard reads a cookie. A
|
|
19
|
+
* gate in `app/server.js` is run by nobody here, so a paid or signed-in page
|
|
20
|
+
* with an ordinary loader is written to `dist/static` and handed out by any
|
|
21
|
+
* static host, with the build reporting it as a success.
|
|
22
|
+
*
|
|
23
|
+
* `/premium` matches that path only. `/premium/*` matches it and everything
|
|
24
|
+
* under it. Nothing else is a pattern: this decides whether to write a file, and
|
|
25
|
+
* a rule nobody can read at a glance is the wrong shape for that.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} url the path the build is about to write
|
|
28
|
+
* @param {string[]} patterns
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function isGated(url, patterns = []) {
|
|
32
|
+
return patterns.some((pattern) => {
|
|
33
|
+
if (!pattern.endsWith('/*')) return url === pattern;
|
|
34
|
+
const base = pattern.slice(0, -2);
|
|
35
|
+
return url === base || url.startsWith(`${base}/`);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The declaration, or a refusal naming what is wrong with it.
|
|
41
|
+
*
|
|
42
|
+
* Checked rather than trusted, because every mistake here fails open. A typo
|
|
43
|
+
* matches nothing, the page is written, and the build says it prerendered a page
|
|
44
|
+
* that was supposed to need paying for.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} gated whatever `app/server.js` exported
|
|
47
|
+
* @returns {string[]}
|
|
48
|
+
* @throws when it is not a list of paths
|
|
49
|
+
*/
|
|
50
|
+
export function readGated(gated) {
|
|
51
|
+
if (gated === undefined || gated === null) return [];
|
|
52
|
+
|
|
53
|
+
if (!Array.isArray(gated)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[transclude] app/server.js exports "gated" as ${typeof gated}. It is a list of paths, ` +
|
|
56
|
+
`like ['/premium', '/api/*'].`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const entry of gated) {
|
|
61
|
+
if (typeof entry !== 'string' || !entry.startsWith('/')) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[transclude] "gated" in app/server.js has ${JSON.stringify(entry)}. ` +
|
|
64
|
+
`Every entry is a path beginning with "/", and "/*" at the end covers what is under it.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return gated;
|
|
70
|
+
}
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Markdown pages. A `.md` file under `routes/` is converted to HTML, and every
|
|
2
|
+
// step after that is the one an `.html` page takes.
|
|
3
|
+
//
|
|
4
|
+
// The framework ships no Markdown parser. `markdown` in the config is a function
|
|
5
|
+
// from source to HTML, the same way `cache` is a store with four methods: which
|
|
6
|
+
// flavor, which extensions and which highlighter are the app's to pick, and
|
|
7
|
+
// none of them becomes a dependency of this package.
|
|
8
|
+
//
|
|
9
|
+
// Three readers exist and they must not disagree: the plugin compiles a page,
|
|
10
|
+
// the type checker reads the same page to collect its names, and `npm run check`
|
|
11
|
+
// reads it again to place a diagnostic. All three come through here.
|
|
12
|
+
|
|
13
|
+
export const MARKDOWN_EXT = '.md';
|
|
14
|
+
|
|
15
|
+
/** @param {string} file @returns {boolean} */
|
|
16
|
+
export const isMarkdown = (file) => file.endsWith(MARKDOWN_EXT);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The source a compiler should see: HTML as written, Markdown converted.
|
|
20
|
+
*
|
|
21
|
+
* A `<script server>` block needs no new syntax to survive this. CommonMark
|
|
22
|
+
* starts an HTML block at `<script` and ends it at `</script>`, with everything
|
|
23
|
+
* between passed through raw, so the loader arrives at `splitBlocks` exactly as
|
|
24
|
+
* it would from an `.html` file.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} file the path, which decides whether anything happens
|
|
27
|
+
* @param {string} source what is on disk
|
|
28
|
+
* @param {((source: string, file: string) => string)|null} markdown from the config
|
|
29
|
+
* @returns {string} HTML
|
|
30
|
+
* @throws when a `.md` page exists and the config has no converter
|
|
31
|
+
*/
|
|
32
|
+
export function sourceOf(file, source, markdown) {
|
|
33
|
+
if (!isMarkdown(file)) return source;
|
|
34
|
+
|
|
35
|
+
if (typeof markdown !== 'function') {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`[transclude] ${file} is Markdown, and transclude.config.js sets no "markdown". ` +
|
|
38
|
+
'It takes (source, file) and returns HTML. This package ships no parser, ' +
|
|
39
|
+
'so the flavor is yours to choose.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const html = markdown(source, file);
|
|
44
|
+
if (typeof html !== 'string') {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[transclude] markdown() returned ${html === null ? 'null' : typeof html} for ${file}. ` +
|
|
47
|
+
'It has to return a string of HTML.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return html;
|
|
52
|
+
}
|
package/src/plugin.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
readFlags,
|
|
18
18
|
} from './compiler/index.js';
|
|
19
19
|
import { resolveRoutesDir, scanRoutes } from './routes.js';
|
|
20
|
+
import { MARKDOWN_EXT, sourceOf } from './markdown.js';
|
|
20
21
|
import { SERVER_FILE } from './server.js';
|
|
21
22
|
|
|
22
23
|
const P_COMPONENT = 'virtual:transclude-component/';
|
|
@@ -34,11 +35,17 @@ export default function transclude({
|
|
|
34
35
|
routesDir = 'routes',
|
|
35
36
|
fragmentParam = 'fragment',
|
|
36
37
|
watchElements = false,
|
|
38
|
+
markdown = null,
|
|
37
39
|
} = {}) {
|
|
38
40
|
// Off unless asked for. It puts a script on every page, and it only earns that
|
|
39
41
|
// when swapped-in markup names an element the page did not already render.
|
|
40
42
|
// A page that renders its own elements defines them without this.
|
|
41
43
|
const watching = watchElements === true;
|
|
44
|
+
|
|
45
|
+
// Every compile in this file goes through here, so a `.md` page reaches the
|
|
46
|
+
// compiler as HTML and nothing downstream learns a second format.
|
|
47
|
+
const read = (file) => sourceOf(file, readRaw(file), markdown);
|
|
48
|
+
|
|
42
49
|
let root;
|
|
43
50
|
let app;
|
|
44
51
|
let runtime;
|
|
@@ -282,7 +289,7 @@ export default function transclude({
|
|
|
282
289
|
return `
|
|
283
290
|
${ids.map((pageId, i) => `import * as __P${i} from ${JSON.stringify(`${P_PAGE}${pageId}`)};`).join('\n')}
|
|
284
291
|
${apiIds.map((apiId, i) => `import * as __E${i} from ${apiSpec(endpoints.get(apiId))};`).join('\n')}
|
|
285
|
-
${hasMiddleware ? `import
|
|
292
|
+
${hasMiddleware ? `import * as __server from ${JSON.stringify(specifier)};` : ''}
|
|
286
293
|
|
|
287
294
|
export const pages = {
|
|
288
295
|
${ids.map((pageId, i) => ` ${JSON.stringify(pageId)}: __P${i},`).join('\n')}
|
|
@@ -292,7 +299,12 @@ export const endpoints = {
|
|
|
292
299
|
${apiIds.map((apiId, i) => ` ${JSON.stringify(apiId)}: __E${i},`).join('\n')}
|
|
293
300
|
};
|
|
294
301
|
|
|
295
|
-
export const middleware = ${hasMiddleware ? '
|
|
302
|
+
export const middleware = ${hasMiddleware ? '__server.default ?? null' : 'null'};
|
|
303
|
+
|
|
304
|
+
// Paths the app says are not public. The build reads this and writes no file for
|
|
305
|
+
// them, because middleware does not run during a build and a file it was meant
|
|
306
|
+
// to gate would be served by any static host.
|
|
307
|
+
export const gated = ${hasMiddleware ? '__server.gated ?? []' : '[]'};
|
|
296
308
|
`;
|
|
297
309
|
}
|
|
298
310
|
|
|
@@ -370,7 +382,7 @@ export const middleware = ${hasMiddleware ? '__middleware ?? null' : 'null'};
|
|
|
370
382
|
if (duplicate) return;
|
|
371
383
|
|
|
372
384
|
server.watcher.on('all', (_event, file) => {
|
|
373
|
-
if (!file.endsWith('.html')) return;
|
|
385
|
+
if (!file.endsWith('.html') && !file.endsWith(MARKDOWN_EXT)) return;
|
|
374
386
|
if (!file.startsWith(app)) return;
|
|
375
387
|
|
|
376
388
|
scan();
|
|
@@ -429,7 +441,7 @@ function readDir(dir) {
|
|
|
429
441
|
return map;
|
|
430
442
|
}
|
|
431
443
|
|
|
432
|
-
function
|
|
444
|
+
function readRaw(file) {
|
|
433
445
|
return fs.readFileSync(file, 'utf8');
|
|
434
446
|
}
|
|
435
447
|
|
package/src/routes.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// routes/index.html -> /
|
|
6
6
|
// routes/about.html -> /about
|
|
7
|
+
// routes/notes.md -> /notes, Markdown converted before it compiles
|
|
7
8
|
// routes/blog/index.html -> /blog
|
|
8
9
|
// routes/blog/[slug].html -> /blog/:slug
|
|
9
10
|
// routes/docs/[...path].html -> /docs/:path{.+}
|
|
@@ -15,7 +16,16 @@
|
|
|
15
16
|
import fs from 'node:fs';
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
|
|
19
|
+
import { MARKDOWN_EXT } from './markdown.js';
|
|
20
|
+
|
|
18
21
|
const EXT = '.html';
|
|
22
|
+
/**
|
|
23
|
+
* A page can also be Markdown, converted to HTML before anything compiles it.
|
|
24
|
+
* The route table does not care which it was: `about.md` and `about.html` both
|
|
25
|
+
* answer `/about`, and a directory holding one of each is the collision below
|
|
26
|
+
* rather than a rule of its own.
|
|
27
|
+
*/
|
|
28
|
+
const PAGE_EXTS = [EXT, MARKDOWN_EXT];
|
|
19
29
|
/**
|
|
20
30
|
* A `.js` file in the routes tree is an endpoint: a route with no template, no
|
|
21
31
|
* layout and no regions, which answers with a `Response` of its own. Same
|
|
@@ -81,7 +91,7 @@ export function scanRoutes(dir) {
|
|
|
81
91
|
*/
|
|
82
92
|
export function toRoute(rel, file) {
|
|
83
93
|
const kind = rel.endsWith(ENDPOINT_EXT) ? 'endpoint' : 'page';
|
|
84
|
-
const ext = kind === 'endpoint' ? ENDPOINT_EXT : EXT;
|
|
94
|
+
const ext = kind === 'endpoint' ? ENDPOINT_EXT : PAGE_EXTS.find((e) => rel.endsWith(e)) ?? EXT;
|
|
85
95
|
const parts = rel.slice(0, -ext.length).split(path.sep);
|
|
86
96
|
|
|
87
97
|
// `blog/index.html` and `blog.html` both mean /blog; the trailing `index`
|
|
@@ -163,7 +173,7 @@ function walk(dir, base = dir, out = []) {
|
|
|
163
173
|
|
|
164
174
|
const full = path.join(dir, entry.name);
|
|
165
175
|
if (entry.isDirectory()) walk(full, base, out);
|
|
166
|
-
else if (entry.name.endsWith(
|
|
176
|
+
else if (entry.name.endsWith(ENDPOINT_EXT) || PAGE_EXTS.some((e) => entry.name.endsWith(e))) {
|
|
167
177
|
out.push(path.relative(base, full));
|
|
168
178
|
}
|
|
169
179
|
}
|
package/src/sitemap.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// crawler can reach by guessing, so it is left out.
|
|
8
8
|
|
|
9
9
|
import { urlFor } from './document.js';
|
|
10
|
+
import { isGated } from './gate.js';
|
|
10
11
|
|
|
11
12
|
/** The protocol's cap for one file. Past it the response is an index of files. */
|
|
12
13
|
const LIMIT = 50000;
|
|
@@ -65,9 +66,16 @@ export async function sitemapEntries(manifest, pages, { entries = [], exclude =
|
|
|
65
66
|
const extra = typeof entries === 'function' ? ((await entries()) ?? []) : entries;
|
|
66
67
|
const all = [...found, ...extra];
|
|
67
68
|
|
|
69
|
+
// `manifest.gated` rather than a second config key. A path the app declared
|
|
70
|
+
// not public should not be advertised, and reading it here covers the file the
|
|
71
|
+
// build writes and the `/sitemap.xml` route together. Two lists is two answers
|
|
72
|
+
// about which URLs a crawler is invited to.
|
|
73
|
+
const gated = manifest.gated ?? [];
|
|
74
|
+
|
|
68
75
|
const seen = new Set();
|
|
69
76
|
return all.filter((entry) => {
|
|
70
77
|
if (seen.has(entry.path) || excluded(entry.path, exclude)) return false;
|
|
78
|
+
if (isGated(entry.path, gated)) return false;
|
|
71
79
|
seen.add(entry.path);
|
|
72
80
|
return true;
|
|
73
81
|
});
|
package/src/typecheck.js
CHANGED
|
@@ -19,6 +19,8 @@ import { AMBIENT_NAMES } from './compiler/ambient.js';
|
|
|
19
19
|
import { buildEndpointShim, buildShim, originalOffset } from './compiler/shim.js';
|
|
20
20
|
import { splitBlocks, readFlags } from './compiler/index.js';
|
|
21
21
|
import { resolveRoutesDir, scanRoutes } from './routes.js';
|
|
22
|
+
// Aliased: this file has its own `sourceOf`, which is the one that reads disk.
|
|
23
|
+
import { MARKDOWN_EXT, sourceOf as htmlFrom } from './markdown.js';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Annotations are optional, so `noImplicitAny` is off: an unannotated parameter
|
|
@@ -79,9 +81,9 @@ const LAYOUT_FILE = '_layout.html';
|
|
|
79
81
|
* on the layouts above it, a page on its whole chain.
|
|
80
82
|
*
|
|
81
83
|
* @param {{ root: string, appDir: string, routesDir: string, elementsDir: string,
|
|
82
|
-
* strict?: boolean }} options
|
|
83
|
-
* @returns {{ files: Function,
|
|
84
|
-
* check: Function, quickInfo: Function, describe: Function }}
|
|
84
|
+
* strict?: boolean, markdown?: ((source: string, file: string) => string)|null }} options
|
|
85
|
+
* @returns {{ files: Function, sourceFor: Function, update: Function,
|
|
86
|
+
* rebuild: Function, check: Function, quickInfo: Function, describe: Function }}
|
|
85
87
|
*/
|
|
86
88
|
export function createChecker({
|
|
87
89
|
root,
|
|
@@ -89,6 +91,7 @@ export function createChecker({
|
|
|
89
91
|
elementsDir = 'elements',
|
|
90
92
|
routesDir = 'routes',
|
|
91
93
|
strict = false,
|
|
94
|
+
markdown = null,
|
|
92
95
|
}) {
|
|
93
96
|
const app = path.resolve(root, appDir);
|
|
94
97
|
const options = compilerOptions(Boolean(strict));
|
|
@@ -126,7 +129,10 @@ export function createChecker({
|
|
|
126
129
|
return built;
|
|
127
130
|
};
|
|
128
131
|
|
|
129
|
-
|
|
132
|
+
// An overlay is already HTML: the editor hands over what it is checking, and a
|
|
133
|
+
// Markdown page is checked as the page it compiles to. Off disk, it is not.
|
|
134
|
+
const sourceOf = (file) =>
|
|
135
|
+
overlays.get(file) ?? htmlFrom(file, fs.readFileSync(file, 'utf8'), markdown);
|
|
130
136
|
|
|
131
137
|
/** The type of one of a shim's marker exports. What tsc made of the file. */
|
|
132
138
|
const exportTypeOf = (file, name) => {
|
|
@@ -421,11 +427,23 @@ export function createChecker({
|
|
|
421
427
|
files() {
|
|
422
428
|
const found = componentFiles();
|
|
423
429
|
const dir = resolveRoutesDir(app, routesDir);
|
|
424
|
-
|
|
430
|
+
walkPages(dir, found);
|
|
425
431
|
for (const route of scanRoutes(dir).endpoints) found.push(route.file);
|
|
426
432
|
return found;
|
|
427
433
|
},
|
|
428
434
|
|
|
435
|
+
/**
|
|
436
|
+
* The source the diagnostics were measured against.
|
|
437
|
+
*
|
|
438
|
+
* For an `.html` page that is the file on disk. For a Markdown page it is
|
|
439
|
+
* the HTML it converted to, and the difference is why this exists: an offset
|
|
440
|
+
* from a shim built out of the converted HTML, printed against the Markdown,
|
|
441
|
+
* points at the wrong column and looks authoritative doing it.
|
|
442
|
+
*/
|
|
443
|
+
sourceFor(file) {
|
|
444
|
+
return sourceOf(file);
|
|
445
|
+
},
|
|
446
|
+
|
|
429
447
|
/** Replaces a file's contents without touching disk, for an editor buffer. */
|
|
430
448
|
update(file, source) {
|
|
431
449
|
overlays.set(file, source);
|
|
@@ -577,12 +595,12 @@ function readDirSafe(dir) {
|
|
|
577
595
|
return fs.existsSync(dir) ? fs.readdirSync(dir) : [];
|
|
578
596
|
}
|
|
579
597
|
|
|
580
|
-
function
|
|
598
|
+
function walkPages(dir, out) {
|
|
581
599
|
if (!fs.existsSync(dir)) return out;
|
|
582
600
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
583
601
|
const full = path.join(dir, entry.name);
|
|
584
|
-
if (entry.isDirectory())
|
|
585
|
-
else if (entry.name.endsWith('.html')) out.push(full);
|
|
602
|
+
if (entry.isDirectory()) walkPages(full, out);
|
|
603
|
+
else if (entry.name.endsWith('.html') || entry.name.endsWith(MARKDOWN_EXT)) out.push(full);
|
|
586
604
|
}
|
|
587
605
|
return out;
|
|
588
606
|
}
|