@transclude/core 0.10.2 → 0.11.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 +4 -0
- 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 +35 -2
- package/src/compiler/codegen.js +6 -1
- package/src/compiler/interp.js +3 -2
- package/src/defaults.js +5 -0
- package/src/markdown.js +52 -0
- package/src/plugin.js +9 -2
- package/src/routes.js +12 -2
- 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/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.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 && 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.
|
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/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;
|
|
@@ -370,7 +377,7 @@ export const middleware = ${hasMiddleware ? '__middleware ?? null' : 'null'};
|
|
|
370
377
|
if (duplicate) return;
|
|
371
378
|
|
|
372
379
|
server.watcher.on('all', (_event, file) => {
|
|
373
|
-
if (!file.endsWith('.html')) return;
|
|
380
|
+
if (!file.endsWith('.html') && !file.endsWith(MARKDOWN_EXT)) return;
|
|
374
381
|
if (!file.startsWith(app)) return;
|
|
375
382
|
|
|
376
383
|
scan();
|
|
@@ -429,7 +436,7 @@ function readDir(dir) {
|
|
|
429
436
|
return map;
|
|
430
437
|
}
|
|
431
438
|
|
|
432
|
-
function
|
|
439
|
+
function readRaw(file) {
|
|
433
440
|
return fs.readFileSync(file, 'utf8');
|
|
434
441
|
}
|
|
435
442
|
|
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/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
|
}
|