@transclude/core 0.11.0 → 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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.11.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",
@@ -277,6 +277,19 @@ element name. `card.html` is not, and the file is dropped.
277
277
  **`<transclude>` has no self-closing form.** `<transclude src="#a" />` is read
278
278
  as an open tag and the rest of the page becomes its fallback content.
279
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
+
280
293
  **Reading a cookie makes a page personal.** It is then not cached and not
281
294
  prerendered. Writing one does not do this; reading one does.
282
295
 
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/plugin.js CHANGED
@@ -289,7 +289,7 @@ export default function transclude({
289
289
  return `
290
290
  ${ids.map((pageId, i) => `import * as __P${i} from ${JSON.stringify(`${P_PAGE}${pageId}`)};`).join('\n')}
291
291
  ${apiIds.map((apiId, i) => `import * as __E${i} from ${apiSpec(endpoints.get(apiId))};`).join('\n')}
292
- ${hasMiddleware ? `import __middleware from ${JSON.stringify(specifier)};` : ''}
292
+ ${hasMiddleware ? `import * as __server from ${JSON.stringify(specifier)};` : ''}
293
293
 
294
294
  export const pages = {
295
295
  ${ids.map((pageId, i) => ` ${JSON.stringify(pageId)}: __P${i},`).join('\n')}
@@ -299,7 +299,12 @@ export const endpoints = {
299
299
  ${apiIds.map((apiId, i) => ` ${JSON.stringify(apiId)}: __E${i},`).join('\n')}
300
300
  };
301
301
 
302
- 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 ?? []' : '[]'};
303
308
  `;
304
309
  }
305
310
 
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
  });