tosijs-ui 1.17.2 → 1.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.3
4
+
5
+ > A doc site answers `/slug` as well as `/slug/`, and a missing page gets a real 404 page.
6
+ > Nothing to change on upgrade. On Firebase, add `"cleanUrls": true` to `firebase.json` for
7
+ > `/slug` to serve the page; GitHub Pages, Netlify and Cloudflare Pages need nothing.
8
+
9
+ ### Added
10
+
11
+ - Every page (and alias) is also written as `/slug.html`, with its references written for
12
+ `/slug`, so a host that resolves extensionless paths (GitHub Pages, Netlify, Cloudflare
13
+ Pages, Firebase with `cleanUrls`) answers `/slug` with the page instead of a redirect. An
14
+ agent's fetch tool that does not follow redirects got a "moved" stub at
15
+ virta.tosijs.net/start. The canonical link stays `/slug/`; a file the site already has at
16
+ `/slug.html` is left alone and named in a warning.
17
+ - The build writes `404.html` (unless the site ships its own): it names the path asked for,
18
+ suggests the closest page, lists every page and links `llms.txt`. Every link in it is
19
+ absolute, since hosts serve it at whatever URL was asked for.
20
+
21
+ ### Fixed
22
+
23
+ - After Cmd-K, the doc site's search field sat on top of a maximized live example. Opening the
24
+ palette raised the whole `<tosi-palette>` (its trigger included) to the page's highest
25
+ z-index and never put it back, and any overlay on the page (haltija's widget on a dev
26
+ server, a toast in an app) made that higher than the example's. The dialog never needed it:
27
+ `showModal()` already puts it in the top layer.
28
+ - The dev server served `/slug` from `slug/index.html`, whose references are written for
29
+ `/slug/`; it now serves `slug.html`. (Its `.html` fallback looked for `slug/.html` and never
30
+ matched.)
31
+ - The live-theme page showed two theme editors: its example had an `html` block and a `js`
32
+ block that each made one.
33
+
3
34
  ## 1.17.2
4
35
 
5
36
  > Components carry only the icons they draw, and `<tosi-affix>` centres a caption on its
@@ -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;
@@ -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,