tosijs-ui 1.16.5 → 1.16.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,108 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.7
4
+
5
+ A patch for readers that are not browsers, and for builds that were too polite. Every doc page
6
+ now also ships as markdown, for LLM fetchers that fail on ordinary HTML. Two misconfigurations
7
+ tjs-lang found the hard way now say so, and `strict: true` turns the build's warnings into
8
+ failures. Nothing to change on upgrade: `markdownPages` is on by default and adds files only;
9
+ `strict` is off by default. `dist/iife.js` grows by 0.3 kB gzip (483.7 → 484.0 kB).
10
+
11
+ ### Every doc page also ships as markdown
12
+
13
+ The site build now writes each page's markdown source beside its HTML, at
14
+ `/{slug}/index.md`, links it from the page with
15
+ `<link rel="alternate" type="text/markdown" href="index.md">`, and says so in `llms.txt`.
16
+ It is for readers that want the text and not the page: an LLM's URL fetcher, a script, `curl`.
17
+ Prompted by one such fetcher reporting "no `<body>`" for a page whose whole article was in the
18
+ static HTML.
19
+
20
+ - On by default; `markdownPages: false` in the site config ships HTML only. It adds one file
21
+ per page: on this repo's site, 70 files and 0.5 MB, about 3% of the output.
22
+ - The file is the page's source as written, so HTML comments and metadata blocks in a doc
23
+ are served as text. If a page's source holds something its rendered page does not show,
24
+ mark it `noindex` or turn this off.
25
+ - A `noindex` page gets neither the file nor the link.
26
+ - The build now writes `.nojekyll` whenever markdown pages are on, not only under
27
+ `host: 'github-pages'`. Without it, GitHub Pages' Jekyll would render `index.md` over the
28
+ real page. The file is empty and harmless on other hosts.
29
+
30
+ ### Misconfigurations the build and the page used to accept in silence (#210)
31
+
32
+ Two reported by tjs-lang, whose site ran every example against the wrong tjs-lang for weeks
33
+ without being told.
34
+
35
+ - **The same-origin tjs-lang copy was skipped without a word.** When `tjs-lang/browser`
36
+ cannot be resolved, nothing is written to `/tjs/`, and `tjs`/`ts` examples load tjs-lang
37
+ from a CDN at the version tosijs-ui pins. The build now warns when the corpus has live
38
+ `tjs` or `ts` examples and that happens. A corpus with none stays quiet.
39
+ - **A custom `bundleEntry` silently dropped what examples import.** It replaces the bundle
40
+ that set the `xinjs`/`xinjsui` globals, so `import { elements } from 'tosijs'` in an example
41
+ failed with "Cannot destructure property 'elements' of 'tosijs'". Examples now get `tosijs`
42
+ from the doc system's own copy. `tosijs-ui` still has to be supplied by the entry (importing
43
+ it here would put every component in every doc site's bundle), and an example that imports
44
+ it without one now fails with a message naming the module and the two lines that fix it,
45
+ whether it is met in the example or its tests. The cost of the
46
+ `tosijs` fallback is 0.3 kB gzip on this site's own bundle.
47
+
48
+ ### `strict: true` makes the build fail on what it otherwise warns about
49
+
50
+ A new site-config option, default `false`. With it, `buildSite` throws
51
+ `SiteMisconfiguredError` when `bundleEntry` does not register `<tosi-doc-system>`, when the
52
+ corpus has executable fences and `bundleEntry` does not register `<tosi-example>`, or when
53
+ the tjs-lang copy above is skipped on a corpus that needs it. Warning stays the default
54
+ because each is a legitimate thing to do on purpose; this is for a project that never does.
55
+ Asked for by tosijs-product, whose inert-bundle blocker reached a release candidate under a
56
+ green build. `SiteMisconfiguredError` is exported from `tosijs-ui/site`, so a caller can tell a
57
+ refused misconfiguration from a crash.
58
+
59
+ Whether a corpus "has executable fences" is now decided by the same rule the page uses, under
60
+ the site's `liveExamples` policy. The build's warning used a narrower pattern of its own: it
61
+ counted a bare `js` fence on an opt-in site, and missed `js:iframe`, `html`, `css`, `~~~`
62
+ fences and registered dialects. `llms.txt`'s note about live examples follows the same rule.
63
+
64
+ ### Found by the pre-tag review, fixed before release
65
+
66
+ None of these ever shipped; they are listed so the notes account for every fix in the release.
67
+
68
+ - `liveFenceLanguages` found no fences in a CRLF corpus and disagreed with the renderer on
69
+ blockquoted, indented and commented-out fences, because it scanned lines. It now reads
70
+ marked's tokens through `collectCodeTokens`, shared with check-examples.
71
+ - The missing-module error covered the example only; tests and inline tjs tests still got the
72
+ destructuring error. They now go through `rewriteContextImports`, and the check matches a
73
+ real import statement, not a commented-out one.
74
+ - The Console REPL keeps plain `rewriteImports`: the checking variant threw outside the `try`
75
+ that prints REPL errors, so a line importing an unsupplied module printed nothing.
76
+ - A failed copy of the tjs-lang bundles shared a `catch` with resolution and would have been
77
+ reported as "could not be resolved"; only resolution is caught now.
78
+
79
+ ### `<meta charset>` is back in the first 1024 bytes of every page
80
+
81
+ Two explanatory HTML comments at the top of each generated page's `<head>` had pushed the
82
+ charset declaration to about byte 1550; the HTML spec wants it within the first 1024. The
83
+ comments now live in the generator's source, and charset and viewport lead the head. Every
84
+ page is about 1.4 kB smaller.
85
+
86
+ ## 1.16.6
87
+
88
+ A one-fix patch: a link to the page you are already on no longer adds a history entry that Back
89
+ has to step through (#174). No API change.
90
+
91
+ ### Clicking a link to the page you are on no longer costs an extra Back (#174)
92
+
93
+ A doc link pointing at the document already showing pushed a duplicate history entry, so the
94
+ next Back press left the page where it was. It now replaces the entry, as a native same-URL
95
+ navigation does. "The same page" means the same document: `/x`, `/x/` and `/x/index.html`, or
96
+ `?x` with extra query parameters, all count. A URL with a hash still pushes, so Back from
97
+ `/x/` returns to `/x/#example`. The `router`'s `navigate()` had the same defect and gets the
98
+ same rule: navigating to the current path (with no hash) replaces.
99
+
100
+ The double push #174 measured on every link (ensemble on 1.14.1) does not reproduce on 1.16.4
101
+ with the same direct import: one click, one entry, and Back and Forward both work, in Chromium
102
+ and WebKit. The documented consumer entry point is `import 'tosijs-ui/doc-browser'`, which
103
+ registers `<tosi-doc-system>` since 1.14.2. The deep `tosijs-ui/doc-system/doc-system.js`
104
+ import loads the same module.
105
+
3
106
  ## 1.16.5
4
107
 
5
108
  A patch, mostly found by using the doc site on an iPhone: menus that stay on screen as Safari's
@@ -1027,7 +1027,19 @@ export function createDocBrowser(options) {
1027
1027
  }
1028
1028
  else {
1029
1029
  const href = hrefFor(filename);
1030
- window.history.pushState({ href }, '', href);
1030
+ /*
1031
+ A link to the document already showing REPLACES the entry rather than pushing one, as a
1032
+ native same-URL navigation does. Pushing left a duplicate the reader had to Back
1033
+ through, and Back then visibly did nothing — the symptom #174 reported.
1034
+
1035
+ "Same" is the DOCUMENT, the identity the popstate guard uses, not the raw path: `/x`,
1036
+ `/x/` and `/x/index.html`, or `?x` and `?x&_testMode=1`, are one page. A URL carrying a
1037
+ hash still pushes, because leaving `/x/#example` for `/x/` is a navigation the reader
1038
+ may want to come Back from.
1039
+ */
1040
+ const same = filename === String(app.currentDoc.filename) &&
1041
+ document.location.hash === '';
1042
+ window.history[same ? 'replaceState' : 'pushState']({ href }, '', href);
1031
1043
  }
1032
1044
  navigateTo(filename);
1033
1045
  };
