tosijs-ui 1.12.2 → 1.12.4

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,124 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.4
4
+
5
+ ### `.tjs` source files are documented again ([#108](https://github.com/tonioloewald/tosijs-ui/issues/108))
6
+
7
+ Converting a source file from `.ts` to native `.tjs` **silently deleted its documentation page**,
8
+ and its `llms.txt` entry with it. Reported from tosijs, mid-port: one page lost per converted
9
+ module.
10
+
11
+ The silence was the real defect. The build exited 0, and the internal-link check passed —
12
+ a page that was never generated is linked from nowhere, so "41 slugs, no 404s" is a _pass_. The
13
+ only signal was a slug count nothing asserts on. `'x.tjs'.endsWith('.ts')` is `false`, so the
14
+ `llms.txt` filter excluded it too, without even the mercy of an accidental match.
15
+
16
+ `.tjs` is now scraped, in both places. The doc-block syntax is identical across these languages —
17
+ a doc block is just a comment — so there is nothing language-specific to support. The list _is_
18
+ the feature, which is exactly why an omission from it is invisible.
19
+
20
+ **And a guard for the next one.** A file carrying what looks like a doc block, in an extension we
21
+ do not scrape, is now **named** in the build output rather than passed over:
22
+
23
+ ```
24
+ ⚠️ 1 file(s) look documented but are not scraped, so they have no page:
25
+ src/thing.vue
26
+ Scraped extensions: .ts, .js, .tjs, .css. Add one to SCRAPED_SOURCE_EXTENSIONS if it should be documented.
27
+ ```
28
+
29
+ It warns rather than fails: a file may legitimately contain the sequence without wanting to be
30
+ documentation, and a doc site that refused to build over a comment would be worse than the bug.
31
+ But whatever the next extension turns out to be, its absence can no longer happen quietly.
32
+
33
+ ## 1.12.3
34
+
35
+ ### Back now has layout coverage, because a reader noticed before the tests did
36
+
37
+ Reported from the keyboard as "sometimes the full-screen page and the full-screen page with the
38
+ nav open look like different history entries". Measured, it is neither history nor
39
+ inconsistent: the nav toggle creates **no** history entry (`history.length` unchanged across
40
+ repeated toggles, URL unchanged). What happens is that **Back re-asserts the page's declared
41
+ layout** and discards the override — so leaving a page with the nav open and returning finds it
42
+ closed.
43
+
44
+ That is defensible and it is what ships: a full-screen page is full-screen whenever you arrive
45
+ at it. Whether Back should instead restore what you left is now an open decision in `TODO.md`,
46
+ with the measurements attached, rather than whatever fell out of the implementation.
47
+
48
+ The gap the report exposed was in the tests, not the feature: `doc-system.pw.ts` already covered
49
+ Back for URL, title and content, but nothing checked its effect on **layout**, and every layout
50
+ test reached its pages by clicking links. Both directions are covered now — and labelled by what
51
+ they actually do, since one is a genuine regression test (dropping `removeAttribute('data-layout')`
52
+ on navigation fails it) and the other is a contract tripwire that survives every mutation tried
53
+ against it. A test that cannot fail is usually decoration; that one is deliberate, and saying so
54
+ is the difference.
55
+
56
+ ### The dev server sends `Cache-Control: no-store` on everything
57
+
58
+ It previously sent no `Cache-Control` at all, and that is not neutral: a browser may invent a
59
+ freshness lifetime when you decline to state one, and Safari is the most willing to. So the dev
60
+ server would rebuild correctly while the browser kept serving the previous build — which
61
+ presents as "my fix did not work", and is only escaped by emptying the cache by hand. It cost
62
+ exactly that here: a bug was reported against code that had already been fixed.
63
+
64
+ All three of the file-server's exits are covered — injected HTML, compressed assets, and
65
+ binaries that stream untouched — because they are separate responses and adding the header to
66
+ the obvious one is precisely how the other two get missed. Each is independently
67
+ mutation-verified.
68
+
69
+ **Dev server only.** The built site in `docs/` is unaffected; it is a static site for a real
70
+ host, and telling a CDN never to store it would be actively wrong.
71
+
72
+ ### `layout: "full-screen"` — and `<tosi-sidenav alwaysCompact>` underneath it
73
+
74
+ `full-width` shipped in 1.12.2 and `full-screen` did not, because it needed something from the
75
+ sidenav that did not exist. It exists now, and it is one line rather than a new layout.
76
+
77
+ **`<tosi-sidenav>` gained `navVisible`** — the one control a "show me the navigation" button
78
+ needs. Read it, flip it:
79
+
80
+ ```js
81
+ sidenav.navVisible = !sidenav.navVisible
82
+ ```
83
+
84
+ It resolves what hiding the nav _means_ at the current width — forcing compact on a wide screen,
85
+ simply showing the content on a narrow one — so no caller has to know, and a narrow-screen
86
+ request does not outstay itself when the window is widened. `layout: "full-screen"` uses the
87
+ same property, so the feature is general rather than something the doc-browser knows a trick
88
+ about.
89
+
90
+ **And `alwaysCompact` underneath it.** Compact mode already shows the nav _or_ the content
91
+ and takes turns between them — which is precisely what a full-screen page wants — so this just
92
+ removes the width test rather than adding a second way to lay out. It is a named state and not a
93
+ `minSize` no viewport can reach, because the second one works and reads as a bug to the next
94
+ person.
95
+
96
+ **`layout: "full-screen"`** then means: no reading measure, no nav, no gutter, and a content area
97
+ with a real height — so a demo, a canvas or an embedded app can ask for `height: 100%` and get
98
+ it. There is a [demo page](/full-screen-demo/), and the navigation button works there: it
99
+ returns you to the normal layout rather than replacing the content with a full-screen nav, and
100
+ it **stays offered** so you can go back. Riding `compact` directly, as it used to, meant the
101
+ button vanished on its own click and stranded you in the normal layout.
102
+
103
+ Two inline styles were quietly breaking the promise. `.doc-content` receives both its
104
+ `max-width` and its `padding` inline from the doc-browser, so no stylesheet could override
105
+ either — a full-screen page rendered inset by 32px, in a box that collapsed to its content
106
+ height instead of filling. `padding` now routes through `--doc-content-padding`, the way the
107
+ measure already did.
108
+
109
+ Navigating away restores the nav, and a reader's override lasts until they navigate rather than
110
+ beyond it — **including Back**, so a full-screen page is full-screen whenever you arrive at it.
111
+ That is predictable, but it does mean leaving a page with the nav open and returning finds it
112
+ closed; whether Back should instead restore what you left is noted in `TODO.md` as a decision
113
+ rather than left to whatever fell out of the implementation. Both directions are mutation-verified, which caught a test that _looked_ like it
114
+ covered the second one and did not: it routed through a prose page, which reset the state as a
115
+ side effect.
116
+
117
+ Before hydration there is no sidenav at all, just static markup, so a stylesheet rule covers the
118
+ first paint. Those rules select `.doc-nav.doc-nav` — the class deliberately repeated — because
119
+ the pre-hydration layout further down the same sheet has identical specificity, and winning on
120
+ source order works right up until someone reorders the spec.
121
+
3
122
  ## 1.12.2
