tosijs-ui 1.17.2 → 1.17.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,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.4
4
+
5
+ > Live examples' inline views keep their controls in the code panel's tab bar, and a site or
6
+ > page can set the view its examples open in. Nothing to change on upgrade. Asked by tjs-lang
7
+ > for its learn-to-program book (#252). `dist/iife.js` grows 0.6 kB (508.7 kB gzip).
8
+
9
+ ### Added
10
+
11
+ - `SiteConfig.exampleView` (`'preview'` | `'code'` | `'console'`) sets the view every live
12
+ example opens in, and `exampleView` in a page's metadata or frontmatter sets it for one page.
13
+ The nearest setting wins (fence, then page, then site), and `"preview"` opts out of a default
14
+ above it, so a big example on a code-first page takes `{"view": "preview"}`. A misspelt value
15
+ fails the build in the site config and is reported in page metadata. `setExampleView` (from
16
+ `tosijs-ui/live-example`) sets it from code.
17
+ - A refresh button in the code panel's tab bar, beside ⋮, in every view. Editing does not re-run
18
+ an example, and ⌘S / ⌘R were undiscoverable.
19
+
20
+ ### Changed
21
+
22
+ - In the inline views (`{"view": "code"}`, `{"view": "console"}`) the toolbar column beside the
23
+ preview is gone: it stood between the preview and its code. Maximize and the new-window editor
24
+ are in the code panel's tab bar. "view/edit code" (the code is already open) and the tests
25
+ check (inline tests always run) are not shown there. Maximized and plain examples are
26
+ unchanged.
27
+
28
+ ### Fixed
29
+
30
+ - `<tosi-tabs>` with more tabs than fit pushed its `after-tabs` controls past its clipped edge:
31
+ at phone width a live example's close button was pushed past the panel's clipped edge. The tab names now
32
+ scroll (swipe, trackpad, or the mouse wheel over them) and the controls stay where they are.
33
+ Selecting a tab scrolls it into view, and the underline follows the scroll, clipped to the
34
+ strip.
35
+
36
+ ## 1.17.3
37
+
38
+ > A doc site answers `/slug` as well as `/slug/`, and a missing page gets a real 404 page.
39
+ > Nothing to change on upgrade. On Firebase, add `"cleanUrls": true` to `firebase.json` for
40
+ > `/slug` to serve the page; GitHub Pages, Netlify and Cloudflare Pages need nothing.
41
+
42
+ ### Added
43
+
44
+ - Every page (and alias) is also written as `/slug.html`, with its references written for
45
+ `/slug`, so a host that resolves extensionless paths (GitHub Pages, Netlify, Cloudflare
46
+ Pages, Firebase with `cleanUrls`) answers `/slug` with the page instead of a redirect. An
47
+ agent's fetch tool that does not follow redirects got a "moved" stub at
48
+ virta.tosijs.net/start. The canonical link stays `/slug/`; a file the site already has at
49
+ `/slug.html` is left alone and named in a warning.
50
+ - The build writes `404.html` (unless the site ships its own): it names the path asked for,
51
+ suggests the closest page, lists every page and links `llms.txt`. Every link in it is
52
+ absolute, since hosts serve it at whatever URL was asked for.
53
+
54
+ ### Fixed
55
+
56
+ - After Cmd-K, the doc site's search field sat on top of a maximized live example. Opening the
57
+ palette raised the whole `<tosi-palette>` (its trigger included) to the page's highest
58
+ z-index and never put it back, and any overlay on the page (haltija's widget on a dev
59
+ server, a toast in an app) made that higher than the example's. The dialog never needed it:
60
+ `showModal()` already puts it in the top layer.
61
+ - The dev server served `/slug` from `slug/index.html`, whose references are written for
62
+ `/slug/`; it now serves `slug.html`. (Its `.html` fallback looked for `slug/.html` and never
63
+ matched.)
64
+ - The live-theme page showed two theme editors: its example had an `html` block and a `js`
65
+ block that each made one.
66
+
3
67
  ## 1.17.2
4
68
 
5
69
  > Components carry only the icons they draw, and `<tosi-affix>` centres a caption on its
@@ -1,6 +1,6 @@
1
1
  import { type AccessibilityMode } from './doc-system/render.js';
2
2
  import { TestResults } from './live-example/test-harness.js';
3
- import { type ExamplePolicy } from './doc-system/example-policy.js';
3
+ import { type ExamplePolicy, type ExampleView } from './doc-system/example-policy.js';
4
4
  export declare const hasTestBlock: (text: string | undefined, policy?: ExamplePolicy) => boolean;
5
5
  export interface PageTestResults {
6
6
  passed: boolean;
@@ -45,6 +45,8 @@ export interface Doc {
45
45
  header?: boolean;
46
46
  /** `'full-width'` drops the reading measure; `'full-screen'` also puts the nav away. */
47
47
  layout?: 'full-width' | 'full-screen';
48
+ /** the view this page's live examples open in (see the site `Doc.exampleView`) */
49
+ exampleView?: ExampleView;
48
50
  testStatus?: 'passed' | 'failed' | 'pending';
49
51
  bakes?: Array<[string, {
50
52
  dialect: string;
@@ -110,7 +110,7 @@ import { LiveExample, testManager, pageTestCount, enableTests, disableTests, } f
110
110
  import { highlightBlocks } from './doc-system/highlight.js';
111
111
  import { examplePolicy } from './live-example/insert-examples.js';
112
112
  import { collectCodeTokens } from './doc-system/code-fences.js';
113
- import { isLiveFence } from './doc-system/example-policy.js';
113
+ import { isExampleView, isLiveFence, } from './doc-system/example-policy.js';
114
114
  import { tosiSidenav, TosiSidenav } from './side-nav.js';
115
115
  import { icons } from './icons.js';
116
116
  import { tosiPalette } from './palette.js';
@@ -957,6 +957,19 @@ export function createDocBrowser(options) {
957
957
  // render from doc text. Every navigation funnels through navigateTo() -> showDoc().
958
958
  const docContent = contentElement || div();
959
959
  docContent.classList.add('doc-content');
960
+ /*
961
+ A page's `exampleView` (#252), where its examples look for it: on the content they sit in. A
962
+ value the metadata block got wrong is reported, not ignored, as frontmatter's is.
963
+ */
964
+ const stampExampleView = (doc) => {
965
+ const view = doc.exampleView;
966
+ if (view !== undefined && !isExampleView(view))
967
+ console.error(`${doc.path || doc.filename}: unknown exampleView ${JSON.stringify(view)}`);
968
+ if (isExampleView(view))
969
+ docContent.setAttribute('data-example-view', view);
970
+ else
971
+ docContent.removeAttribute('data-example-view');
972
+ };
960
973
  Object.assign(docContent.style, {
961
974
  display: 'block',
962
975
  // Same var the pre-hydration `:not(:defined)` layout uses (doc-system-styles.ts).
@@ -1132,6 +1145,7 @@ export function createDocBrowser(options) {
1132
1145
  rewriteContentLinks();
1133
1146
  // Stamp each example with its source file (for the source↔doc map). doc.path
1134
1147
  // is the extracted file (.md, or a source file with doc comments).
1148
+ stampExampleView(doc);
1135
1149
  LiveExample.insertExamples(docContent, context, doc.path || undefined);
1136
1150
  focusableCodeBlocks(docContent);
1137
1151
  /*
@@ -1370,6 +1384,7 @@ export function createDocBrowser(options) {
1370
1384
  if (view === 'preview') {
1371
1385
  docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path), { wikilinks, accessible: !!accessibility, title: editUI.doc.title });
1372
1386
  rewriteContentLinks();
1387
+ stampExampleView(editUI.doc);
1373
1388
  LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
1374
1389
  focusableCodeBlocks(docContent);
1375
1390
  }
@@ -71,6 +71,15 @@ export declare const EXAMPLE_TABS: readonly ["js", "html", "css", "test", "conso
71
71
  export type ExampleTab = (typeof EXAMPLE_TABS)[number];
72
72
  /** Why `value` is not a list of example tabs, or undefined when it is one. */
73
73
  export declare function exampleTabsProblem(value: unknown): string | undefined;
74
+ /**
75
+ * The view a live example opens in (#252): `preview` (the default), `code` (the preview beside
76
+ * its code) or `console` (the console beside the code). Set per fence (`{"view": "code"}`), per
77
+ * page (`exampleView` in its metadata) or per site (`SiteConfig.exampleView`); the nearest wins,
78
+ * so `preview` opts one example or page out of a default.
79
+ */
80
+ export declare const EXAMPLE_VIEWS: readonly ["preview", "code", "console"];
81
+ export type ExampleView = (typeof EXAMPLE_VIEWS)[number];
82
+ export declare function isExampleView(value: unknown): value is ExampleView;
74
83
  /**
75
84
  * Will this fence become a live example?
76
85
  *
@@ -118,6 +118,16 @@ export function exampleTabsProblem(value) {
118
118
  .join(', ')} (the tabs are ${EXAMPLE_TABS.join(', ')})`
119
119
  : undefined;
120
120
  }
121
+ /**
122
+ * The view a live example opens in (#252): `preview` (the default), `code` (the preview beside
123
+ * its code) or `console` (the console beside the code). Set per fence (`{"view": "code"}`), per
124
+ * page (`exampleView` in its metadata) or per site (`SiteConfig.exampleView`); the nearest wins,
125
+ * so `preview` opts one example or page out of a default.
126
+ */
127
+ export const EXAMPLE_VIEWS = ['preview', 'code', 'console'];
128
+ export function isExampleView(value) {
129
+ return EXAMPLE_VIEWS.includes(value);
130
+ }
121
131
  /**
122
132
  * Will this fence become a live example?
123
133
  *
@@ -1228,10 +1228,15 @@ export async function devServer(config, opts = {}) {
1228
1228
  */
1229
1229
  if (!isUnderRoot(directory, basePath))
1230
1230
  return null;
1231
- const suffixes = ['', '.html', 'index.html'];
1232
- for (const suffix of suffixes) {
1231
+ // `/slug` is the build's `slug.html` (written for that URL) before `slug/index.html`;
1232
+ // `/slug/` is only ever the folder's index
1233
+ const candidates = [
1234
+ basePath,
1235
+ ...(reqPath.endsWith('/') ? [] : [basePath + '.html']),
1236
+ path.join(basePath, 'index.html'),
1237
+ ];
1238
+ for (const pathWithSuffix of candidates) {
1233
1239
  try {
1234
- const pathWithSuffix = path.join(basePath, suffix);
1235
1240
  const stat = statSync(pathWithSuffix);
1236
1241
  if (stat && stat.isFile()) {
1237
1242
  return pathWithSuffix;
@@ -76,6 +76,11 @@ export interface Doc {
76
76
  * reach them from out here.
77
77
  */
78
78
  layout?: 'full-width' | 'full-screen';
79
+ /**
80
+ * The view this page's live examples open in (1.17.4, #252): `'code'`, `'console'`, or
81
+ * `'preview'` to opt out of the site's `exampleView`. A fence's `{"view": …}` still wins.
82
+ */
83
+ exampleView?: 'preview' | 'code' | 'console';
79
84
  headTitle?: string;
80
85
  description?: string;
81
86
  keywords?: string | string[];
@@ -160,6 +160,13 @@ export function parseFrontmatter(content) {
160
160
  else if (val)
161
161
  console.error(`unknown layout ${JSON.stringify(val)} in frontmatter`);
162
162
  }
163
+ else if (key === 'exampleview') {
164
+ // Same reasoning as layout: a misspelt view would silently do nothing.
165
+ if (val === 'preview' || val === 'code' || val === 'console')
166
+ data.exampleView = val;
167
+ else if (val)
168
+ console.error(`unknown exampleView ${JSON.stringify(val)} in frontmatter`);
169
+ }
163
170
  else if (key === 'header') {
164
171
  if (/^(false|no|0)$/i.test(val))
165
172
  data.header = false;
@@ -1,11 +1,14 @@
1
1
  import type { Doc } from './docs.js';
2
2
  import type { ProjectLinks, LinkItem } from '../../doc-browser.js';
3
+ import { NavNode } from '../nav-tree.js';
3
4
  import { type AccessibilityMode, type ExampleBakes } from '../render.js';
4
5
  declare global {
5
6
  var Bun: any;
6
7
  }
7
8
  export interface GenerateSiteConfig {
8
9
  docs: Doc[];
10
+ /** whether the site serves `/llms.txt`, which the 404 page then points to */
11
+ llmsTxt?: boolean;
9
12
  /** `SiteConfig.aliases`: { alias path -> slug } (board #3513) */
10
13
  aliases?: Record<string, string>;
11
14
  /** `SiteConfig.searchPlaceholder` */
@@ -160,3 +163,4 @@ export declare function withStamp(url: string, stamp?: string): string;
160
163
  */
161
164
  export declare function rebaseRelativeRefs(html: string, fromPath: string, depth: number): string;
162
165
  export declare function generateSite(config: GenerateSiteConfig): Promise<number>;
166
+ export declare function notFoundHtml(pages: NavNode<Doc>[], slugMap: Record<string, string>, config: Pick<GenerateSiteConfig, 'projectName' | 'basePath' | 'lang' | 'llmsTxt'>): string;
@@ -163,11 +163,12 @@ export function relativeUrl(depth, p) {
163
163
  }
164
164
  async function pageHtml(doc, config, slugMap, configAttr, wikilinks,
165
165
  /** the slug this copy is written at: the doc's own, or an alias of it */
166
- at = slugMap[doc.filename] ?? '') {
166
+ at = slugMap[doc.filename] ?? '',
167
+ /** the directory depth its references resolve from: 0 for the `/slug` copy (`slug.html`) */
168
+ depth = pageDepth(at)) {
167
169
  const { projectName = '', baseUrl = '', lang = 'en', favicon = '/favicon.svg', docsUrl = '/docs.json', scriptUrl = '/iife.js', hydrateUrl, stylesUrl = '/doc-system.css', assetStamp, docsStamp, localizedUrl = '/localized-strings.txt', basePath, headExtra = '', bakes, } = config;
168
170
  // Functional URLs are emitted relative to THIS page's depth so the build is
169
171
  // mount-agnostic (issue #25); metadata URLs below stay absolute via withBase.
170
- const depth = pageDepth(at);
171
172
  const own = slugMap[doc.filename] ?? '';
172
173
  const localizedAttr = config.localizedStrings
173
174
  ? ` localized="${escapeAttr(relativeUrl(depth, localizedUrl))}"`
@@ -210,9 +211,10 @@ at = slugMap[doc.filename] ?? '') {
210
211
  config.liveExamples ?? 'auto'), (filename) => slugMap[filename] !== undefined
211
212
  ? relativeUrl(depth, pathForSlug(slugMap[filename]))
212
213
  : null);
213
- // A copy at an alias: what the author wrote relative to the page's own path must still reach
214
- // the same file (a README's `img/logo.png` is /img/logo.png, not /readme/img/logo.png).
215
- const body = at === own
214
+ // A copy anywhere but the page's own path (an alias, or `/slug` with no slash): what the
215
+ // author wrote relative to that path must still reach the same file (a README's
216
+ // `img/logo.png` is /img/logo.png, not /readme/img/logo.png).
217
+ const body = at === own && depth === pageDepth(own)
216
218
  ? rendered
217
219
  : rebaseRelativeRefs(rendered, pathForSlug(own), depth);
218
220
  const nav = navHtml(config.docs, slugMap, doc.filename, depth);
@@ -562,6 +564,35 @@ export async function generateSite(config) {
562
564
  await Bun.write(`${dir}/index.md`, doc.text.trimEnd() + '\n');
563
565
  }
564
566
  }
567
+ /*
568
+ Every page again as `slug.html`, with its references written for `/slug`, so the trailing
569
+ slash is optional. Hosts serve `/slug` from `slug/index.html` with a redirect, and a client
570
+ that does not follow it (an agent's fetch tool, onboarding from a typed URL) got a "moved"
571
+ stub instead of the page. With `slug.html` beside it, a host that resolves extensionless
572
+ paths to `.html` (GitHub Pages, Netlify, Cloudflare Pages, Firebase with `cleanUrls`)
573
+ answers `/slug` with the page itself. The canonical link stays `/slug/`.
574
+ */
575
+ const slashless = [
576
+ ...docs.map((doc) => [slugMap[doc.filename], doc]),
577
+ ...Object.entries(aliases).map(([alias, target]) => [alias, docs.find((d) => slugMap[d.filename] === target)]),
578
+ ];
579
+ const occupied = [];
580
+ for (const [at, doc] of slashless) {
581
+ if (at === '')
582
+ continue;
583
+ const file = `${outputDir}/${at}.html`;
584
+ // the output folder starts empty, so a file here is one the site ships: leave it alone
585
+ if (existsSync(file)) {
586
+ occupied.push(` /${at}.html`);
587
+ continue;
588
+ }
589
+ await Bun.write(file, await pageHtml(doc, config, slugMap, configAttr, wikilinks, at, 0));
590
+ }
591
+ if (occupied.length) {
592
+ console.warn(`\n⚠️ These pages are served only at /slug/, because the site already has a file where\n` +
593
+ ` the /slug copy goes (rename the file to make the trailing slash optional):\n` +
594
+ `${occupied.join('\n')}\n`);
595
+ }
565
596
  if (danglingWikilinks.length) {
566
597
  misconfigured(config.strict, `\n⚠️ Wikilinks that reach no doc page. A target matches a page's slug (its file name)\n` +
567
598
  ` or its title; write [[slug|label]] to link one by file name, and an ordinary\n` +
@@ -631,5 +662,88 @@ export async function generateSite(config) {
631
662
  await Bun.write(robotsPath, `${base}Sitemap: ${config.baseUrl}/sitemap.xml\n`);
632
663
  }
633
664
  }
665
+ // A site's own 404.html (copied from its static files) wins.
666
+ if (!existsSync(`${outputDir}/404.html`)) {
667
+ await Bun.write(`${outputDir}/404.html`, notFoundHtml(flattenNav(buildNavTree(docs, slugMap)), slugMap, config));
668
+ }
634
669
  return count;
635
670
  }
671
+ /*
672
+ The page a host serves for a path the site does not have: GitHub Pages and Firebase serve
673
+ `404.html` from the site root, at whatever URL was asked for, so nothing in it may be
674
+ relative. It lists every page, which is what a reader without JavaScript (an agent's fetch
675
+ tool, most often) needs to find its way, and with JavaScript it names the closest page to the
676
+ path asked for. The script is a nicety: under a policy that blocks inline scripts, the list
677
+ is still there.
678
+ */
679
+ export function notFoundHtml(pages, slugMap, config) {
680
+ const { projectName = '', basePath, lang = 'en', llmsTxt } = config;
681
+ const href = (slug) => withBase(basePath, pathForSlug(slug));
682
+ const listed = pages.filter((node) => !node.doc.hidden);
683
+ const items = listed
684
+ .map((node) => ` <li><a href="${escapeAttr(href(node.slug))}">${escapeText(node.doc.title || node.slug || 'Home')}</a></li>`)
685
+ .join('\n');
686
+ const slugs = JSON.stringify(Object.values(slugMap).filter((slug) => slug !== '')).replace(/</g, '\\u003c');
687
+ const title = projectName
688
+ ? `Page not found — ${projectName}`
689
+ : 'Page not found';
690
+ return `<!DOCTYPE html>
691
+ <html lang="${escapeAttr(lang)}">
692
+ <head>
693
+ <meta charset="utf-8" />
694
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
695
+ <meta name="robots" content="noindex" />
696
+ <title>${escapeText(title)}</title>
697
+ <style>
698
+ body { font: 16px/1.5 system-ui, sans-serif; max-width: 40em; margin: 2em auto; padding: 0 1em; }
699
+ @media (prefers-color-scheme: dark) { body { background: #111; color: #eee; } a { color: #8bf; } }
700
+ </style>
701
+ </head>
702
+ <body>
703
+ <h1>Page not found</h1>
704
+ <p>There is no page at <code id="asked"></code> on this site.<span id="closest"></span></p>
705
+ <p><a href="${escapeAttr(withBase(basePath, '/'))}">Home</a>${llmsTxt
706
+ ? ` · <a href="${escapeAttr(withBase(basePath, '/llms.txt'))}">llms.txt</a> (every page, for agents)`
707
+ : ''}</p>
708
+ <h2>Pages</h2>
709
+ <ul>
710
+ ${items}
711
+ </ul>
712
+ <script>
713
+ ;(() => {
714
+ const slugs = ${slugs}
715
+ const base = ${JSON.stringify(withBase(basePath, '/'))}
716
+ const asked = decodeURIComponent(location.pathname)
717
+ document.getElementById('asked').textContent = asked
718
+ const want = asked.slice(base.length - 1).replace(/^\\/+|\\/+$/g, '').toLowerCase()
719
+ if (!want) return
720
+ const distance = (a, b) => {
721
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i)
722
+ for (let i = 1; i <= a.length; i++) {
723
+ let prev = row[0]++
724
+ for (let j = 1; j <= b.length; j++) {
725
+ const next = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
726
+ prev = row[j]
727
+ row[j] = next
728
+ }
729
+ }
730
+ return row[b.length]
731
+ }
732
+ let best = '', score = Infinity
733
+ for (const slug of slugs) {
734
+ const prefix = Math.min(want.length, slug.length) >= 3 && (slug.startsWith(want) || want.startsWith(slug))
735
+ const d = prefix ? 0.5 : distance(want, slug)
736
+ if (d < score) [best, score] = [slug, d]
737
+ }
738
+ if (score > Math.max(2, want.length / 3)) return
739
+ const link = document.createElement('a')
740
+ link.href = base + best + '/'
741
+ link.textContent = base + best + '/'
742
+ const closest = document.getElementById('closest')
743
+ closest.append(' Did you mean ', link, '?')
744
+ })()
745
+ </script>
746
+ </body>
747
+ </html>
748
+ `;
749
+ }
@@ -1653,6 +1653,7 @@ export async function buildSite(config, opts = {}) {
1653
1653
  const pageCount = await generateSite({
1654
1654
  liveExamples: config.liveExamples,
1655
1655
  docs,
1656
+ llmsTxt: config.llmsTxt !== false,
1656
1657
  aliases: config.aliases,
1657
1658
  searchPlaceholder: config.searchPlaceholder,
1658
1659
  accessibility: config.accessibility,
@@ -1,8 +1,9 @@
1
- import type { ExampleTab } from '../example-policy.js';
1
+ import type { ExampleTab, ExampleView } from '../example-policy.js';
2
2
  export interface PageGlobalsConfig {
3
3
  liveExamples?: 'auto' | 'opt-in' | 'no-code';
4
4
  exampleConsole?: boolean;
5
5
  exampleTabs?: ExampleTab[];
6
+ exampleView?: ExampleView;
6
7
  dialects?: string[];
7
8
  }
8
9
  /**
@@ -13,5 +14,6 @@ export interface PageGlobalsConfig {
13
14
  export declare function siteSwitchProblem(config: {
14
15
  liveExamples?: string;
15
16
  kind?: string;
17
+ exampleView?: string;
16
18
  }): string | null;
17
19
  export declare function pageGlobalsHead(config: PageGlobalsConfig): string;
@@ -6,7 +6,7 @@ bundle reads when it runs (so they must come BEFORE the bundle script — genera
6
6
  Each is emitted only when it differs from the default, so a default site's HTML is unchanged
7
7
  byte-for-byte when a new switch is added.
8
8
  */
9
- import { exampleTabsProblem, normalizeDialectNames } from '../example-policy.js';
9
+ import { EXAMPLE_VIEWS, exampleTabsProblem, normalizeDialectNames, } from '../example-policy.js';
10
10
  const LIVE_EXAMPLE_POLICIES = ['auto', 'opt-in', 'no-code'];
11
11
  const SITE_KINDS = ['documentation', 'book'];
12
12
  /**
@@ -19,7 +19,8 @@ export function siteSwitchProblem(config) {
19
19
  ? `SiteConfig.${name} is ${JSON.stringify(value)}; it must be one of ${allowed.map((a) => `'${a}'`).join(', ')}`
20
20
  : null;
21
21
  return (wrong('liveExamples', config.liveExamples, LIVE_EXAMPLE_POLICIES) ??
22
- wrong('kind', config.kind, SITE_KINDS));
22
+ wrong('kind', config.kind, SITE_KINDS) ??
23
+ wrong('exampleView', config.exampleView, [...EXAMPLE_VIEWS]));
23
24
  }
24
25
  export function pageGlobalsHead(config) {
25
26
  // A misspelt tab would hide a tab on every example with nothing to say why.
@@ -35,6 +36,10 @@ export function pageGlobalsHead(config) {
35
36
  config.exampleTabs
36
37
  ? `<script>globalThis.__TOSI_EXAMPLE_TABS=${JSON.stringify(config.exampleTabs)}</script>`
37
38
  : '',
39
+ // #252: the view examples open in
40
+ config.exampleView && config.exampleView !== 'preview'
41
+ ? `<script>globalThis.__TOSI_EXAMPLE_VIEW="${config.exampleView}"</script>`
42
+ : '',
38
43
  // 1.16.2: the example console
39
44
  config.exampleConsole === false
40
45
  ? `<script>globalThis.__TOSI_EXAMPLE_CONSOLE=false</script>`
@@ -8,7 +8,7 @@ import type { EditableDataOptions } from './data-store.js';
8
8
  import type { BookManifest } from '../book-manifest.js';
9
9
  import type { AccessibilityMode } from '../render.js';
10
10
  import type { AltTextHook } from './alt-text.js';
11
- import type { ExampleTab } from '../example-policy.js';
11
+ import type { ExampleTab, ExampleView } from '../example-policy.js';
12
12
  export type SiteHost = 'github-pages' | 'firebase' | 'static';
13
13
  /** Resolved paths handed to a `libraryBuild` override (see SiteConfig). */
14
14
  export interface LibraryBuildContext {
@@ -200,6 +200,14 @@ export interface SiteConfig {
200
200
  * `{"tabs": ["html"]}`. A hidden tab's block still runs; it just can't be edited.
201
201
  */
202
202
  exampleTabs?: ExampleTab[];
203
+ /**
204
+ * The view every live example opens in (1.17.4, #252): `'code'` puts the preview beside its
205
+ * code, `'console'` the console beside the code. Default `'preview'`, the example alone. For
206
+ * a book whose small examples should show their code: set it once here instead of
207
+ * `{"view": "code"}` on every fence. A page overrides it with `exampleView` in its metadata,
208
+ * and one example with the fence option `{"view": "preview"}`.
209
+ */
210
+ exampleView?: ExampleView;
203
211
  /**
204
212
  * Run script examples from same-origin modules (1.17.0, #245), for a site served under a
205
213
  * Content Security Policy without `'unsafe-eval'` (`script-src 'self'`).