@transclude/core 0.10.1 → 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 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
- const source = fs.readFileSync(file, 'utf8');
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
- console.log(`\n${relative}:${line}:${column + 1} ${diagnostic.severity} TS${diagnostic.code}`);
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.10.1",
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 `${` cannot be written in a template.** There is no escape. Pass any
219
- text containing it in from the loader as data.
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/app.js CHANGED
@@ -444,7 +444,12 @@ export function createApp({
444
444
  return sendRendered(c, html, ctx, preload);
445
445
  }
446
446
 
447
- const html = await cache.read(cacheKey(c.req.url), window, render);
447
+ // The fourth argument is what holds the background rebuild. Without it
448
+ // workerd stops the rebuild when this response is sent, and the entry it
449
+ // leaves in the in-flight map answers every later request with a dead
450
+ // promise.
451
+ const after = afterFor(c, (error) => report(error, c));
452
+ const html = await cache.read(cacheKey(c.req.url), window, render, after);
448
453
 
449
454
  // A miss rendered through the cache, and that render can answer with a
450
455
  // `Response`. It was not stored, but it is still the answer.
package/src/cache.js CHANGED
@@ -62,6 +62,35 @@ export function memoryStore({ max = 1000 } = {}) {
62
62
  };
63
63
  }
64
64
 
65
+ /**
66
+ * How long an unfinished render may hold a key.
67
+ *
68
+ * The map exists so one render happens per key. It assumed every promise it held
69
+ * would settle. On workerd one may not: the isolate is allowed to stop when the
70
+ * response is sent, and it stops the work with it. The `finally` never runs, the
71
+ * entry stays, and every later request for that key waits on a promise that is
72
+ * already dead. The page hangs for as long as the isolate lives.
73
+ *
74
+ * `after` is the fix, and this is the bound on it being wrong. A render slower
75
+ * than this loses its claim on the key rather than keeping it forever.
76
+ */
77
+ const ABANDONED_MS = 30_000;
78
+
79
+ /**
80
+ * Keeps the background rebuild alive, and its failure off the unhandled path.
81
+ *
82
+ * `after` is `waitUntil` on workerd and a no-op elsewhere, and it attaches its
83
+ * own catch. Without one, the catch here is all there is: a rebuild that throws
84
+ * must not take down a process that was only serving a stale page.
85
+ *
86
+ * @param {Promise<unknown>} work
87
+ * @param {((work: Promise<unknown>) => void)|null} after
88
+ */
89
+ function hold(work, after) {
90
+ if (after) after(work);
91
+ else work.catch(() => {});
92
+ }
93
+
65
94
  /**
66
95
  * One route's cache, wrapped around the render.
67
96
  *
@@ -80,8 +109,11 @@ export function createCache(store = memoryStore(), { now = () => Date.now() } =
80
109
  // and every request behind it each start their own.
81
110
  const inFlight = new Map();
82
111
 
83
- const refresh = async (key, window, render) => {
84
- if (inFlight.has(key)) return inFlight.get(key);
112
+ const refresh = (key, window, render) => {
113
+ const current = inFlight.get(key);
114
+ if (current && now() - current.at < ABANDONED_MS) return current.work;
115
+
116
+ const started = now();
85
117
 
86
118
  const work = (async () => {
87
119
  const result = await render();
@@ -97,15 +129,25 @@ export function createCache(store = memoryStore(), { now = () => Date.now() } =
97
129
  store.delete(key);
98
130
  }
99
131
  return result;
100
- })().finally(() => inFlight.delete(key));
132
+ })().finally(() => {
133
+ // Only if this is still the entry made above. A render that ran past
134
+ // ABANDONED_MS was replaced, and it must not delete its replacement.
135
+ if (inFlight.get(key)?.at === started) inFlight.delete(key);
136
+ });
101
137
 
102
- inFlight.set(key, work);
138
+ inFlight.set(key, { work, at: started });
103
139
  return work;
104
140
  };
105
141
 
106
142
  return {
107
- /** `null` when the caller should just render, which is every uncached route. */
108
- async read(key, window, render) {
143
+ /**
144
+ * `null` when the caller should just render, which is every uncached route.
145
+ *
146
+ * `after` is the request's `ctx.after`. Only the stale path uses it, and a
147
+ * caller that leaves it out gets a rebuild nothing holds, which is what this
148
+ * used to do everywhere.
149
+ */
150
+ async read(key, window, render, after = null) {
109
151
  if (!window) return null;
110
152
 
111
153
  const hit = store.get(key);
@@ -116,7 +158,10 @@ export function createCache(store = memoryStore(), { now = () => Date.now() } =
116
158
  // Stale. Answer with it now and rebuild behind the response. A failed
117
159
  // rebuild leaves the stale entry in place rather than emptying the cache
118
160
  // because one render threw.
119
- refresh(key, window, render).catch(() => {});
161
+ //
162
+ // `after` is what keeps the rebuild alive. On workerd the isolate may stop
163
+ // the moment the response is sent, and work nothing holds stops with it.
164
+ hold(refresh(key, window, render), after);
120
165
  return hit.html;
121
166
  },
122
167
 
@@ -903,7 +903,12 @@ class Codegen {
903
903
  const dynamic = parts.some((p) => p.type === 'expr');
904
904
 
905
905
  if (!dynamic) {
906
- this.s(out, attr.value === '' ? ` ${attr.name}` : ` ${attr.name}="${escapeAttr(attr.value)}"`);
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(
@@ -8,8 +8,9 @@
8
8
  /**
9
9
  * Text and `${expr}` in source order.
10
10
  *
11
- * There is no escape for a literal `${`, so anything documenting the syntax has
12
- * to pass its examples in as data rather than write them in a template.
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
  /**
@@ -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 read(file) {
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(EXT) || entry.name.endsWith(ENDPOINT_EXT)) {
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, update: Function, rebuild: 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
- const sourceOf = (file) => overlays.get(file) ?? fs.readFileSync(file, 'utf8');
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
- walkHtml(dir, found);
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 walkHtml(dir, out) {
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()) walkHtml(full, out);
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
  }