@@ -1,9 +1,10 @@
1
1
  import { ElementCreator } from 'tosijs';
2
+ import * as tosijsModule from 'tosijs';
2
3
  import { Doc } from '../doc-browser.js';
3
4
  import './css-var-editor.js';
4
5
  export declare const CORPUS_ATTEMPTS = 3;
5
6
  export declare function fetchCorpus(url: string): Promise<Doc[]>;
6
- declare const TosiDocSystem_base: import("tosijs").WithAttributes<{
7
+ declare const TosiDocSystem_base: tosijsModule.WithAttributes<{
7
8
  docs: string;
8
9
  config: string;
9
10
  localized: string;
@@ -57,6 +57,7 @@ A self-contained, controllable embed (e.g. docs in a floating panel):
57
57
  */
58
58
  /*{ "parent": "Appendices" }*/
59
59
  import { StyleSheet, elements, tosi, vars, withAttributes, } from 'tosijs';
60
+ import * as tosijsModule from 'tosijs';
60
61
  import { createDocBrowser, } from '../doc-browser.js';
61
62
  import { buildSlugMap, legacyQueryPath } from './routing.js';
62
63
  import { buildBookHtml, slugify } from './book-html.js';
@@ -527,8 +528,19 @@ export class TosiDocSystem extends withAttributes({
527
528
  this.route = slug;
528
529
  }
529
530
  : undefined,
531
+ /*
532
+ The iife sets `xinjs` / `xinjsui`; a site with its own `bundleEntry` has neither, and
533
+ examples there used to get `undefined` for both with no word said (#210 item 4).
534
+
535
+ `tosijs` falls back to the copy this module already imports — the same instance the
536
+ page's components use, which is the one an example should share state with.
537
+ `tosijs-ui` has no such fallback on purpose: importing the barrel here would put every
538
+ component in every doc site's bundle (#133). A site that wants it in examples supplies
539
+ it, and an example that imports it without one now fails with a sentence saying so
540
+ (`assertContextProvided`).
541
+ */
530
542
  context: this.context || {
531
- tosijs: globalThis.xinjs,
543
+ tosijs: globalThis.xinjs ?? tosijsModule,
532
544
  'tosijs-ui': globalThis.xinjsui,
533
545
  },
534
546
  projectName: config.projectName,
@@ -0,0 +1,23 @@
1
+ import { type ExamplePolicy } from '../example-policy.js';
2
+ /** Thrown by `misconfigured` under `strict`, so a caller can tell it from a crash. */
3
+ export declare class SiteMisconfiguredError extends Error {
4
+ name: string;
5
+ }
6
+ /**
7
+ * Report a misconfiguration: a warning by default, a thrown `SiteMisconfiguredError` (which
8
+ * fails `buildSite`) when the site config sets `strict: true`.
9
+ */
10
+ export declare function misconfigured(strict: boolean | undefined, message: string): void;
11
+ /**
12
+ * The languages of the corpus's LIVE fences, under the site's example policy.
13
+ *
14
+ * Code blocks come from marked (`collectCodeTokens`), the parser the pages are rendered with,
15
+ * and liveness from `isLiveFence`, THE predicate. Neither is re-derived here: a second copy of
16
+ * the liveness rule is what disagreed when `:static` shipped, a regex of its own in the bundle
17
+ * guard is what the 1.16.7 review caught deciding a `strict` build, and the line scanner that
18
+ * first replaced it missed CRLF files and blockquoted fences. Every build-side "does this
19
+ * corpus have live examples?" asks here.
20
+ */
21
+ export declare function liveFenceLanguages(docs: Array<{
22
+ text?: string;
23
+ }>, policy?: ExamplePolicy): Set<string>;
@@ -0,0 +1,45 @@
1
+ /*
2
+ Things a site build can see are wrong and used to mention quietly, or not at all.
3
+
4
+ A build that exits 0 with a warning in scrollback is how tosijs-product's 0.8.0 blocker reached
5
+ a release candidate (#145, board #2543), and how tjs.tosijs.net ran every example against the
6
+ wrong tjs-lang for weeks without anyone being told (#210). Warning stays the default, because
7
+ each of these is a legitimate thing to do on purpose. `strict: true` in the site config is how a
8
+ project that never does them on purpose says so, and then the build fails.
9
+ */
10
+ import { isLiveFence } from '../example-policy.js';
11
+ import { collectCodeTokens } from './code-fences.js';
12
+ /** Thrown by `misconfigured` under `strict`, so a caller can tell it from a crash. */
13
+ export class SiteMisconfiguredError extends Error {
14
+ name = 'SiteMisconfiguredError';
15
+ }
16
+ /**
17
+ * Report a misconfiguration: a warning by default, a thrown `SiteMisconfiguredError` (which
18
+ * fails `buildSite`) when the site config sets `strict: true`.
19
+ */
20
+ export function misconfigured(strict, message) {
21
+ if (strict) {
22
+ throw new SiteMisconfiguredError(`${message}\n This fails the build because the site config sets \`strict: true\`.\n`);
23
+ }
24
+ console.warn(message);
25
+ }
26
+ /**
27
+ * The languages of the corpus's LIVE fences, under the site's example policy.
28
+ *
29
+ * Code blocks come from marked (`collectCodeTokens`), the parser the pages are rendered with,
30
+ * and liveness from `isLiveFence`, THE predicate. Neither is re-derived here: a second copy of
31
+ * the liveness rule is what disagreed when `:static` shipped, a regex of its own in the bundle
32
+ * guard is what the 1.16.7 review caught deciding a `strict` build, and the line scanner that
33
+ * first replaced it missed CRLF files and blockquoted fences. Every build-side "does this
34
+ * corpus have live examples?" asks here.
35
+ */
36
+ export function liveFenceLanguages(docs, policy = 'auto') {
37
+ const langs = new Set();
38
+ for (const doc of docs) {
39
+ for (const { lang, mode } of collectCodeTokens(doc.text ?? '')) {
40
+ if (lang && isLiveFence(lang, mode, policy))
41
+ langs.add(lang);
42
+ }
43
+ }
44
+ return langs;
45
+ }
@@ -21,9 +21,9 @@ executed, so they're skipped.
21
21
 
22
22
  Build-time only (bun). Never import from browser code.
23
23
  */
24
- import { marked } from 'marked';
24
+ import { collectCodeTokens } from './code-fences.js';
25
25
  import { rewriteImports, AsyncFunction, loadTransform, transformAvailable, UnsupportedImportError, } from '../../live-example/code-transform.js';
26
- import { isLiveFence, parseFenceInfo, } from '../example-policy.js';
26
+ import { isLiveFence } from '../example-policy.js';
27
27
  // The default live-example context (matches the IIFE globals the pages provide).
28
28
  // A project that sets a custom `context` on its <tosi-doc-system> can pass its
29
29
  // own keys; these are the tosijs-ui defaults.
@@ -54,36 +54,6 @@ const EXECUTABLE = new Set(['js', 'tjs', 'ts', 'test']);
54
54
  // scope is the difference between "once" and "once per rebuild". Lazily created, so a
55
55
  // corpus with no `ts` examples never makes one at all.
56
56
  let tsTranspiler;
57
- /*
58
- Fence info is parsed by `parseFenceInfo`, the one copy of that grammar. This file had its own
59
- `dialectOf`/`modeOf` pair; once fences could carry JSON options (#184) `modeOf` would have read
60
- `{"debug":true}` as the mode `true`.
61
- */
62
- function collectCodeTokens(text) {
63
- const out = [];
64
- const walk = (tokens) => {
65
- for (const t of tokens) {
66
- /*
67
- Keep the MODE. `dialectOf` reduced `js:static` to `js`, so the checker could not tell a
68
- block that will never run from one that will — and hard-failed a build over the syntax
69
- of illustrative code, advising the author to retag it as `typescript`, which is exactly
70
- the mislabelling this release exists to abolish (review major M2).
71
- */
72
- if (t.type === 'code')
73
- out.push({
74
- lang: parseFenceInfo(t.lang ?? '').lang,
75
- text: t.text,
76
- mode: parseFenceInfo(t.lang ?? '').mode,
77
- });
78
- if (Array.isArray(t.tokens))
79
- walk(t.tokens);
80
- if (Array.isArray(t.items))
81
- walk(t.items); // list items
82
- }
83
- };
84
- walk(marked.lexer(text));
85
- return out;
86
- }
87
57
  /**
88
58
  * Transpile-check every executable block in the corpus. Returns the problems and
89
59
  * the `tjs` bakes (which it computes anyway while checking — no double transpile).
@@ -0,0 +1,5 @@
1
+ export declare function collectCodeTokens(text: string): Array<{
2
+ lang: string;
3
+ text: string;
4
+ mode?: string;
5
+ }>;
@@ -0,0 +1,40 @@
1
+ /*
2
+ The build's ONE way to enumerate the code blocks in a doc: marked's own lexer.
3
+
4
+ `check-examples` had this function; `build-warnings` then grew a hand-written line scanner for
5
+ the same job, and the 1.16.7 re-review found the scanner disagreeing with marked on CRLF files,
6
+ blockquoted fences and fences inside HTML blocks. A scanner can only ever approximate the
7
+ parser the pages are rendered with, so nothing here scans: both callers ask marked.
8
+ */
9
+ import { marked } from 'marked';
10
+ import { parseFenceInfo } from '../example-policy.js';
11
+ /*
12
+ Fence info is parsed by `parseFenceInfo`, the one copy of that grammar. This file had its own
13
+ `dialectOf`/`modeOf` pair; once fences could carry JSON options (#184) `modeOf` would have read
14
+ `{"debug":true}` as the mode `true`.
15
+ */
16
+ export function collectCodeTokens(text) {
17
+ const out = [];
18
+ const walk = (tokens) => {
19
+ for (const t of tokens) {
20
+ /*
21
+ Keep the MODE. `dialectOf` reduced `js:static` to `js`, so the checker could not tell a
22
+ block that will never run from one that will — and hard-failed a build over the syntax
23
+ of illustrative code, advising the author to retag it as `typescript`, which is exactly
24
+ the mislabelling this release exists to abolish (review major M2).
25
+ */
26
+ if (t.type === 'code')
27
+ out.push({
28
+ lang: parseFenceInfo(t.lang ?? '').lang,
29
+ text: t.text,
30
+ mode: parseFenceInfo(t.lang ?? '').mode,
31
+ });
32
+ if (Array.isArray(t.tokens))
33
+ walk(t.tokens);
34
+ if (Array.isArray(t.items))
35
+ walk(t.items); // list items
36
+ }
37
+ };
38
+ walk(marked.lexer(text));
39
+ return out;
40
+ }
@@ -75,6 +75,13 @@ export interface GenerateSiteConfig {
75
75
  * self-contained-examples-plan.md.
76
76
  */
77
77
  bakes?: Map<string, ExampleBakes>;
78
+ /**
79
+ * Write each page's markdown beside its HTML (`/slug/index.md`) and point at it with
80
+ * `<link rel="alternate" type="text/markdown">`. Default true. For readers that want the
81
+ * text and not the page: an LLM's URL fetcher, a script, `curl`. `noindex` pages get
82
+ * neither, the same rule the sitemap applies.
83
+ */
84
+ markdownPages?: boolean;
78
85
  /** URL of the burned-in theme stylesheet (written by ./generate-css.ts) */
79
86
  stylesUrl?: string;
80
87
  /** extra lines injected into every <head> (favicon, analytics, etc.) */
@@ -163,8 +163,6 @@ async function pageHtml(doc, config, slugMap, configAttr) {
163
163
  })
164
164
  : '';
165
165
  const head = [
166
- ' <meta charset="utf-8" />',
167
- ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
168
166
  // Render-blocking @import of web fonts lives in the stylesheet; warm the connection.
169
167
  ' <link rel="preconnect" href="https://fonts.googleapis.com" />',
170
168
  ' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
@@ -179,6 +177,10 @@ async function pageHtml(doc, config, slugMap, configAttr) {
179
177
  : '',
180
178
  doc.noindex ? ' <meta name="robots" content="noindex, follow" />' : '',
181
179
  baseUrl ? ` <link rel="canonical" href="${escapeAttr(canonical)}" />` : '',
180
+ // Relative to the page's own directory, so it holds at any mount (see relativeUrl).
181
+ hasMarkdownPage(doc, config)
182
+ ? ` <link rel="alternate" type="text/markdown" href="index.md" title="${escapeAttr(title)} (markdown)" />`
183
+ : '',
182
184
  ' <meta property="og:type" content="article" />',
183
185
  projectName
184
186
  ? ` <meta property="og:site_name" content="${escapeAttr(projectName)}" />`
@@ -203,23 +205,31 @@ async function pageHtml(doc, config, slugMap, configAttr) {
203
205
  ]
204
206
  .filter(Boolean)
205
207
  .join('\n');
208
+ /*
209
+ Two notes that used to ship as HTML comments at the top of every page's <head>. They are for
210
+ whoever edits this template, not for readers, and they pushed `<meta charset>` to byte ~1550
211
+ — the HTML spec wants it within the first 1024, and a byte-limited or strict fetcher may give
212
+ up before it gets there. So charset and viewport now lead, and the notes live here.
213
+
214
+ 1. The body is NOT hidden until hydration. It used to be (body opacity 0 + a 4s safety-net
215
+ timeout), because an undefined custom element is display:inline, so the pre-rendered page
216
+ stacked as bare text and hydration reflowed the whole thing. The cost was a blank screen
217
+ for as long as the bundle took — ~4.5s on a cheap phone, for content already in the HTML.
218
+ The tosi-doc-system:not(:defined) rules in the stylesheet now lay the static page out as
219
+ though the chrome were there, so hydration only ADDS the chrome and nothing moves. Paint
220
+ immediately; don't hide readable content.
221
+
222
+ 2. The inline script applies the theme BEFORE first paint. Now that we paint the static page
223
+ rather than hiding it, a dark-mode reader would otherwise get a flash of the light theme
224
+ until the bundle lands and sets body.darkmode. CSS alone can't do this: an explicit theme
225
+ choice lives in localStorage. Mirrors applyThemePrefs() (doc-system.ts) — keep the two in
226
+ step. Fails safe: any error leaves the light default.
227
+ */
206
228
  return `<!DOCTYPE html>
207
229
  <html lang="${escapeAttr(lang)}">
208
230
  <head>
209
- <!-- NB: the body is NOT hidden until hydration. It used to be (body opacity 0 +
210
- a 4s safety-net timeout), because an undefined custom element is
211
- display:inline, so the pre-rendered page stacked as bare text and hydration
212
- reflowed the whole thing. The cost was a blank screen for as long as the
213
- bundle took — ~4.5s on a cheap phone, for content already in the HTML. The
214
- tosi-doc-system:not(:defined) rules in the stylesheet now lay the static page
215
- out as though the chrome were there, so hydration only ADDS the chrome and
216
- nothing moves. Paint immediately; don't hide readable content. -->
217
- <!-- Theme, applied BEFORE first paint. Now that we paint the static page rather
218
- than hiding it, a dark-mode reader would otherwise get a flash of the light
219
- theme until the bundle lands and sets body.darkmode. CSS alone can't do this:
220
- an explicit theme choice lives in localStorage. Mirrors applyThemePrefs()
221
- (doc-system.ts) — keep the two in step. Fails safe: any error leaves the
222
- light default, exactly as before. -->
231
+ <meta charset="utf-8" />
232
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
223
233
  <script>
224
234
  try {
225
235
  var p = JSON.parse(localStorage.getItem('tosi-doc-system-prefs') || '{}')
@@ -263,6 +273,10 @@ Only same-origin: `scriptUrl` may legitimately point at a CDN, and appending a q
263
273
  someone else's URL can miss their cache key or, worse, be rejected. A URL that already carries
264
274
  a query is left alone — the caller has said something deliberate about it.
265
275
  */
276
+ /** A page gets a markdown copy unless the site opted out or the page is `noindex`. */
277
+ function hasMarkdownPage(doc, config) {
278
+ return config.markdownPages !== false && !doc.noindex;
279
+ }
266
280
  export function withStamp(url, stamp) {
267
281
  if (!stamp)
268
282
  return url;
@@ -297,6 +311,10 @@ export async function generateSite(config) {
297
311
  const slug = slugMap[doc.filename];
298
312
  const dir = slug === '' ? outputDir : `${outputDir}/${slug}`;
299
313
  await Bun.write(`${dir}/index.html`, await pageHtml(doc, config, slugMap, configAttr));
314
+ // The page's own source, for readers that want text rather than a page.
315
+ if (hasMarkdownPage(doc, config)) {
316
+ await Bun.write(`${dir}/index.md`, doc.text.trimEnd() + '\n');
317
+ }
300
318
  count += 1;
301
319
  }
302
320
  // The corpus the component fetches for nav + client-side rendering of other pages.
@@ -1,6 +1,7 @@
1
1
  export { defineSiteConfig } from './site-config.js';
2
2
  export type { SiteConfig, SiteHost, LibraryBuildContext, } from './site-config.js';
3
3
  export { buildSite } from './orchestrator.js';
4
+ export { SiteMisconfiguredError } from './build-warnings.js';
4
5
  export { devServer } from './dev-server.js';
5
6
  export { auditDependencies, reportAudit, resolveAuditMode, } from './audit-guard.js';
6
7
  export type { AuditConfig, AuditGate, AuditResult, AuditSeverity, AuditMode, AuditAdvisory, } from './audit-guard.js';
@@ -5,6 +5,9 @@ browser code. See ./README is at ../doc-site-system.md and the repo docs.
5
5
  */
6
6
  export { defineSiteConfig } from './site-config.js';
7
7
  export { buildSite } from './orchestrator.js';
8
+ // Thrown by `buildSite` under `strict: true` — exported so a caller can tell a refused
9
+ // misconfiguration from a crash (`catch (e) { if (e instanceof SiteMisconfiguredError) … }`).
10
+ export { SiteMisconfiguredError } from './build-warnings.js';
8
11
  export { devServer } from './dev-server.js';
9
12
  export { auditDependencies, reportAudit, resolveAuditMode, } from './audit-guard.js';
10
13
  export { openDevBrowser, buildOpenPlan } from './open-browser.js';
@@ -1,3 +1,4 @@
1
+ import type { ExamplePolicy } from '../example-policy.js';
1
2
  /** A doc as it appears in the extracted corpus (docs.json). */
2
3
  interface CorpusDoc {
3
4
  title?: string;
@@ -39,6 +40,14 @@ export interface LlmsTxtMeta {
39
40
  * for exactly the projects that enabled the MORE capable mode.
40
41
  */
41
42
  haltijaDev?: boolean | 'tunnel';
43
+ /**
44
+ * Mirrors `SiteConfig.markdownPages` (default true): the build writes `index.md` beside
45
+ * every page, so the index says so. Pass false when the site ships HTML only — pointing a
46
+ * reader at a file that is not there costs more than not mentioning it.
47
+ */
48
+ markdownPages?: boolean;
49
+ /** Mirrors `SiteConfig.liveExamples`: which fences are live decides the examples note. */
50
+ liveExamples?: ExamplePolicy;
42
51
  }
43
52
  /**
44
53
  * Build entries from the extracted corpus — every doc that was actually
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { buildSlugMap, pathForSlug, withBase } from '../routing.js';
4
+ import { liveFenceLanguages } from './build-warnings.js';
4
5
  const SRC = 'src';
5
6
  const DIST = 'dist';
6
7
  function extractTitle(text) {
@@ -113,8 +114,9 @@ export function generateLlmsTxt(outputPath, meta = {}, corpus) {
113
114
  : entriesFromSrcScan();
114
115
  // Only orient agents about the live-example execution model when the corpus
115
116
  // actually has live examples (a book / pure-docs site has none).
116
- const exampleFence = /```(js|tjs|ts|html|css)\b/;
117
- const hasLiveExamples = !!corpus && corpus.some((d) => exampleFence.test(d.text ?? ''));
117
+ // THE shared rule (build-warnings.ts), under the site's policy — this was a regex of its
118
+ // own, which told an opt-in prose site about live examples it does not have.
119
+ const hasLiveExamples = !!corpus && liveFenceLanguages(corpus, meta.liveExamples).size > 0;
118
120
  const liveExampleNote = hasLiveExamples
119
121
  ? [
120
122
  'Live examples run inline on the shared page by default (not sandboxed):',
@@ -142,6 +144,15 @@ export function generateLlmsTxt(outputPath, meta = {}, corpus) {
142
144
  '',
143
145
  ]
144
146
  : [];
147
+ // Only for a corpus-driven index: the legacy scan links to dist/ files, not pages.
148
+ const markdownNote = corpus && meta.markdownPages !== false
149
+ ? [
150
+ 'Pages below are also available as plain markdown: append `index.md` to the URL',
151
+ '(for example `/carousel/index.md`). Prefer that if you only need the text. A page',
152
+ 'marked noindex has no markdown copy.',
153
+ '',
154
+ ]
155
+ : [];
145
156
  const name = meta.name ?? pkg.name ?? '';
146
157
  const description = meta.description ?? pkg.description ?? '';
147
158
  const links = [];
@@ -165,6 +176,7 @@ export function generateLlmsTxt(outputPath, meta = {}, corpus) {
165
176
  '',
166
177
  'Full documentation, with live code examples, is at the links below.',
167
178
  '',
179
+ ...markdownNote,
168
180
  ...liveExampleNote,
169
181
  ...haltijaNote,
170
182
  '## Pages',