tosijs-ui 1.16.6 → 1.16.8

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/doc-browser.d.ts +2 -1
  3. package/dist/doc-browser.js +138 -52
  4. package/dist/doc-system/code-fences.d.ts +5 -0
  5. package/dist/doc-system/code-fences.js +41 -0
  6. package/dist/doc-system/doc-system.d.ts +2 -1
  7. package/dist/doc-system/doc-system.js +13 -1
  8. package/dist/doc-system/site/build-warnings.d.ts +23 -0
  9. package/dist/doc-system/site/build-warnings.js +45 -0
  10. package/dist/doc-system/site/check-examples.js +2 -32
  11. package/dist/doc-system/site/dev-server.js +12 -2
  12. package/dist/doc-system/site/generate-site.d.ts +7 -0
  13. package/dist/doc-system/site/generate-site.js +34 -16
  14. package/dist/doc-system/site/index.d.ts +3 -0
  15. package/dist/doc-system/site/index.js +6 -0
  16. package/dist/doc-system/site/make-llms-txt.d.ts +9 -0
  17. package/dist/doc-system/site/make-llms-txt.js +14 -2
  18. package/dist/doc-system/site/orchestrator.js +84 -16
  19. package/dist/doc-system/site/site-config.d.ts +19 -0
  20. package/dist/doc-system/site/test-report.d.ts +13 -0
  21. package/dist/doc-system/site/test-report.js +33 -0
  22. package/dist/doc-system/test-completion.d.ts +43 -0
  23. package/dist/doc-system/test-completion.js +54 -0
  24. package/dist/iife.js +104 -102
  25. package/dist/iife.js.map +13 -12
  26. package/dist/live-example/code-transform.d.ts +23 -2
  27. package/dist/live-example/code-transform.js +61 -7
  28. package/dist/live-example/component.d.ts +37 -0
  29. package/dist/live-example/component.js +131 -4
  30. package/dist/live-example/execution.js +3 -1
  31. package/dist/live-example/styles.d.ts +29 -0
  32. package/dist/live-example/styles.js +34 -0
  33. package/dist/live-example/test-harness.js +2 -2
  34. package/dist/live-example/types.d.ts +11 -4
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/llms.txt +5 -1
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,149 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.8
4
+
5
+ A patch for honest results. The doc-test lane can no longer pass over pages that never
6
+ reported, three more of tjs-lang's findings are fixed, and an example that only logs now
7
+ shows what it logged. Two things to know on upgrade: `ts` examples lose tjs's runtime
8
+ parameter guards (below), and one thing may newly go red: a doc-test
9
+ run that was silently losing pages will now fail and name them. `dist/iife.js` grows by
10
+ 1.0 kB gzip (484.0 → 485.0 kB).
11
+
12
+ ### Three more from tjs-lang's sweep of its own site (#210)
13
+
14
+ - **Inline tjs tests ran with plain-JavaScript semantics**, so passing tests were shown
15
+ failing: `"hello world".capitalize is not a function` for a local `extend`, `Expected false
16
+ but got true` for a boxed boolean. This release prepares the fix and tjs-lang delivers it:
17
+ an example's inline tests now use the runner `tjs()` itself returns, and from the tjs-lang
18
+ release after 0.14.0-rc.2 that runner is built from the rewritten test bodies. On 0.13.13,
19
+ which this release still pins, nothing changes.
20
+ - **The `ts` dialect overrode converted TypeScript's semantics.** It passed `dialect: 'tjs'`
21
+ to `tjs()`, which outranks the annotation `fromTS` writes, so a TS example's
22
+ `new Calculator(…)` was refused as native TJS. It now passes no dialect. **This changes
23
+ what every `ts` example does at run time:** converted TypeScript now has JavaScript's
24
+ semantics, so the runtime parameter guards are gone too. `add('a', 2)` with
25
+ `(a: number, b: number)` returned a type error before and returns `'a2'` now, as it would
26
+ in TypeScript. `tjs` examples are unaffected.
27
+ - **An example whose output is only `console.log` was an empty box.** When a run finishes with
28
+ nothing rendered into its preview, the lines it logged (and its inline-test summary, when
29
+ one was run) are shown where the preview would be. It steps aside when the example renders
30
+ anything, never writes into `preview`, and follows the existing console switches. Inline
31
+ examples only: an `:iframe` example's output stays in its Console tab.
32
+
33
+ Not in this release: examples written as modules (`export const x = …`) still fail. Stripping
34
+ `export` safely needs a JavaScript parser, which tosijs-ui does not ship; tjs-lang already
35
+ bundles one, so the ask is with them to export their stripper.
36
+
37
+ ### The doc-test lane can no longer pass over pages that reported nothing (#2874)
38
+
39
+ The background test runner counted a page as tested when it finished or timed out, whether or
40
+ not any results arrived, and the lane passed on "0 failed". A run once passed with results for
41
+ 7 of 19 pages.
42
+
43
+ - A page with test blocks that reports no tests, or does not finish within 45 seconds, is now
44
+ recorded as a failed test naming the page and the reason. A page that timed out after some
45
+ of its examples had reported was still recorded as a pass in the first cut of this fix; a
46
+ timeout now fails the page whatever had reported.
47
+ - The parent waited 30 seconds per page, the same as the frame's own stall timeout, so a
48
+ stall report could arrive just after the parent moved on. The page budget is now 45s.
49
+ - Which pages have tests is decided by parsing the markdown the way the page is rendered. A
50
+ ` ```test ` line shown inside a longer or `:static` block no longer makes a page count as
51
+ one with tests.
52
+ - `bun run test-browser` now exits non-zero unless every page with tests has recorded results,
53
+ and prints how many pages its count covers.
54
+ - The page a run starts on is tested in place, by its own code path. That path replaced the
55
+ page's results each time one example finished, so the last example was the whole page and
56
+ a failing earlier one could vanish; and it marked the page tested on a timer. It now adds
57
+ its examples up and waits for all of them, by the same rule the iframes use. A deployed
58
+ (non-localhost) page with tests enabled follows the same rule.
59
+ - The runner accepted `tosi-test-results` messages from any window on the page; it now
60
+ requires its own test frame as the source.
61
+ - `testReportVerdict` is exported from `tosijs-ui/site` for adopters' own gates. A gate that
62
+ checks only `failed === 0` should also require results for every page with tests.
63
+
64
+ ## 1.16.7
65
+
66
+ A patch for readers that are not browsers, and for builds that were too polite. Every doc page
67
+ now also ships as markdown, for LLM fetchers that fail on ordinary HTML. Two misconfigurations
68
+ tjs-lang found the hard way now say so, and `strict: true` turns the build's warnings into
69
+ failures. Nothing to change on upgrade: `markdownPages` is on by default and adds files only;
70
+ `strict` is off by default. `dist/iife.js` grows by 0.3 kB gzip (483.7 → 484.0 kB).
71
+
72
+ ### Every doc page also ships as markdown
73
+
74
+ The site build now writes each page's markdown source beside its HTML, at
75
+ `/{slug}/index.md`, links it from the page with
76
+ `<link rel="alternate" type="text/markdown" href="index.md">`, and says so in `llms.txt`.
77
+ It is for readers that want the text and not the page: an LLM's URL fetcher, a script, `curl`.
78
+ Prompted by one such fetcher reporting "no `<body>`" for a page whose whole article was in the
79
+ static HTML.
80
+
81
+ - On by default; `markdownPages: false` in the site config ships HTML only. It adds one file
82
+ per page: on this repo's site, 70 files and 0.5 MB, about 3% of the output.
83
+ - The file is the page's source as written, so HTML comments and metadata blocks in a doc
84
+ are served as text. If a page's source holds something its rendered page does not show,
85
+ mark it `noindex` or turn this off.
86
+ - A `noindex` page gets neither the file nor the link.
87
+ - The build now writes `.nojekyll` whenever markdown pages are on, not only under
88
+ `host: 'github-pages'`. Without it, GitHub Pages' Jekyll would render `index.md` over the
89
+ real page. The file is empty and harmless on other hosts.
90
+
91
+ ### Misconfigurations the build and the page used to accept in silence (#210)
92
+
93
+ Two reported by tjs-lang, whose site ran every example against the wrong tjs-lang for weeks
94
+ without being told.
95
+
96
+ - **The same-origin tjs-lang copy was skipped without a word.** When `tjs-lang/browser`
97
+ cannot be resolved, nothing is written to `/tjs/`, and `tjs`/`ts` examples load tjs-lang
98
+ from a CDN at the version tosijs-ui pins. The build now warns when the corpus has live
99
+ `tjs` or `ts` examples and that happens. A corpus with none stays quiet.
100
+ - **A custom `bundleEntry` silently dropped what examples import.** It replaces the bundle
101
+ that set the `xinjs`/`xinjsui` globals, so `import { elements } from 'tosijs'` in an example
102
+ failed with "Cannot destructure property 'elements' of 'tosijs'". Examples now get `tosijs`
103
+ from the doc system's own copy. `tosijs-ui` still has to be supplied by the entry (importing
104
+ it here would put every component in every doc site's bundle), and an example that imports
105
+ it without one now fails with a message naming the module and the two lines that fix it,
106
+ whether it is met in the example or its tests. The cost of the
107
+ `tosijs` fallback is 0.3 kB gzip on this site's own bundle.
108
+
109
+ ### `strict: true` makes the build fail on what it otherwise warns about
110
+
111
+ A new site-config option, default `false`. With it, `buildSite` throws
112
+ `SiteMisconfiguredError` when `bundleEntry` does not register `<tosi-doc-system>`, when the
113
+ corpus has executable fences and `bundleEntry` does not register `<tosi-example>`, or when
114
+ the tjs-lang copy above is skipped on a corpus that needs it. Warning stays the default
115
+ because each is a legitimate thing to do on purpose; this is for a project that never does.
116
+ Asked for by tosijs-product, whose inert-bundle blocker reached a release candidate under a
117
+ green build. `SiteMisconfiguredError` is exported from `tosijs-ui/site`, so a caller can tell a
118
+ refused misconfiguration from a crash.
119
+
120
+ Whether a corpus "has executable fences" is now decided by the same rule the page uses, under
121
+ the site's `liveExamples` policy. The build's warning used a narrower pattern of its own: it
122
+ counted a bare `js` fence on an opt-in site, and missed `js:iframe`, `html`, `css`, `~~~`
123
+ fences and registered dialects. `llms.txt`'s note about live examples follows the same rule.
124
+
125
+ ### Found by the pre-tag review, fixed before release
126
+
127
+ None of these ever shipped; they are listed so the notes account for every fix in the release.
128
+
129
+ - `liveFenceLanguages` found no fences in a CRLF corpus and disagreed with the renderer on
130
+ blockquoted, indented and commented-out fences, because it scanned lines. It now reads
131
+ marked's tokens through `collectCodeTokens`, shared with check-examples.
132
+ - The missing-module error covered the example only; tests and inline tjs tests still got the
133
+ destructuring error. They now go through `rewriteContextImports`, and the check matches a
134
+ real import statement, not a commented-out one.
135
+ - The Console REPL keeps plain `rewriteImports`: the checking variant threw outside the `try`
136
+ that prints REPL errors, so a line importing an unsupplied module printed nothing.
137
+ - A failed copy of the tjs-lang bundles shared a `catch` with resolution and would have been
138
+ reported as "could not be resolved"; only resolution is caught now.
139
+
140
+ ### `<meta charset>` is back in the first 1024 bytes of every page
141
+
142
+ Two explanatory HTML comments at the top of each generated page's `<head>` had pushed the
143
+ charset declaration to about byte 1550; the HTML spec wants it within the first 1024. The
144
+ comments now live in the generator's source, and charset and viewport lead the head. Every
145
+ page is about 1.4 kB smaller.
146
+
3
147
  ## 1.16.6
4
148
 
5
149
  A one-fix patch: a link to the page you are already on no longer adds a history entry that Back
@@ -1,5 +1,6 @@
1
1
  import { TestResults } from './live-example/test-harness.js';
2
- export declare const hasTestBlock: (text: string | undefined) => boolean;
2
+ import { type ExamplePolicy } from './doc-system/example-policy.js';
3
+ export declare const hasTestBlock: (text: string | undefined, policy?: ExamplePolicy) => boolean;
3
4
  export interface PageTestResults {
4
5
  passed: boolean;
5
6
  tests: TestResults['tests'];
@@ -156,10 +156,12 @@ import { buildSlugMap, pathForSlug, filenameForPath, } from './doc-system/routin
156
156
  import { buildNavTree } from './doc-system/nav-tree.js';
157
157
  import { renderDocMarkdown } from './doc-system/render.js';
158
158
  import { pageTitle } from './doc-system/doc-title.js';
159
- import { unsettledExamples } from './doc-system/test-completion.js';
159
+ import { unsettledExamples, tallyExamples, closePage, } from './doc-system/test-completion.js';
160
160
  import { LiveExample, testManager, pageTestCount, enableTests, disableTests, } from './live-example.js';
161
161
  import { highlightBlocks } from './doc-system/highlight.js';
162
162
  import { examplePolicy } from './live-example/insert-examples.js';
163
+ import { collectCodeTokens } from './doc-system/code-fences.js';
164
+ import { isLiveFence } from './doc-system/example-policy.js';
163
165
  import { tosiSidenav, TosiSidenav } from './side-nav.js';
164
166
  import { icons } from './icons.js';
165
167
  import { tosiLocalized } from './localize.js';
@@ -183,8 +185,17 @@ standalone run reporting "62 passed" against a build the full suite failed on.
183
185
  A fence is only a fence at the start of a line, which is the rule the doc extractor already
184
186
  applies to `/*#`. `[ \t]*` allows the indented fences that appear inside list items.
185
187
  */
