@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 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) return [{ url: route.pattern, params: {} }];
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.map((params) => ({
168
- url: urlFor(route, params),
169
- params,
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
- 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.2",
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 `${` 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.
@@ -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
 
@@ -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
  /**
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
+ }
@@ -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 __middleware from ${JSON.stringify(specifier)};` : ''}
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 ? '__middleware ?? null' : 'null'};
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 read(file) {
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(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/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, 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
  }