tosijs-ui 1.7.1 → 1.7.3

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 (59) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/code-editor-cm.js +5 -1
  3. package/dist/color-input.d.ts +0 -1
  4. package/dist/color-input.js +24 -10
  5. package/dist/doc-browser.js +4 -2
  6. package/dist/doc-system/doc-system-styles.d.ts +2 -0
  7. package/dist/doc-system/doc-system-styles.js +15 -1
  8. package/dist/doc-system/site/check-examples-cli.js +5 -3
  9. package/dist/doc-system/site/check-examples.d.ts +13 -0
  10. package/dist/doc-system/site/check-examples.js +26 -10
  11. package/dist/doc-system/site/dev-server.d.ts +11 -0
  12. package/dist/doc-system/site/dev-server.js +15 -2
  13. package/dist/doc-system/site/generate-site.d.ts +18 -0
  14. package/dist/doc-system/site/generate-site.js +42 -10
  15. package/dist/doc-system/site/orchestrator.js +15 -2
  16. package/dist/doc-system/site/site-config.d.ts +10 -3
  17. package/dist/form.d.ts +0 -1
  18. package/dist/form.js +35 -21
  19. package/dist/hydrate/{ajs-language-h1x0ydap.js → ajs-language-qm5w38ae.js} +3 -3
  20. package/dist/hydrate/{ajs-language-h1x0ydap.js.map → ajs-language-qm5w38ae.js.map} +1 -1
  21. package/dist/hydrate/{code-editor-cm-2hgj9002.js → code-editor-cm-95n31m4f.js} +8 -8
  22. package/dist/hydrate/{code-editor-cm-2hgj9002.js.map → code-editor-cm-95n31m4f.js.map} +3 -3
  23. package/dist/hydrate/hydrate.js +82 -82
  24. package/dist/hydrate/hydrate.js.map +15 -14
  25. package/dist/hydrate/{index-nsmyq9mv.js → index-0ps8jzdn.js} +3 -3
  26. package/dist/hydrate/{index-nsmyq9mv.js.map → index-0ps8jzdn.js.map} +1 -1
  27. package/dist/hydrate/{index-iife-64z9yngn.js → index-iife-415dypjp.js} +3 -3
  28. package/dist/hydrate/{index-iife-64z9yngn.js.map → index-iife-415dypjp.js.map} +1 -1
  29. package/dist/hydrate/index-iife-kwhe0yt8.js +27 -0
  30. package/dist/hydrate/{index-iife-5wzyvyzm.js.map → index-iife-kwhe0yt8.js.map} +2 -2
  31. package/dist/hydrate/{index-iife-x2h4f6fc.js → index-iife-ype8sch3.js} +3 -3
  32. package/dist/hydrate/{index-iife-x2h4f6fc.js.map → index-iife-ype8sch3.js.map} +1 -1
  33. package/dist/hydrate/{tjs-import-resolver-akt3k27z.js → tjs-import-resolver-s4z0w296.js} +2 -2
  34. package/dist/iife.js +114 -114
  35. package/dist/iife.js.map +16 -15
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/live-example/code-transform.d.ts +14 -0
  39. package/dist/live-example/code-transform.js +20 -2
  40. package/dist/live-example/component.d.ts +32 -52
  41. package/dist/live-example/component.js +44 -51
  42. package/dist/live-example/styles.d.ts +30 -51
  43. package/dist/live-example/styles.js +65 -50
  44. package/dist/live-example/types.d.ts +2 -2
  45. package/dist/pocket-bar.d.ts +99 -0
  46. package/dist/pocket-bar.js +298 -0
  47. package/dist/pop-float.d.ts +1 -1
  48. package/dist/pop-float.js +13 -7
  49. package/dist/segmented.d.ts +0 -1
  50. package/dist/segmented.js +38 -20
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/llms.txt +3 -3
  55. package/package.json +3 -3
  56. package/dist/bp-loader.d.ts +0 -1
  57. package/dist/bp-loader.js +0 -26
  58. package/dist/hydrate/index-iife-5wzyvyzm.js +0 -27
  59. /package/dist/hydrate/{tjs-import-resolver-akt3k27z.js.map → tjs-import-resolver-s4z0w296.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.3
4
+
5
+ - **New `<tosi-pocket-bar>` component.** A "pocket buttonbar": a single translucent icon at
6
+ rest that expands into a bar of slotted icon-button controls on hover/tap, then collapses
7
+ again. `direction` (the full `FloatPosition` vocabulary, `auto` by default) determines both
8
+ layout and growth direction; the handle stays put and toggles the bar; touch counts as hover.
9
+ Built as a thin wrapper around `positionFloat`.
10
+ - **The live-example toolbar is now a pocket bar**, pinned to the top-right of the preview (so
11
+ it never sits over the code editors in side-by-side mode). The collapsed handle carries the
12
+ example's pass/fail color, "run tests" is a checkbox that greys when off, and the bar stays
13
+ visible when the example is maximized.
14
+ - **Build system is fragility-tolerant.** An *unsupported import* in a live example (a bare
15
+ specifier the runtime can't resolve) now downgrades that block to display-only with a warning
16
+ instead of failing the whole build; genuine syntax errors still fail the build.
17
+ - **Removed a render-skip anti-pattern** from `<tosi-segmented>`, `<tosi-color>`, and
18
+ `<tosi-form>`. They previously depended on `render()` being *skipped* after interaction via a
19
+ `valueChanged` flag — fragile timing that broke under tosijs's parts changes. Renders are now
20
+ idempotent: they skip only *provably-redundant* writes by comparing values (colors compared
21
+ **parsed**, so a `#rrggbb ↔ rgba()` round-trip no longer clobbers the caret/selection in the
22
+ field being edited).
23
+ - **Code editor background is now a neutral off-white** (off-black in dark mode) instead of a
24
+ brand-tinted color — both the default `<tosi-code>` and live examples.
25
+ - **Fix: nav flickered on refresh / reflowed on hydration.** The static no-JS nav relied on the
26
+ browser's native `<details>` collapse (`::details-content { content-visibility: hidden }`),
27
+ which Chromium applies lazily and non-deterministically on first paint — a closed section
28
+ could render at full height for a frame before snapping shut. Closed sections now collapse
29
+ synchronously via `display: none` (still opening on a no-JS `<summary>` toggle). Enforced by
30
+ `tests/hydration.pw.ts`.
31
+ - **Moved the doc-browser test-counter widget to the bottom-left** so it no longer sits under
32
+ the haltija dev overlay.
33
+ - **tosijs bumped to `^1.7.8`**, which fixes the `this.parts` proxy crossing into nested
34
+ component instances (tosijs#20 — surfaced by the self-hosting doc demo, where clicking "edit"
35
+ on one example opened the editor in the wrong one) and change-handler value staleness
36
+ (tosijs#21).
37
+
38
+ ## 1.7.2
39
+
40
+ - **Fix: doc sites are now mount-point-agnostic** (issue #25). The build baked `basePath` into
41
+ every functional URL (nav/content links, `scriptUrl`, `stylesUrl`, favicon, `docsUrl`), so a
42
+ build was locked to one mount — adding a custom domain to a GitHub project page flipped the
43
+ live site to the root while `docs/` still pointed at `/repo`, 404ing every asset into a bare,
44
+ unstyled shell with no warning. Functional URLs are now emitted **relative to each page**, so
45
+ one build works at `/repo`, a custom-domain root, or a moved mount with no rebuild. *Metadata*
46
+ URLs (`canonical`, `og:url`, `og:image`, `sitemap.xml`) stay absolute via `baseUrl`+`basePath`
47
+ — so `basePath` now only affects metadata, never the page's assets. (The hydrated SPA's own
48
+ nav/`pushState` and `__TJS_LOCAL_BASE` remain root-relative — tracked in #16.)
49
+ - **Fix: the haltija dev channel loaded once per background-test iframe.** The doc-browser's
50
+ background test runner loads every page-with-tests in a hidden iframe, each served the injected
51
+ dev-channel loader — so `dev.js` was imported N times into throwaway frames. The loader is now
52
+ gated to the top window (`self===top`).
53
+ - **Live examples get a rounded, padded card.** `.preview` gains `padding` (breathing room around
54
+ rendered content) and its first child's top margin is dropped; the inset border moves to the
55
+ example's `:host` with a `border-radius`, so the whole example (preview + editors) reads as one
56
+ bordered card. CodeMirror tooltips render fixed-positioned so autocomplete isn't clipped by the
57
+ card's `overflow: hidden`.
58
+ - **Removed the "blueprint loading" doc page.** It documented tosijs's own
59
+ `<xin-loader>`/`<xin-blueprint>` elements (no tosijs-ui component), so it belongs in tosijs's
60
+ docs. The generic `blueprint` icon stays.
61
+
3
62
  ## 1.7.1
4
63
 
5
64
  - **Fix: components looked wrong in the doc site's dark mode** (e.g. `<tosi-table>` stayed
@@ -10,7 +10,7 @@ Languages here are the always-available set (js/ts/css/html/markdown). First-cla
10
10
  tjs (highlighting + runtime-value autocomplete via tjs-lang's editors/codemirror) is
11
11
  layered in separately and only when tjs-lang is installed.
12
12
  */
13
- import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine, } from '@codemirror/view';
13
+ import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine, tooltips, } from '@codemirror/view';
14
14
  import { EditorState, Compartment, Annotation, Transaction, } from '@codemirror/state';
15
15
  import { indentWithTab, history, defaultKeymap, historyKeymap, undo, redo, undoDepth, redoDepth, } from '@codemirror/commands';
16
16
  import { indentUnit, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, } from '@codemirror/language';
@@ -129,6 +129,10 @@ const editorTheme = EditorView.theme({
129
129
  function baseExtensions() {
130
130
  return [
131
131
  editorTheme,
132
+ // Render autocomplete / hover tooltips in a fixed layer so they escape the
133
+ // live-example's `:host { overflow: hidden }` (the rounded card clip) instead
134
+ // of being cut off at the editor's bottom edge in a short example.
135
+ tooltips({ position: 'fixed' }),
132
136
  lineNumbers(),
133
137
  highlightActiveLineGutter(),
134
138
  highlightSpecialChars(),
@@ -33,7 +33,6 @@ declare class ColorInput extends Component<ColorParts> {
33
33
  };
34
34
  };
35
35
  content: HTMLInputElement[];
36
- private valueChanged;
37
36
  update: (event: Event) => void;
38
37
  connectedCallback(): void;
39
38
  render(): void;
@@ -71,7 +71,6 @@ class ColorInput extends Component {
71
71
  }),
72
72
  input({ title: 'css color spec', part: 'css' }),
73
73
  ];
74
- valueChanged = false;
75
74
  update = (event) => {
76
75
  const { rgb, alpha, css } = this.parts;
77
76
  if (event.type === 'input') {
@@ -86,7 +85,6 @@ class ColorInput extends Component {
86
85
  }
87
86
  rgb.style.opacity = String(this.color.a);
88
87
  this.value = this.color.rgba;
89
- this.valueChanged = true;
90
88
  };
91
89
  connectedCallback() {
92
90
  super.connectedCallback();
@@ -96,16 +94,32 @@ class ColorInput extends Component {
96
94
  css.addEventListener('change', this.update);
97
95
  }
98
96
  render() {
99
- if (this.valueChanged) {
100
- this.valueChanged = false;
101
- return;
102
- }
103
97
  const { rgb, alpha, css } = this.parts;
104
98
  this.color = Color.fromCss(this.value);
105
- rgb.value = this.color.html.substring(0, 7);
106
- rgb.style.opacity = String(this.color.a);
107
- alpha.value = String(this.color.a);
108
- css.value = this.color.html;
99
+ const rgba = this.color.rgba;
100
+ const alphaStr = String(this.color.a);
101
+ // Idempotent: only WRITE an input whose value doesn't already represent this
102
+ // colour. Compare the PARSED colour, not the string — `#rrggbb` and `rgba(...)`
103
+ // are the same colour, so a string compare (or a blind write) would clobber the
104
+ // caret / selection and re-round-trip the user's format on the field they're
105
+ // editing, every render. Skipping provably-redundant work is right; a `valueChanged`
106
+ // timing flag to decide it was the fragile part (broke under tosijs 1.7.7).
107
+ const sameColor = (v) => {
108
+ try {
109
+ return Color.fromCss(v).rgba === rgba;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ };
115
+ if (!sameColor(rgb.value))
116
+ rgb.value = this.color.html.substring(0, 7);
117
+ if (rgb.style.opacity !== alphaStr)
118
+ rgb.style.opacity = alphaStr;
119
+ if (alpha.value !== alphaStr)
120
+ alpha.value = alphaStr;
121
+ if (!sameColor(css.value))
122
+ css.value = this.color.html;
109
123
  }
110
124
  }
111
125
  export const colorInput = ColorInput.elementCreator();
@@ -206,12 +206,14 @@ const testIndicatorStyleSpec = {
206
206
  background: testColor.fail,
207
207
  animation: 'test-pulse 2s ease-in-out infinite',
208
208
  },
209
- // Floating widget - position and colors only, inherits button structure
209
+ // Floating widget - position and colors only, inherits button structure.
210
+ // Bottom-LEFT: bottom-right is where the injected haltija dev widget sits, and it
211
+ // was overlaying this.
210
212
  '.test-widget': {
211
213
  _testBg: testColor.running,
212
214
  position: 'fixed',
213
215
  bottom: vars.spacing,
214
- right: vars.spacing,
216
+ left: vars.spacing,
215
217
  zIndex: '1000',
216
218
  background: vars.testBg,
217
219
  color: 'white',
@@ -8,6 +8,8 @@ export interface DocSystemTheme {
8
8
  text?: string;
9
9
  buttonBg?: string;
10
10
  inputBg?: string;
11
+ /** code-editor surface (`--code-bg`); neutral off-white by default, not brand-tinted. */
12
+ codeBg?: string;
11
13
  }
12
14
  /** Compute the full set of `:root` color variables from a few base colors. */
13
15
  export declare function docSystemColors(theme?: DocSystemTheme): XinStyleRule;
@@ -34,7 +34,9 @@ export function docSystemColors(theme = {}) {
34
34
  _placeholderColor: brandColor.opacity(0.4),
35
35
  _brandTextColor: brandColor.rotate(30).brighten(0.9),
36
36
  _insetBg: brandColor.rotate(45).brighten(0.8),
37
- _codeBg: brandColor.rotate(-15).desaturate(0.5).brighten(0.9),
37
+ // Neutral off-white (not brand-tinted); dark mode inverts its luminance to
38
+ // off-black, so the editor surface stays a clean neutral in both themes.
39
+ _codeBg: theme.codeBg ?? '#fdfdfd',
38
40
  _linkColor: brandColor.rotate(-30).darken(0.5),
39
41
  _shadowColor: '#0004',
40
42
  _menuBg: '#fafafa',
@@ -476,6 +478,18 @@ export function docSystemStyleSpec(theme = {}) {
476
478
  '.doc-nav details > ul': {
477
479
  paddingLeft: vars.spacing,
478
480
  },
481
+ // Collapse a closed section's list SYNCHRONOUSLY. The UA collapses <details>
482
+ // via `::details-content { content-visibility: hidden }`, which is applied
483
+ // lazily and — for this static tree — non-deterministically on first paint:
484
+ // sometimes the closed section is still laid out at full height for a frame
485
+ // before it snaps shut. That frame is a visible flicker on refresh, and it made
486
+ // the "nav does not move on hydration" test measure a 36px row that hydration
487
+ // then collapses to 0. An explicit display:none collapses it in the first layout
488
+ // pass with no content-visibility race, and still opens on a no-JS <summary>
489
+ // toggle (the UA adds [open], so this rule stops matching).
490
+ '.doc-nav details:not([open]) > ul': {
491
+ display: 'none',
492
+ },
479
493
  // <summary> is the section header (its triangle toggles; the link navigates).
480
494
  '.doc-nav summary': {
481
495
  cursor: 'pointer',
@@ -20,8 +20,9 @@ the bundle step, the ePub step, and generate-css; see the Bun.build note in
20
20
  orchestrator.ts.
21
21
 
22
22
  bun check-examples-cli.ts <docs.json> [contextKey ...]
23
- → writes ExampleProblem[] as JSON to stdout; exit 0 whether or not problems were
24
- found (a problem is data, not a crash — only a real failure exits non-zero).
23
+ → writes { problems, warnings, bakes } as JSON to stdout; exit 0 whether or not
24
+ problems were found (a problem is data, not a crash — only a real failure exits
25
+ non-zero). The parent fails the build on `problems`, warns on `warnings`.
25
26
 
26
27
  Build-time only. Never import this from browser code.
27
28
  */
@@ -36,7 +37,7 @@ const contextKeys = process.argv.slice(3);
36
37
  // non-context imports validate as dynamic `<prefix><spec>` imports rather than failing.
37
38
  const importPrefix = process.env.TOSI_IMPORT_PREFIX || undefined;
38
39
  const corpus = JSON.parse(await Bun.file(docsJson).text());
39
- const { problems, bakes } = await checkExamples(corpus, {
40
+ const { problems, warnings, bakes } = await checkExamples(corpus, {
40
41
  ...(contextKeys.length ? { contextKeys } : {}),
41
42
  ...(importPrefix ? { importPrefix } : {}),
42
43
  });
@@ -45,6 +46,7 @@ const { problems, bakes } = await checkExamples(corpus, {
45
46
  // Maps can't JSON-roundtrip, so bakes go over as [filename, [source, {dialect,js}][]][].
46
47
  process.stdout.write(JSON.stringify({
47
48
  problems,
49
+ warnings,
48
50
  bakes: Array.from(bakes.entries()).map(([filename, docBakes]) => [
49
51
  filename,
50
52
  Array.from(docBakes.entries()),
@@ -8,7 +8,20 @@ export interface ExampleProblem {
8
8
  snippet: string;
9
9
  }
10
10
  export interface ExampleCheck {
11
+ /**
12
+ * Blocks that failed to build for a real reason (a tjs/TS syntax error, a
13
+ * transpile failure). These FAIL the build — broken code shouldn't ship.
14
+ */
11
15
  problems: ExampleProblem[];
16
+ /**
17
+ * Blocks that reference a package the environment can't provide (a non-context
18
+ * import, no import-resolver). The code isn't broken, it just can't run here —
19
+ * almost always illustrative code that should be tagged `typescript` (display-
20
+ * only) rather than `ts`. These WARN and are treated as display-only; they do
21
+ * NOT fail the build. (Enable `importResolver`, or tag the block display-only,
22
+ * to silence the warning.)
23
+ */
24
+ warnings: ExampleProblem[];
12
25
  /**
13
26
  * Build-time transpiled JS for `tjs` blocks, grouped by doc filename, each keyed
14
27
  * by exact source text. The renderer embeds a doc's bakes as hidden scripts (so
@@ -4,17 +4,25 @@ Build-time transpile check for live examples.
4
4
  Every executable code block in the corpus (`js` / `tjs` / `ts` / `test`) is put
5
5
  through the SAME front half of the runtime pipeline the live-example component
6
6
  uses — `rewriteImports` → the tjs-lang transform → `new AsyncFunction(...)` — so a
7
- block that can't build (a tjs/TS syntax error, an unsupported import, or
8
- illustrative config mistakenly tagged with an executable language like `ts`
9
- instead of the display-only `typescript`) fails the build *at authoring time*,
10
- on every page, instead of silently rendering an error only when someone opens
11
- that page. `html` / `css` and display-only languages (`typescript`, `json`, …)
12
- are not executed, so they're skipped.
7
+ block that can't build is caught *at authoring time*, on every page, instead of
8
+ silently rendering an error only when someone opens that page. The two outcomes
9
+ are graded differently (this is the point):
10
+
11
+ - a real **syntax / transpile error** is broken code → a `problem` that FAILS
12
+ the build;
13
+ - an **unsupported import** (a non-context package, no import-resolver) is fine
14
+ code the doc environment just can't run → a `warning`: the block is treated as
15
+ display-only and the build survives. It's almost always illustrative code that
16
+ should have been tagged `typescript` instead of `ts` — a mistagging shouldn't
17
+ take the whole build down with it.
18
+
19
+ `html` / `css` and display-only languages (`typescript`, `json`, …) are not
20
+ executed, so they're skipped.
13
21
 
14
22
  Build-time only (bun). Never import from browser code.
15
23
  */
16
24
  import { marked } from 'marked';
17
- import { rewriteImports, AsyncFunction, loadTransform, } from '../../live-example/code-transform';
25
+ import { rewriteImports, AsyncFunction, loadTransform, UnsupportedImportError, } from '../../live-example/code-transform';
18
26
  // The default live-example context (matches the IIFE globals the pages provide).
19
27
  // A project that sets a custom `context` on its <tosi-doc-system> can pass its
20
28
  // own keys; these are the tosijs-ui defaults.
@@ -58,6 +66,7 @@ function collectCodeTokens(text) {
58
66
  export async function checkExamples(docs, opts = {}) {
59
67
  const contextKeys = opts.contextKeys ?? DEFAULT_CONTEXT_KEYS;
60
68
  const problems = [];
69
+ const warnings = [];
61
70
  const bakes = new Map();
62
71
  for (const doc of docs) {
63
72
  for (const block of collectCodeTokens(doc.text)) {
@@ -97,17 +106,24 @@ export async function checkExamples(docs, opts = {}) {
97
106
  }
98
107
  }
99
108
  catch (err) {
100
- problems.push({
109
+ const entry = {
101
110
  filename: doc.filename,
102
111
  title: doc.title,
103
112
  lang: block.lang,
104
113
  error: err.message || String(err),
105
114
  snippet: block.text.split('\n').slice(0, 3).join('\n'),
106
- });
115
+ };
116
+ // An unsupported import means the block references deps the environment
117
+ // can't provide — not broken code. Warn (display-only), don't fail the
118
+ // build. A real syntax/transpile error is a genuine problem.
119
+ if (err instanceof UnsupportedImportError)
120
+ warnings.push(entry);
121
+ else
122
+ problems.push(entry);
107
123
  }
108
124
  }
109
125
  }
110
- return { problems, bakes };
126
+ return { problems, warnings, bakes };
111
127
  }
112
128
  /** Format problems for a build log. */
113
129
  export function formatExampleProblems(problems) {
@@ -28,6 +28,17 @@ export declare function resolveIdleMs(configHours: number | undefined, envHours:
28
28
  * off. Only an explicit non-positive number disables the ceiling.
29
29
  */
30
30
  export declare function resolveLimitMb(configMb: number | undefined, envMb: string | undefined): number;
31
+ /**
32
+ * The localhost-gated haltija dev-channel loader injected into served HTML at
33
+ * serve time (never bundled, never in the built output).
34
+ *
35
+ * `self===top` keeps it in the TOP window only. The doc-browser's background test
36
+ * runner loads every page-with-tests in a hidden iframe (`?_testMode=1`), each
37
+ * served this same HTML — without the guard, haltija's `dev.js` gets imported
38
+ * once per test page (N redundant loads in throwaway frames). An agent only ever
39
+ * drives the top page, so nested frames never need the channel.
40
+ */
41
+ export declare function haltijaLoaderSnippet(httpsPort: number): string;
31
42
  export declare function devServer(config: SiteConfig, opts?: {
32
43
  test?: boolean;
33
44
  build?: () => unknown | Promise<unknown>;
@@ -78,6 +78,20 @@ export function resolveLimitMb(configMb, envMb) {
78
78
  const mb = Number.isFinite(raw) ? raw : DEFAULT_LIMIT_MB;
79
79
  return mb > 0 ? mb : 0;
80
80
  }
81
+ /**
82
+ * The localhost-gated haltija dev-channel loader injected into served HTML at
83
+ * serve time (never bundled, never in the built output).
84
+ *
85
+ * `self===top` keeps it in the TOP window only. The doc-browser's background test
86
+ * runner loads every page-with-tests in a hidden iframe (`?_testMode=1`), each
87
+ * served this same HTML — without the guard, haltija's `dev.js` gets imported
88
+ * once per test page (N redundant loads in throwaway frames). An agent only ever
89
+ * drives the top page, so nested frames never need the channel.
90
+ */
91
+ export function haltijaLoaderSnippet(httpsPort) {
92
+ return (`<script>self===top&&/^localhost$|^127\\./.test(location.hostname)` +
93
+ `&&import('https://localhost:${httpsPort}/dev.js')</script>`);
94
+ }
81
95
  /**
82
96
  * Reclaim the port we are about to bind, from the process LISTENING on it.
83
97
  *
@@ -259,8 +273,7 @@ export async function devServer(config, opts = {}) {
259
273
  (config.haltijaDev === true ||
260
274
  process.env.HALTIJA_DEV === '1' ||
261
275
  process.env.HALTIJA_DEV === 'true');
262
- const HALTIJA_SNIPPET = `<script>/^localhost$|^127\\./.test(location.hostname)` +
263
- `&&import('https://localhost:${HALTIJA_HTTPS_PORT}/dev.js')</script>`;
276
+ const HALTIJA_SNIPPET = haltijaLoaderSnippet(HALTIJA_HTTPS_PORT);
264
277
  let testReportResolve;
265
278
  // Source read/write for in-browser "edit page source" (config.editableSources).
266
279
  // Local dev only — your machine, your files — so the lone guard is correctness:
@@ -56,4 +56,22 @@ export interface GenerateSiteConfig {
56
56
  /** extra lines injected into every <head> (favicon, analytics, etc.) */
57
57
  headExtra?: string;
58
58
  }
59
+ /**
60
+ * Depth of a page below the site root: 0 for the root index (served at the mount
61
+ * root), 1 for every `/slug/` page. The generator only ever emits a flat `/slug/`
62
+ * tree (see `pathForSlug`), so a non-root page is always exactly one directory deep.
63
+ */
64
+ export declare function pageDepth(slug: string): number;
65
+ /**
66
+ * Rewrite a root-relative *functional* URL (asset ref, nav / content link) to be
67
+ * relative to a page at `depth`, so ONE build works at ANY mount point — a GitHub
68
+ * project page under `/repo`, a custom-domain root, or a moved mount — with no
69
+ * `basePath` rebuild. Relative URLs resolve against wherever the page is actually
70
+ * served, so `basePath` is deliberately NOT applied here: a page at `/repo/x/`
71
+ * gets `../styles.css` → `/repo/styles.css`, the same page at `/x/` gets it at
72
+ * `/styles.css`. External (`https://…`, `//…`) and already-relative refs pass
73
+ * through untouched. (Metadata URLs still use `withBase` — they need the absolute
74
+ * origin.) See issue #25; the runtime/SPA-navigation half is issue #16.
75
+ */
76
+ export declare function relativeUrl(depth: number, p: string): string;
59
77
  export declare function generateSite(config: GenerateSiteConfig): Promise<number>;
@@ -27,11 +27,11 @@ const escapeText = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace
27
27
  * <details> on the path to `currentFilename` opened. `currentFilename` gets
28
28
  * aria-current. Crawlable + no-JS: every node is a real <a>.
29
29
  */
30
- function navHtml(docs, slugMap, currentFilename, basePath) {
30
+ function navHtml(docs, slugMap, currentFilename, depth) {
31
31
  const roots = buildNavTree(docs, slugMap);
32
32
  const open = navOpenPath(roots, currentFilename);
33
33
  const renderNode = (node, indent) => {
34
- const href = withBase(basePath, pathForSlug(node.slug));
34
+ const href = relativeUrl(depth, pathForSlug(node.slug));
35
35
  const current = node.doc.filename === currentFilename;
36
36
  // `doc-link` is what the HYDRATED nav emits, and the shared nav CSS is written
37
37
  // against it. Emitting a bare <a> here forced a second, hand-copied rule set under
@@ -78,16 +78,48 @@ function absUrl(baseUrl, pathOrUrl) {
78
78
  /**
79
79
  * Prefix a root-relative path with basePath. No-op for '/', empty, protocol-
80
80
  * relative (`//…`), or absolute (`https://…`) URLs.
81
+ *
82
+ * Used ONLY for *metadata* URLs now (canonical, og:url, og:image, sitemap) — they
83
+ * legitimately need the real absolute origin+path. *Functional* URLs go through
84
+ * `relativeUrl` instead, so the artifact is mount-agnostic. See `relativeUrl`.
81
85
  */
82
86
  function withBase(basePath, p) {
83
87
  if (!p || !basePath || basePath === '/' || /^(https?:)?\/\//.test(p))
84
88
  return p;
85
89
  return basePath.replace(/\/$/, '') + (p.startsWith('/') ? p : '/' + p);
86
90
  }
91
+ /**
92
+ * Depth of a page below the site root: 0 for the root index (served at the mount
93
+ * root), 1 for every `/slug/` page. The generator only ever emits a flat `/slug/`
94
+ * tree (see `pathForSlug`), so a non-root page is always exactly one directory deep.
95
+ */
96
+ export function pageDepth(slug) {
97
+ return slug === '' ? 0 : 1;
98
+ }
99
+ /**
100
+ * Rewrite a root-relative *functional* URL (asset ref, nav / content link) to be
101
+ * relative to a page at `depth`, so ONE build works at ANY mount point — a GitHub
102
+ * project page under `/repo`, a custom-domain root, or a moved mount — with no
103
+ * `basePath` rebuild. Relative URLs resolve against wherever the page is actually
104
+ * served, so `basePath` is deliberately NOT applied here: a page at `/repo/x/`
105
+ * gets `../styles.css` → `/repo/styles.css`, the same page at `/x/` gets it at
106
+ * `/styles.css`. External (`https://…`, `//…`) and already-relative refs pass
107
+ * through untouched. (Metadata URLs still use `withBase` — they need the absolute
108
+ * origin.) See issue #25; the runtime/SPA-navigation half is issue #16.
109
+ */
110
+ export function relativeUrl(depth, p) {
111
+ if (!p || /^(https?:)?\/\//.test(p) || !p.startsWith('/'))
112
+ return p;
113
+ const rel = '../'.repeat(depth) + p.slice(1);
114
+ return rel === '' ? './' : rel;
115
+ }
87
116
  function pageHtml(doc, config, slugMap, configAttr) {
88
117
  const { projectName = '', baseUrl = '', lang = 'en', favicon = '/favicon.svg', docsUrl = '/docs.json', scriptUrl = '/iife.js', hydrateUrl, stylesUrl = '/doc-system.css', localizedUrl = '/localized-strings.txt', basePath, headExtra = '', bakes, } = config;
118
+ // Functional URLs are emitted relative to THIS page's depth so the build is
119
+ // mount-agnostic (issue #25); metadata URLs below stay absolute via withBase.
120
+ const depth = pageDepth(slugMap[doc.filename] ?? '');
89
121
  const localizedAttr = config.localizedStrings
90
- ? ` localized="${escapeAttr(withBase(basePath, localizedUrl))}"`
122
+ ? ` localized="${escapeAttr(relativeUrl(depth, localizedUrl))}"`
91
123
  : '';
92
124
  // ONE rule, shared with the doc-browser's hydration path — see doc-title.ts. Writing
93
125
  // it twice is what made the title change on hydration (issue #6).
@@ -103,9 +135,9 @@ function pageHtml(doc, config, slugMap, configAttr) {
103
135
  // static HTML is correct for no-JS readers and crawlers (the doc-browser also
104
136
  // does this client-side after hydration).
105
137
  const body = rewriteDocLinks(renderDocMarkdown(doc.text, { bakes: bakes?.get(doc.filename) }), (filename) => slugMap[filename] !== undefined
106
- ? withBase(basePath, pathForSlug(slugMap[filename]))
138
+ ? relativeUrl(depth, pathForSlug(slugMap[filename]))
107
139
  : null);
108
- const nav = navHtml(config.docs, slugMap, doc.filename, basePath);
140
+ const nav = navHtml(config.docs, slugMap, doc.filename, depth);
109
141
  const navbar = linkListHtml('doc-navbar', config.navbarLinks);
110
142
  const jsonLd = baseUrl
111
143
  ? jsonLdScript({
@@ -126,7 +158,7 @@ function pageHtml(doc, config, slugMap, configAttr) {
126
158
  ' <link rel="preconnect" href="https://fonts.googleapis.com" />',
127
159
  ' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
128
160
  // Burned-in theme: styles the page with no JS and with zero flash on hydration.
129
- ` <link rel="stylesheet" href="${escapeAttr(withBase(basePath, stylesUrl))}" data-tosi-doc-system />`,
161
+ ` <link rel="stylesheet" href="${escapeAttr(relativeUrl(depth, stylesUrl))}" data-tosi-doc-system />`,
130
162
  ` <title>${escapeText(title)}</title>`,
131
163
  description
132
164
  ? ` <meta name="description" content="${escapeAttr(description)}" />`
@@ -155,7 +187,7 @@ function pageHtml(doc, config, slugMap, configAttr) {
155
187
  ? ` <meta name="twitter:image" content="${escapeAttr(imageAbs)}" />`
156
188
  : '',
157
189
  jsonLd,
158
- ` <link rel="icon" href="${escapeAttr(withBase(basePath, favicon))}" />`,
190
+ ` <link rel="icon" href="${escapeAttr(relativeUrl(depth, favicon))}" />`,
159
191
  headExtra,
160
192
  ]
161
193
  .filter(Boolean)
@@ -189,7 +221,7 @@ function pageHtml(doc, config, slugMap, configAttr) {
189
221
  ${head}
190
222
  </head>
191
223
  <body>
192
- <tosi-doc-system docs="${escapeAttr(withBase(basePath, docsUrl))}" config="${configAttr}"${localizedAttr}>
224
+ <tosi-doc-system docs="${escapeAttr(relativeUrl(depth, docsUrl))}" config="${configAttr}"${localizedAttr}>
193
225
  <article class="doc-content">
194
226
  ${body}
195
227
  </article>
@@ -197,8 +229,8 @@ ${nav}
197
229
  ${navbar}
198
230
  </tosi-doc-system>
199
231
  ${hydrateUrl
200
- ? `<script type="module" src="${escapeAttr(withBase(basePath, hydrateUrl))}"></script>`
201
- : `<script src="${escapeAttr(withBase(basePath, scriptUrl))}"></script>`}
232
+ ? `<script type="module" src="${escapeAttr(relativeUrl(depth, hydrateUrl))}"></script>`
233
+ : `<script src="${escapeAttr(relativeUrl(depth, scriptUrl))}"></script>`}
202
234
  </body>
203
235
  </html>
204
236
  `;
@@ -132,6 +132,7 @@ async function checkExamplesInChild(docsJson, importPrefix) {
132
132
  const payload = JSON.parse(out);
133
133
  return {
134
134
  problems: payload.problems,
135
+ warnings: payload.warnings ?? [],
135
136
  bakes: new Map(payload.bakes.map(([file, e]) => [file, new Map(e)])),
136
137
  };
137
138
  }
@@ -239,14 +240,26 @@ export async function buildSite(config) {
239
240
  : undefined;
240
241
  let exampleBakes;
241
242
  if (config.checkExamples !== false) {
242
- const { problems, bakes } = await checkExamplesInChild(DOCS_JSON, resolverPrefix);
243
+ const { problems, warnings, bakes } = await checkExamplesInChild(DOCS_JSON, resolverPrefix);
243
244
  exampleBakes = bakes;
245
+ // Unsupported imports don't fail the build — the code isn't broken, it just
246
+ // can't run in the doc environment (almost always illustrative code that
247
+ // should be tagged `typescript`). Warn loudly, treat the block as display-only,
248
+ // and keep going. Only real syntax/transpile errors below are fatal.
249
+ if (warnings.length) {
250
+ console.warn(`⚠️ doc-site build: ${warnings.length} live example block(s) can't run ` +
251
+ `here and were left as display-only:\n\n` +
252
+ formatExampleProblems(warnings) +
253
+ `\n\nTag each with a display-only language like \`typescript\` (instead of` +
254
+ ` \`js\`/\`ts\`) to silence this, or enable \`importResolver\` to import` +
255
+ ` other packages.\n`);
256
+ }
244
257
  if (problems.length) {
245
258
  throw new Error(`doc-site build: ${problems.length} live example(s) failed to build:\n\n` +
246
259
  formatExampleProblems(problems) +
247
260
  `\n\nFix the code, or — if a block is illustrative and not meant to run —` +
248
261
  ` tag it with a display-only language like \`typescript\` instead of` +
249
- ` \`js\`/\`ts\`. (Disable with checkExamples: false.)`);
262
+ ` \`js\`/\`ts\`. (Disable all checking with checkExamples: false.)`);
250
263
  }
251
264
  }
252
265
  // Copy static-asset dirs into the web root.
@@ -88,9 +88,16 @@ export interface SiteConfig {
88
88
  domain?: string;
89
89
  /**
90
90
  * URL prefix the site is served under, default '/'. Set to '/<repo>' for a
91
- * GitHub project page without a custom domain; every absolute URL the
92
- * generator emits (links, canonical, sitemap, scriptUrl, stylesUrl) is
93
- * rewritten under it.
91
+ * GitHub project page without a custom domain.
92
+ *
93
+ * As of the mount-agnostic build (issue #25), `basePath` only affects
94
+ * *metadata* URLs — `canonical`, `og:url`, `og:image`, and `sitemap.xml` —
95
+ * which need the real absolute served path for SEO. *Functional* URLs (nav /
96
+ * content links, `scriptUrl`, `stylesUrl`, favicon, `docsUrl`) are emitted
97
+ * **relative to each page**, so one build works at `/repo`, at a custom-domain
98
+ * root, or a moved mount with no rebuild. You still want `basePath` correct so
99
+ * search engines see canonical URLs at the real path; getting it wrong now only
100
+ * mis-states metadata, it no longer 404s the page's assets.
94
101
  */
95
102
  basePath?: string;
96
103
  /**
package/dist/form.d.ts CHANGED
@@ -45,7 +45,6 @@ export declare class TosiField extends XinComponent {
45
45
  };
46
46
  value: any;
47
47
  content: HTMLLabelElement;
48
- private valueChanged;
49
48
  handleChange: () => void;
50
49
  connectedCallback(): void;
51
50
  render(): void;