186
- const TEST_FENCE = /^[ \t]*```test[ \t]*$/m;
187
- export const hasTestBlock = (text) => typeof text === 'string' && TEST_FENCE.test(text);
188
+ /*
189
+ …and a line-anchored regex was not the rule either (#2874). A ` ```test ` line SHOWN inside a
190
+ longer or `:static` block matched it, so a page that documents the test tier was counted as a
191
+ page with tests, ran in a background iframe, reported nothing, and was counted as tested. The
192
+ lane said "19 of 19 pages" over results for 18. Code blocks now come from marked, the parser
193
+ the page is rendered with, and liveness from `isLiveFence`, under the site's policy.
194
+ */
195
+ export const hasTestBlock = (text, policy = 'auto') => typeof text === 'string' &&
196
+ // Cheap reject first: lexing every doc in a large corpus is not free.
197
+ text.includes('test') &&
198
+ collectCodeTokens(text).some((t) => t.lang === 'test' && isLiveFence('test', t.mode, policy));
188
199
  const { div, span, a, header, button, template, input, h2, img, details, summary, ul, li, } = elements;
189
200
  /*
190
201
  Test colors, and the text color that goes ON each — because contrast is a PAIR.
@@ -498,10 +509,15 @@ export function createDocBrowser(options) {
498
509
  let refreshNav = () => { };
499
510
  // Test result tracking
500
511
  const pageTestResults = {};
512
+ // The per-example results behind the entry for the page the reader is on.
513
+ let liveResults = new Map();
514
+ let liveResultsFile = '';
501
515
  let testResultsResolve;
502
516
  let backgroundTestsStarted = false;
503
517
  let pagesWithTests = 0;
504
518
  let pagesTested = 0;
519
+ // Per-page budget for the background runner; see the wait loop for why it exceeds 30s.
520
+ const PAGE_DEADLINE_MS = 45_000;
505
521
  // Set up global promise for scriptable browser integration. A memory-routed
506
522
  // (embedded) browser must not clobber the host page's global.
507
523
  if (!memoryRouting) {
@@ -554,15 +570,93 @@ export function createDocBrowser(options) {
554
570
  const handleTestComplete = (event) => {
555
571
  const { results } = event.detail;
556
572
  const filename = String(app.currentDoc.filename);
557
- // Reset page results each time (don't accumulate across reloads)
558
- pageTestResults[filename] = {
559
- passed: results.failed === 0,
560
- tests: [...results.tests],
561
- totalPassed: results.passed,
562
- totalFailed: results.failed,
563
- };
573
+ /*
574
+ One entry per EXAMPLE, added up — `testcomplete` fires once for each. This used to
575
+ replace the page's entry on every event, so the last example to finish was the whole
576
+ page: a failing first example followed by a passing second one reported green.
577
+
578
+ Keyed by element, so an example that re-runs (an edit, a refresh) replaces its own
579
+ result and nothing else's. A different page starts a fresh tally, and elements no longer
580
+ in the document are dropped first.
581
+ */
582
+ if (liveResultsFile !== filename) {
583
+ liveResultsFile = filename;
584
+ liveResults = new Map();
585
+ }
586
+ // Navigating away and back re-creates the page's examples; the old elements are gone
587
+ // from the document and their results must go with them, or the page counts double.
588
+ for (const element of liveResults.keys()) {
589
+ if (!element.isConnected)
590
+ liveResults.delete(element);
591
+ }
592
+ liveResults.set(event.detail.element, results);
593
+ pageTestResults[filename] = tallyExamples(liveResults.values());
564
594
  updateDocTestStatus(filename);