4
123
 
5
124
  ### `haltijaDev: 'tunnel'` — let an agent drive a page running in a headset (#104)
@@ -22,8 +22,8 @@ export interface Doc {
22
22
  path: string;
23
23
  pin?: string;
24
24
  hidden?: boolean;
25
- /** `'full-width'` drops the reading measure. See `Doc.layout` in docs.ts. */
26
- layout?: 'full-width';
25
+ /** `'full-width'` drops the reading measure; `'full-screen'` also puts the nav away. */
26
+ layout?: 'full-width' | 'full-screen';
27
27
  testStatus?: 'passed' | 'failed' | 'pending';
28
28
  bakes?: Array<[string, {
29
29
  dialect: string;
@@ -422,6 +422,16 @@ export function createDocBrowser(options) {
422
422
  docs,
423
423
  currentDoc,
424
424
  compact: false,
425
+ /*
426
+ Whether to OFFER the navigation button — deliberately not the same thing as `compact`.
427
+
428
+ The button used to ride `compact` directly, which is right for a responsive page: wide
429
+ enough to show the nav beside the content, and there is nothing to toggle. But opening the
430
+ nav on a full-screen page leaves compact mode by design, and the button would vanish with
431
+ it — stranding the reader in the normal layout with no way back to full screen. So it is
432
+ offered whenever the layout is compact OR the page asked to be full-screen.
433
+ */
434
+ navToggle: false,
425
435
  },
426
436
  });
427
437
  const app = state[stateKey];
@@ -576,13 +586,58 @@ export function createDocBrowser(options) {
576
586
  // sidenav')`, which returns the FIRST sidenav in the document (an OUTER instance
577
587
  // when this one is an embedded, memory-routed browser).
578
588
  let sidenav = null;
589
+ /*
590
+ `full-screen` uses the sidenav's OWN compact behaviour rather than a new state.
591
+
592
+ `<tosi-sidenav>` already does exactly what full-screen wants: compact mode shows the nav OR the
593
+ content, with `contentVisible` picking which. `alwaysCompact` keeps it in that mode at any
594
+ width, and `contentVisible` gives the page the whole box.
595
+
596
+ Remembered rather than applied directly, because the sidenav is not there yet when the first
597
+ doc renders — `sidenav` is captured from its own change event, which fires on a state CHANGE
598
+ and so may never fire at all on a normal-width page.
599
+ */
600
+ let wantsFullScreen = false;
601
+ /*
602
+ What we last PUSHED onto the sidenav, so the user's own toggle is not overwritten.
603
+
604
+ The first version applied the layout on every call, and `applyFullScreen` is called from the
605
+ sidenav's change handler — so clicking "navigation" flipped `contentVisible`, the sidenav
606
+ changed state, the handler fired, and the layout was re-applied on top. The nav button looked
607
+ live and did nothing. Applying only on TRANSITION means a full-screen page sets itself up once
608
+ and then leaves the reader alone.
609
+ */
610
+ let appliedFullScreen = null;
611
+ /** Offer the button when there is something to toggle: a compact layout, or a full-screen page. */
612
+ const refreshNavToggle = () => {
613
+ app.navToggle = (Boolean(sidenav?.compact) || wantsFullScreen);
614
+ };
615
+ const applyFullScreen = () => {
616
+ /*
617
+ Resolved from THIS browser's own container when the change event has not introduced it yet.
618
+ Scoped rather than `document.querySelector('tosi-sidenav')`, which returns the first in the
619
+ document — for a nested doc-browser, the HOST's.
620
+ */
621
+ if (!sidenav && typeof container !== 'undefined' && container) {
622
+ sidenav = container.querySelector(TosiSidenav.tagName);
623
+ }
624
+ if (!sidenav)
625
+ return;
626
+ if (appliedFullScreen === wantsFullScreen)
627
+ return;
628
+ // The same one control the nav button uses — the layout is just asking for the nav to be
629
+ // away, and the sidenav decides how that is achieved.
630
+ sidenav.navVisible = !wantsFullScreen;
631
+ appliedFullScreen = wantsFullScreen;
632
+ refreshNavToggle();
633
+ };
579
634
  const headerContent = [
580
635
  button({
581
636
  class: 'iconic',
582
637
  style: { color: vars.linkColor },
583
638
  title: 'navigation',
584
639
  bind: {
585
- value: app.compact,
640
+ value: app.navToggle,
586
641
  binding: {
587
642
  toDOM(element, compact) {
588
643
  element.style.display = compact ? '' : 'none';
@@ -593,8 +648,10 @@ export function createDocBrowser(options) {
593
648
  },
594
649
  },
595
650
  onClick() {
651
+ // Read it, flip it. `<tosi-sidenav>` resolves what that means at this width; a
652
+ // button should not have to know, and this one used to.
596
653
  if (sidenav)
597
- sidenav.contentVisible = !sidenav.contentVisible;
654
+ sidenav.navVisible = !sidenav.navVisible;
598
655
  },
599
656
  }, icons.menu()),
600
657
  span({ style: { flex: '0 0 10px' } }),
@@ -651,7 +708,13 @@ export function createDocBrowser(options) {
651
708
  // doc-browser (no doc-system stylesheet) working.
652
709
  maxWidth: 'var(--doc-content-max-width, 44em)',
653
710
  margin: 'auto',
654
- padding: '0 1em',
711
+ /*
712
+ Through a variable, for the same reason `maxWidth` is: this is an INLINE style, so a
713
+ stylesheet cannot override it, and `layout: "full-screen"` needs to. A full-screen page that
714
+ keeps a 1em gutter is not full screen — it is a demo inset by 32px, which is exactly what it
715
+ looked like.
716
+ */
717
+ padding: 'var(--doc-content-padding, 0 1em)',
655
718
  overflow: 'hidden',
656
719
  });
657
720
  // Adoption is zero-flash hydration of a statically pre-rendered page: it only
@@ -743,6 +806,25 @@ export function createDocBrowser(options) {
743
806
  else
744
807
  layoutHost.removeAttribute('data-layout');
745
808
  }
809
+ /*
810
+ `full-screen` also collapses the nav, and that has to go through the COMPONENT rather than
811
+ CSS: `<tosi-sidenav>` writes its column widths as inline properties, so a stylesheet has
812
+ nothing to override. The pre-hydration rule in doc-system-styles.ts handles the static
813
+ markup, which has a plain `<nav>` and no sidenav yet; this handles the hydrated page. Two
814
+ rules for two genuinely different DOMs, not two fixes for one bug.
815
+ */
816
+ wantsFullScreen = doc.layout === 'full-screen';
817
+ /*
818
+ Forced on every navigation. Without this, opening the nav on one full-screen page (which
819
+ turns `alwaysCompact` off) would leave the NEXT full-screen page not full-screen, because
820
+ the wish had not changed and the transition check would skip it. A reader's override is
821
+ meant to last until they navigate, not beyond it.
822
+ */
823
+ appliedFullScreen = null;
824
+ applyFullScreen();
825
+ // applyFullScreen returns early when there is no sidenav yet; the button state still has
826
+ // to follow the doc, so this does not ride on that path succeeding.
827
+ refreshNavToggle();
746
828
  }
747
829
  rewriteContentLinks();
748
830
  // Stamp each example with its source file (for the source↔doc map). doc.path
@@ -1249,6 +1331,9 @@ export function createDocBrowser(options) {
1249
1331
  sidenav = event.target.closest(TosiSidenav.tagName);
1250
1332
  if (sidenav)
1251
1333
  app.compact = sidenav.compact;
1334
+ refreshNavToggle();
1335
+ // The sidenav has just introduced itself; give it the layout the doc asked for.
1336
+ applyFullScreen();
1252
1337
  },
1253
1338
  }, searchField, navContent, div({
1254
1339
  style: {
@@ -108,6 +108,51 @@ export function docSystemStyleSpec(theme = {}) {
108
108
  'tosi-doc-system[data-layout="full-width"]': {
109
109
  _docContentMaxWidth: 'none',
110
110
  },
111
+ /*
112
+ `full-screen` — no measure, and no chrome.
113
+
114
+ These rules are for the PRE-HYDRATION page, whose markup is a plain `<nav class="doc-nav">`
115
+ with no inline styles, so hiding it here works and works on first paint. Once the bundle
116
+ loads, the nav becomes a slotted node inside `<tosi-sidenav>` carrying its own inline
117
+ `display`, and CSS can no longer touch it — the doc-browser sets `sidenav.navHidden`
118
+ instead. Different DOM before and after hydration, so genuinely two rules rather than a
119
+ belt-and-braces duplicate.
120
+ */
121
+ 'tosi-doc-system[data-layout="full-screen"]': {
122
+ _docContentMaxWidth: 'none',
123
+ },
124
+ 'tosi-doc-system[data-layout="full-screen"] .doc-content': {
125
+ padding: '0',
126
+ /*
127
+ A definite height, so a child asking for `height: 100%` gets one.
128
+
129
+ "The content is the viewport" is the promise this layout makes, and a demo, a canvas or an
130
+ embedded app is the reason to use it — all of which want to FILL, not to sit in a tall
131
+ box. Without a height here `100%` on a child resolves against `auto` and collapses to its
132
+ content.
133
+ */
134
+ height: '100%',
135
+ overflow: 'auto',
136
+ /*
137
+ The gutter goes too. `.doc-content`'s padding is an INLINE style set by the doc-browser,
138
+ so this is set through the variable it reads rather than declared here, where it would
139
+ simply lose.
140
+ */
141
+ _docContentPadding: '0',
142
+ },
143
+ /*
144
+ `.doc-nav.doc-nav` — the class twice, on purpose.
145
+
146
+ `tosi-doc-system:not(:defined) .doc-nav` (the pre-hydration layout, further down this same
147
+ sheet) has IDENTICAL specificity to the single-class form, so which one wins is decided by
148
+ source order, and it sits later. Winning on position works until someone reorders the spec,
149
+ at which point the nav quietly comes back on full-screen pages with nothing to point at.
150
+ Repeating the class costs one selector token and settles it on specificity, wherever these
151
+ rules end up living.
152
+ */
153
+ 'tosi-doc-system[data-layout="full-screen"] > .doc-nav.doc-nav, tosi-doc-system[data-layout="full-screen"] > .doc-navbar.doc-navbar': {
154
+ display: 'none',
155
+ },
111
156
  '.darkmode': {
112
157
  ...invertLuminance(colors),
113
158
  /*
@@ -190,7 +235,7 @@ export function docSystemStyleSpec(theme = {}) {
190
235
  display: 'block',
191
236
  maxWidth: vars.docContentMaxWidth,
192
237
  margin: 'auto',
193
- padding: '0 1em',
238
+ padding: 'var(--doc-content-padding, 0 1em)',
194
239
  // Must match doc-browser's inline style EXACTLY, `overflow` included: it
195
240
  // establishes a block formatting context, which stops the <h1>'s top margin
196
241
  // collapsing out of the box. Without it the static content sits 10px lower
@@ -832,6 +832,29 @@ export async function devServer(config, opts = {}) {
832
832
  });
833
833
  }
834
834
  // disk, and non-HTML assets are streamed untouched.
835
+ /*
836
+ NOTHING the dev server serves may be cached. Ever.
837
+
838
+ None of the file paths below sent `Cache-Control`, so browsers applied HEURISTIC caching —
839
+ they are free to invent a freshness lifetime when you decline to state one, and Safari is the
840
+ most willing to. The result is a dev server that rebuilds correctly while the browser keeps
841
+ showing the previous build, which presents as "the fix did not work" and costs whoever is
842
+ looking at it far more than a re-download. It cost exactly that here: a bug was reported
843
+ against code that had already been fixed.
844
+
845
+ `no-store` rather than `no-cache`: no-cache still stores and revalidates, which needs
846
+ validators we do not emit, and a dev server has nothing to gain from the round trip. The cost
847
+ is re-fetching the bundle on a full page load — a doc site navigates client-side after that,
848
+ and the responses are brotli/gzip compressed anyway.
849
+
850
+ Deliberately NOT applied to the built output: `docs/` is a static site for a real host, and
851
+ telling a CDN never to store it would be actively wrong. This is the dev server only.
852
+ */
853
+ const NO_CACHE = {
854
+ 'Cache-Control': 'no-store, must-revalidate',
855
+ Pragma: 'no-cache',
856
+ Expires: '0',
857
+ };
835
858
  async function respondFile(filePath, request, viaTunnel = false) {
836
859
  /*
837
860
  WHICH loader, decided per request rather than once at boot.
@@ -864,6 +887,7 @@ export async function devServer(config, opts = {}) {
864
887
  return new Response(body, {
865
888
  headers: {
866
889
  'Content-Type': 'text/html; charset=utf-8',
890
+ ...NO_CACHE,
867
891
  ...(encoding
868
892
  ? { 'Content-Encoding': encoding, Vary: 'Accept-Encoding' }
869
893
  : {}),
@@ -881,14 +905,16 @@ export async function devServer(config, opts = {}) {
881
905
  return new Response(bytes, {
882
906
  headers: {
883
907
  'Content-Type': file.type || 'application/octet-stream',
908
+ ...NO_CACHE,
884
909
  'Content-Encoding': encoding,
885
910
  Vary: 'Accept-Encoding',
886
911
  },
887
912
  });
888
913
  }
889
914
  // Everything else streams untouched — images, fonts, epubs and glb are already
890
- // compressed, and re-encoding them only makes them bigger.
891
- return new Response(Bun.file(filePath));
915
+ // compressed, and re-encoding them only makes them bigger. Still uncacheable: an icon or a
916
+ // font you just changed is no more welcome stale than a script is.
917
+ return new Response(Bun.file(filePath), { headers: { ...NO_CACHE } });
892
918
  }
893
919
  async function handleTestReport(request) {
894
920
  try {
@@ -45,14 +45,12 @@ export interface Doc {
45
45
  * frontmatter. Unset means the reading column, so nothing changes for a corpus that does not
46
46
  * ask.
47
47
  *
48
- * `'full-screen'` — chrome out of the way entirely — is NOT implemented yet and is rejected
49
- * rather than half-honoured. It needs `<tosi-side-nav>` to expose a collapsed state: the nav
50
- * width and the nav's own `display` are both set as INLINE styles, which a stylesheet cannot
51
- * override without `!important`, and a value that drops the measure while leaving the chrome
52
- * exactly where it was is the kind of partial success that gets reported as a bug. Tracked in
53
- * TODO.md.
48
+ * `'full-screen'` drops the measure AND puts the nav away, so the page is the whole viewport —
49
+ * for a demo, a canvas, an embedded app. It needed `<tosi-sidenav navHidden>` to exist first:
50
+ * the nav width and the nav's own `display` are both INLINE styles, so no stylesheet could
51
+ * reach them from out here.
54
52
  */
55
- layout?: 'full-width';
53
+ layout?: 'full-width' | 'full-screen';
56
54
  headTitle?: string;
57
55
  description?: string;
58
56
  keywords?: string | string[];
@@ -84,5 +82,6 @@ export declare function parseFrontmatter(content: string): {
84
82
  data: Partial<Doc>;
85
83
  body: string;
86
84
  };
85
+ export declare const SCRAPED_SOURCE_EXTENSIONS: string[];
87
86
  export declare function extractDocs(options: ExtractDocsOptions): Doc[];
88
87
  export declare function saveDocsJSON(docs: Doc[], outputPath: string): void;
@@ -150,11 +150,8 @@ export function parseFrontmatter(content) {
150
150
  `wide` — and silently keeping the reading column is the behaviour that gets reported as
151
151
  "the metadata does nothing", so say so instead.
152
152
  */
153
- if (val === 'full-width')
153
+ if (val === 'full-width' || val === 'full-screen')
154
154
  data.layout = val;
155
- else if (val === 'full-screen')
156
- console.error(`layout: full-screen is not implemented yet (see TODO.md) — ignoring it rather than ` +
157
- `dropping the measure and leaving the chrome in place`);
158
155
  else if (val)
159
156
  console.error(`unknown layout ${JSON.stringify(val)} in frontmatter`);
160
157
  }
@@ -184,6 +181,53 @@ function metadata(content, filePath) {
184
181
  delete data.title;
185
182
  return data;
186
183
  }
184
+ /*
185
+ Source extensions whose doc-comment blocks become documentation.
186
+
187
+ `.tjs` earns its place for a reason worth recording: converting `more-math.ts` to `more-math.tjs`
188
+ in tosijs SILENTLY deleted its documentation page (tosijs-ui#108). The build stayed green, and so
189
+ did the internal-link check — a page that was never generated is linked from nowhere, so "41
190
+ slugs, no 404s" is a pass. The only signal was a slug count nobody asserts on. A project mid-port
191
+ loses one page per converted module and hears about it from a reader.
192
+
193
+ The block syntax is identical across all of these, because a doc block is just a comment. There
194
+ is nothing language-specific to support; the list IS the whole feature, which is exactly why
195
+ forgetting an entry is silent.
196
+
197
+ (Written without the closing-delimiter characters spelled out, because putting them in a comment
198
+ ends it — the same trap tosijs-ui#70 records for doc blocks, hit again while writing this.)
199
+
200
+ See `warnUnscrapedDocBlocks` below: a missing extension should never again be invisible.
201
+ */
202
+ export const SCRAPED_SOURCE_EXTENSIONS = ['.ts', '.js', '.tjs', '.css'];
203
+ /** Files that look documented but are not scraped — reported once at the end of a walk. */
204
+ const unscrapedDocBlocks = [];
205
+ /**
206
+ * Does an UNSCRAPED file appear to carry a doc block?
207
+ *
208
+ * Deliberately cheap and deliberately narrow: only files we are not already reading, only
209
+ * plausible source extensions, and only the same line-starting pattern the scraper itself uses,
210
+ * so this cannot disagree with it about what a doc block is. Binary files and anything large are
211
+ * skipped — this is a safety net, not a search.
212
+ */
213
+ function hasDocBlock(filePath) {
214
+ const ext = path.extname(filePath);
215
+ if (SCRAPED_SOURCE_EXTENSIONS.includes(ext) || ext === '.md')
216
+ return false;
217
+ // Extensions worth checking: source-shaped, and not the ones we already read.
218
+ if (!/^\.[cm]?[jt]sx?$|^\.(tjs|ajs|svelte|vue|scss|sass|less)$/.test(ext)) {
219
+ return false;
220
+ }
221
+ try {
222
+ const stat = fs.statSync(filePath);
223
+ if (stat.size > 2_000_000)
224
+ return false;
225
+ return /^[ \t]*\/\*#/m.test(fs.readFileSync(filePath, 'utf8'));
226
+ }
227
+ catch {
228
+ return false; // unreadable is not our business here
229
+ }
230
+ }
187
231
  function findMarkdownFiles(paths, ignore) {
188
232
  const markdownFiles = [];
189
233
  const truncationFound = [];
@@ -226,7 +270,22 @@ function findMarkdownFiles(paths, ignore) {
226
270
  ...fm, // frontmatter wins over JSON-comment metadata + the H1
227
271
  });
228
272
  }
229
- else if (['.ts', '.js', '.css'].includes(path.extname(file))) {
273
+ else if (hasDocBlock(filePath)) {
274
+ /*
275
+ A file we do NOT scrape, carrying what looks like a doc block.
276
+
277
+ This is the guard for the whole class, not just for `.tjs`. The extension list is the
278
+ entire feature, so an omission from it is invisible by construction: the page is simply
279
+ never generated, nothing links to it, and the link check passes. tosijs lost a page per
280
+ ported module this way and found out from a reader (tosijs-ui#108).
281
+
282
+ Warn rather than fail. A file may legitimately contain the sequence without wanting to be
283
+ documentation, and a doc site that refuses to build over a comment would be worse than
284
+ the bug. But it can no longer happen in silence.
285
+ */
286
+ unscrapedDocBlocks.push(path.relative(process.cwd(), filePath));
287
+ }
288
+ else if (SCRAPED_SOURCE_EXTENSIONS.includes(path.extname(file))) {
230
289
  const content = fs.readFileSync(filePath, 'utf8');
231
290
  // A /*# … */ block is only a doc when it STARTS a line (whitespace-only
232
291
  // before the slash). This keeps a `/*#` that appears inside a // comment,
@@ -284,6 +343,21 @@ function findMarkdownFiles(paths, ignore) {
284
343
  if (truncationFound.length) {
285
344
  console.warn('\n' + formatTruncationWarnings(truncationFound));
286
345
  }
346
+ /*
347
+ Same policy, different failure: a file that looks documented and is not being read.
348
+
349
+ Named individually rather than counted, because the useful version of this message is one you
350
+ can act on without going looking. The fix is nearly always one entry in
351
+ SCRAPED_SOURCE_EXTENSIONS.
352
+ */
353
+ if (unscrapedDocBlocks.length) {
354
+ const list = unscrapedDocBlocks.map((f) => ` ${f}`).join('\n');
355
+ console.warn(`\n⚠️ ${unscrapedDocBlocks.length} file(s) look documented but are not scraped, so ` +
356
+ `they have no page:\n${list}\n` +
357
+ ` Scraped extensions: ${SCRAPED_SOURCE_EXTENSIONS.join(', ')}. ` +
358
+ `Add one to SCRAPED_SOURCE_EXTENSIONS if it should be documented.\n`);
359
+ unscrapedDocBlocks.length = 0; // a walk reports its own findings, not the previous one's
360
+ }
287
361
  return markdownFiles.sort(pinnedSort);
288
362
  }
289
363
  export function extractDocs(options) {
@@ -60,7 +60,14 @@ function entriesFromSrcScan() {
60
60
  const entries = [];
61
61
  let files;
62
62
  try {
63
- files = fs.readdirSync(SRC).filter((f) => f.endsWith('.ts'));
63
+ /*
64
+ `.tjs` too — and note `'x.tjs'.endsWith('.ts')` is FALSE, so the old test excluded it
65
+ silently rather than catching it by accident. Same root as tosijs-ui#108: a module ported
66
+ from `.ts` to `.tjs` dropped out of both the doc corpus and this index at once.
67
+ */
68
+ files = fs
69
+ .readdirSync(SRC)
70
+ .filter((f) => f.endsWith('.ts') || f.endsWith('.tjs'));
64
71
  }
65
72
  catch {
66
73
  return entries;