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 +31 -0
- package/dist/doc-system/site/dev-server.js +8 -3
- package/dist/doc-system/site/generate-site.d.ts +4 -0
- package/dist/doc-system/site/generate-site.js +119 -5
- package/dist/doc-system/site/orchestrator.js +1 -0
- package/dist/iife.js +52 -52
- package/dist/iife.js.map +5 -5
- package/dist/live-theme.js +0 -3
- package/dist/palette.js +3 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
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
|
-
|
|
1232
|
-
|
|
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
|
|
214
|
-
// the same file (a README's
|
|
215
|
-
|
|
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,
|