565
595
  };
596
+ /*
597
+ When has the page now rendered in THIS document finished its tests? Resolves with the
598
+ examples that never settled (empty when all did).
599
+
600
+ One definition, used by a test iframe to signal its parent, by the page the reader is on,
601
+ and by a deployed page — the latter two used to be a flat timer, which marked a page tested
602
+ whether or not anything had reported.
603
+
604
+ The census is only as good as the DOM it reads. Examples are inserted into the page as it
605
+ renders, so a census taken while insertion is still in progress sees FEWER examples than the
606
+ page has — and an example that appears afterwards was never waited on, never reported, and
607
+ never missed. So the count must be observed to STOP GROWING before "done" means anything:
608
+ two consecutive equal censuses, and a third example arriving later restarts the requirement.
609
+ */
610
+ const whenPageSettled = (deadlineMs) => new Promise((resolve) => {
611
+ const startedAt = Date.now();
612
+ let previousCount = -1;
613
+ let stableCensuses = 0;
614
+ const census = () => {
615
+ const examples = [...container.querySelectorAll('tosi-example')];
616
+ if (examples.length === previousCount)
617
+ stableCensuses++;
618
+ else
619
+ stableCensuses = 0;
620
+ previousCount = examples.length;
621
+ const stalled = unsettledExamples(examples.map((ex) => ({
622
+ element: ex,
623
+ test: ex.test,
624
+ hasTests: ex.classList.contains('-has-tests'),
625
+ testRunning: ex.classList.contains('-test-running'),
626
+ })));
627
+ const settling = stalled.length > 0 || stableCensuses < 2;
628
+ if (settling && Date.now() - startedAt < deadlineMs) {
629
+ setTimeout(census, 100);
630
+ return;
631
+ }
632
+ resolve(stalled.map(({ element }) => element));
633
+ };
634
+ // Give the page a moment to insert its examples before the first census.
635
+ setTimeout(census, 500);
636
+ });
637
+ /*
638
+ The page the reader is on runs its tests in place. Wait for them the way a test iframe is
639
+ waited for, then close the entry by the same rule (`closePage`): examples that never
640
+ settled, or no results at all, are failures with a name.
641
+ */
642
+ const settleCurrentPage = async (filename) => {
643
+ const stalled = await whenPageSettled(PAGE_DEADLINE_MS);
644
+ /*
645
+ If the reader navigated away, the census was of ANOTHER page and says nothing about this
646
+ one. Whatever this page had reported stands, and if it reported nothing no failure is
647
+ invented for it: the report then has one page fewer than it should, which is the honest
648
+ statement ("we did not look") and is what `testReportVerdict` fails on.
649
+ */
650
+ if (String(app.currentDoc.filename) === filename) {
651
+ pageTestResults[filename] = closePage(pageTestResults[filename], {
652
+ timedOut: false,
653
+ deadlineMs: PAGE_DEADLINE_MS,
654
+ stalled: stalled.length,
655
+ });
656
+ updateDocTestStatus(filename);
657
+ }
658
+ markPageTested(filename);
659
+ };
566
660
  // Track when a page finishes loading all its tests
567
661
  const markPageTested = (_filename) => {
568
662
  pagesTested++;
@@ -1769,36 +1863,7 @@ export function createDocBrowser(options) {
1769
1863
  if (isTestFrame && testFrameFilename) {
1770
1864
  // Generous: this bounds a hung example, it does not pace a slow one.
1771
1865
  const STALL_TIMEOUT_MS = 30000;
1772
- const startedAt = Date.now();
1773
- /*
1774
- The census is only as good as the DOM it reads. Examples are inserted into the page as
1775
- it renders, so a census taken while insertion is still in progress sees FEWER examples
1776
- than the page has — and an example that appears afterwards was never waited on, never
1777
- reported, and never missed. That is the same silent-loss failure as the bug above, one
1778
- step earlier, so the count must be observed to STOP GROWING before "done" means
1779
- anything. Two consecutive equal censuses is enough; a third example arriving later
1780
- restarts the requirement.
1781
- */
1782
- let previousCount = -1;
1783
- let stableCensuses = 0;
1784
- const signalDone = () => {
1785
- const examples = [...container.querySelectorAll('tosi-example')];
1786
- if (examples.length === previousCount)
1787
- stableCensuses++;
1788
- else
1789
- stableCensuses = 0;
1790
- previousCount = examples.length;
1791
- const stalled = unsettledExamples(examples.map((ex) => ({
1792
- element: ex,
1793
- test: ex.test,
1794
- hasTests: ex.classList.contains('-has-tests'),
1795
- testRunning: ex.classList.contains('-test-running'),
1796
- })));
1797
- const settling = stalled.length > 0 || stableCensuses < 2;
1798
- if (settling && Date.now() - startedAt < STALL_TIMEOUT_MS) {
1799
- setTimeout(signalDone, 100);
1800
- return;
1801
- }
1866
+ void whenPageSettled(STALL_TIMEOUT_MS).then((stalled) => {
1802
1867
  /*
1803
1868
  Past the deadline with examples still unsettled: report them as failures rather than
1804
1869
  signalling a done that omits them. An example that never finishes is a real defect —
@@ -1812,7 +1877,7 @@ export function createDocBrowser(options) {
1812
1877
  results: {
1813
1878
  passed: 0,
1814
1879
  failed: stalled.length,
1815
- tests: stalled.map(({ element }) => ({
1880
+ tests: stalled.map((element) => ({
1816
1881
  name: `example never finished running (${STALL_TIMEOUT_MS}ms)`,
1817
1882
  passed: false,
1818
1883
  error: `The example's code did not complete, so its test block never ran. Its source begins: ${String(element.js ?? '').slice(0, 120)}`,
@@ -1821,9 +1886,7 @@ export function createDocBrowser(options) {
1821
1886
  }, '*');
1822
1887
  }
1823
1888
  window.parent.postMessage({ type: 'tosi-tests-done', filename: testFrameFilename }, '*');
1824
- };
1825
- // Give the page a moment to insert its examples before taking the census.
1826
- setTimeout(signalDone, 500);
1889
+ });
1827
1890
  }
1828
1891
  // Background test runner for all doc pages
1829
1892
  const runBackgroundTests = async () => {
@@ -1847,7 +1910,7 @@ export function createDocBrowser(options) {
1847
1910
  exits 0. So a runtime error in an example on a page with no ` ```test ` block is caught by
1848
1911
  nothing. Tracked as its own issue rather than papered over by making this predicate loose.
1849
1912
  */
1850
- const docsWithTests = docs.filter((doc) => hasTestBlock(doc.text));
1913
+ const docsWithTests = docs.filter((doc) => hasTestBlock(doc.text, examplePolicy()));
1851
1914
  pagesWithTests = docsWithTests.length;
1852
1915
  if (pagesWithTests > 0) {
1853
1916
  setTestWidgetRunning();
@@ -1895,6 +1958,9 @@ export function createDocBrowser(options) {
1895
1958
  const messageHandler = (event) => {
1896
1959
  if (event.data?.type !== 'tosi-test-results')
1897
1960
  return;
1961
+ // Only from OUR test frame: any other window on the page could otherwise post results.
1962
+ if (event.source !== testFrame.contentWindow)
1963
+ return;
1898
1964
  const { filename, results } = event.data;
1899
1965
  if (!pageTestResults[filename]) {
1900
1966
  pageTestResults[filename] = {
@@ -1922,11 +1988,15 @@ export function createDocBrowser(options) {
1922
1988
  routing === 'path'
1923
1989
  ? `${window.location.origin}${hrefFor(doc.filename)}?_testMode=1`
1924
1990
  : `${window.location.origin}${window.location.pathname}?${doc.filename}&_testMode=1`;
1925
- // Wait for the iframe to signal it's done (max 30s per page)
1991
+ // Wait for the iframe to signal it's done. LONGER than the frame's own 30s stall
1992
+ // timeout, so a frame that gives up on a hung example gets to say so: at the same 30s
1993
+ // the two raced, and the parent could move on a moment before the stall report landed.
1994
+ let timedOut = false;
1926
1995
  await new Promise((resolve) => {
1927
- const deadline = Date.now() + 30_000;
1996
+ const deadline = Date.now() + PAGE_DEADLINE_MS;
1928
1997
  const onDone = (event) => {
1929
- if (event.data?.type === 'tosi-tests-done' &&
1998
+ if (event.source === testFrame.contentWindow &&
1999
+ event.data?.type === 'tosi-tests-done' &&
1930
2000
  event.data.filename === doc.filename) {
1931
2001
  window.removeEventListener('message', onDone);
1932
2002
  resolve();
@@ -1935,17 +2005,24 @@ export function createDocBrowser(options) {
1935
2005
  window.addEventListener('message', onDone);
1936
2006
  setTimeout(() => {
1937
2007
  window.removeEventListener('message', onDone);
2008
+ timedOut = true;
1938
2009
  resolve();
1939
2010
  }, deadline - Date.now());
1940
2011
  });
2012
+ // A page that timed out fails even if some of its examples had reported (closePage).
2013
+ pageTestResults[doc.filename] = closePage(pageTestResults[doc.filename], {
2014
+ timedOut,
2015
+ deadlineMs: PAGE_DEADLINE_MS,
2016
+ });
2017
+ updateDocTestStatus(doc.filename);
1941
2018
  markPageTested(doc.filename);
1942
2019
  }
1943
2020
  // Clean up
1944
2021
  window.removeEventListener('message', messageHandler);
1945
2022
  testFrame.remove();
1946
- // Mark current page as tested if it has tests
2023
+ // The page the reader is on ran its tests in place; wait for them and close its entry.
1947
2024
  if (docsWithTests.some((d) => d.filename === currentFilename)) {
1948
- setTimeout(() => markPageTested(currentFilename), 1000);
2025
+ void settleCurrentPage(currentFilename);
1949
2026
  }
1950
2027
  };
1951
2028
  // Run background tests when enabled (initially or when toggled on)
@@ -1956,11 +2033,20 @@ export function createDocBrowser(options) {
1956
2033
  setTimeout(runBackgroundTests, 1000);
1957
2034
  }
1958
2035
  else {
1959
- const currentHasTests = currentDoc.text.includes('```test');
2036
+ /*
2037
+ The page showing NOW, not the one this browser was constructed on. Tests are toggled
2038
+ from the menu, often after navigating, and the construction-time doc then named a page
2039
+ that is no longer on screen: it was judged by a census of some other page and recorded
2040
+ as "reported no tests" without ever having run (1.16.8 re-review, F1).
2041
+ */
2042
+ const showing = String(app.currentDoc.filename);
2043
+ const showingDoc = docs.find((d) => d.filename === showing);
2044
+ const currentHasTests = hasTestBlock(showingDoc?.text, examplePolicy());
1960
2045
  if (currentHasTests) {
1961
2046
  pagesWithTests = 1;
1962
2047
  setTestWidgetRunning();
1963
- setTimeout(() => markPageTested(currentDoc.filename), 2000);
2048
+ // Not a flat timer: the same settle-and-close rule as on localhost.
2049
+ void settleCurrentPage(showing);
1964
2050
  }
1965
2051
  else if (testResultsResolve) {
1966
2052
  testResultsResolve({
@@ -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,41 @@
1
+ /*
2
+ THE way to enumerate the code blocks in a doc, build and client alike: 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: every caller asks marked. The
8
+ doc-browser's test runner asks here too (`hasTestBlock`), for the same reason.
9
+ */
10
+ import { marked } from 'marked';
11
+ import { parseFenceInfo } from './example-policy.js';
12
+ /*
13
+ Fence info is parsed by `parseFenceInfo`, the one copy of that grammar. This file had its own
14
+ `dialectOf`/`modeOf` pair; once fences could carry JSON options (#184) `modeOf` would have read
15
+ `{"debug":true}` as the mode `true`.
16
+ */
17
+ export function collectCodeTokens(text) {
18
+ const out = [];
19
+ const walk = (tokens) => {
20
+ for (const t of tokens) {
21
+ /*
22
+ Keep the MODE. `dialectOf` reduced `js:static` to `js`, so the checker could not tell a
23
+ block that will never run from one that will — and hard-failed a build over the syntax
24
+ of illustrative code, advising the author to retag it as `typescript`, which is exactly
25
+ the mislabelling this release exists to abolish (review major M2).
26
+ */
27
+ if (t.type === 'code')
28
+ out.push({
29
+ lang: parseFenceInfo(t.lang ?? '').lang,
30
+ text: t.text,
31
+ mode: parseFenceInfo(t.lang ?? '').mode,
32
+ });
33
+ if (Array.isArray(t.tokens))
34
+ walk(t.tokens);
35
+ if (Array.isArray(t.items))
36
+ walk(t.items); // list items
37
+ }
38
+ };
39
+ walk(marked.lexer(text));
40
+ return out;
41
+ }
@@ -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
+ }