tosijs-ui 1.16.35 → 1.16.36

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.36
4
+
5
+ One additive site option. Nothing changes for a site that does not set it.
6
+
7
+ - **A page can be served at a second path (#3513).** `aliases: { readme: '' }` in the site
8
+ config also writes the root page to `/readme/`, with its stylesheet, bundle and `docs.json`
9
+ references worked out for that depth, and the browser opens it as the README. Before this a
10
+ host rewrite was the only way, and it served the page one level down with every relative
11
+ reference 404ing, so the page rendered but never hydrated. An alias page's canonical link
12
+ names the page's own path, aliases stay out of the sitemap and the nav, and relative images
13
+ and links in the page are rewritten to reach the same files. An alias is one lowercase path
14
+ segment with no dot; the build fails on one that is not, that takes a page's own path or a
15
+ static folder's, or that names no page.
16
+
3
17
  ## 1.16.35
4
18
 
5
19
  Board follow-ups from 1.16.32 and 1.16.33, plus one tunnel feature. Nothing to change in an
@@ -102,6 +102,11 @@ export interface DocBrowserOptions {
102
102
  * doc). Lets a host mount the browser already pointed at a specific doc.
103
103
  */
104
104
  initialRoute?: string;
105
+ /**
106
+ * Path routing only: extra paths a doc is served at, `{ alias: slug }` (`''` is the root
107
+ * page). The site build passes its `aliases` here, so `/readme/` opens the README.
108
+ */
109
+ aliases?: Record<string, string>;
105
110
  /**
106
111
  * Memory routing only: called with the current doc's slug whenever in-app
107
112
  * navigation happens, so a host can reflect it (e.g. to an attribute).
@@ -354,7 +354,7 @@ const logoMarkStyleSpec = {
354
354
  // tosi() registry key (see stateKey below) and two browsers can't share state.
355
355
  let docBrowserSeq = 0;
356
356
  export function createDocBrowser(options) {
357
- const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, onRouteChange, searchShortcut = 'mod+k', navbarLinks, contentElement, } = options;
357
+ const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, aliases = {}, onRouteChange, searchShortcut = 'mod+k', navbarLinks, contentElement, } = options;
358
358
  // Memory routing is fully self-contained: it never reads or writes
359
359
  // window.history/location, so an embedded or nested browser can't hijack the
360
360
  // host page's URL (or recurse into it).
@@ -381,7 +381,7 @@ export function createDocBrowser(options) {
381
381
  if (memoryRouting)
382
382
  return initialRoute ? filenameForSlug(initialRoute) : '';
383
383
  return routing === 'path'
384
- ? filenameForPath(document.location.pathname, slugMap)
384
+ ? filenameForPath(document.location.pathname, slugMap, aliases)
385
385
  : document.location.search !== ''
386
386
  ? document.location.search.substring(1).split('&')[0]
387
387
  : '';
@@ -414,7 +414,7 @@ export function createDocBrowser(options) {
414
414
  }
415
415
  // Clean `/slug/` path form (only meaningful when slugs are in play).
416
416
  if (!memoryRouting && routing !== 'query') {
417
- const filename = known(filenameForPath(url.pathname, slugMap));
417
+ const filename = known(filenameForPath(url.pathname, slugMap, aliases));
418
418
  if (filename)
419
419
  return filename;
420
420
  }
@@ -48,6 +48,8 @@ export declare class TosiDocSystem extends TosiDocSystem_base {
48
48
  private initPrefs;
49
49
  private initLocale;
50
50
  private settingsButton;
51
+ /** The site's `aliases`, which the build writes into the config attribute (board #3513). */
52
+ private configAliases;
51
53
  private parseLinks;
52
54
  connectedCallback(): void;
53
55
  render(): void;
@@ -485,6 +485,16 @@ export class TosiDocSystem extends withAttributes({
485
485
  // ---- Lifecycle -----------------------------------------------------------
486
486
  // Parse a declarative <ul class="..."> of <li><a href data-icon?>label</a> into
487
487
  // LinkItem[]. Returns [] when the list is absent.
488
+ /** The site's `aliases`, which the build writes into the config attribute (board #3513). */
489
+ configAliases() {
490
+ try {
491
+ const { aliases } = JSON.parse(this.config || '{}');
492
+ return aliases && typeof aliases === 'object' ? aliases : undefined;
493
+ }
494
+ catch {
495
+ return undefined;
496
+ }
497
+ }
488
498
  parseLinks(selector) {
489
499
  const list = this.querySelector(selector);
490
500
  if (!list)
@@ -570,6 +580,7 @@ export class TosiDocSystem extends withAttributes({
570
580
  this.browser = createDocBrowser({
571
581
  docs: this.corpus,
572
582
  routing: memory ? 'memory' : 'path',
583
+ aliases: memory ? undefined : this.configAliases(),
573
584
  initialRoute: memory ? this.route || undefined : undefined,
574
585
  onRouteChange: memory
575
586
  ? (slug) => {
@@ -55,8 +55,21 @@ export declare function slugForPath(pathname: string): string;
55
55
  /**
56
56
  * Resolve a browser location.pathname to a doc filename, using a slug map.
57
57
  * Returns '' if nothing matches (caller falls back to the first/README doc).
58
+ *
59
+ * `aliases` ({ alias path -> slug }, the site config's `aliases`) are a doc's extra paths: a
60
+ * page's own slug wins, so an alias never takes a path from a page.
61
+ */
62
+ export declare function filenameForPath(pathname: string, slugMap: Record<string, string>, aliases?: Record<string, string>): string;
63
+ /**
64
+ * Check a site's `aliases` against its slug map, and return the problems in words (empty when
65
+ * there are none). An alias is one lowercase path segment with no dot, takes no page's own
66
+ * path, and names a page.
67
+ *
68
+ * Lowercase because on a case-insensitive filesystem (macOS, Windows) writing `Guide/` replaces
69
+ * `guide/`, and a crawler at `/guide/` then got another page. No dot because every file the
70
+ * build writes at the root has one (`docs.json`, `sitemap.xml`, `llms.txt`).
58
71
  */
59
- export declare function filenameForPath(pathname: string, slugMap: Record<string, string>): string;
72
+ export declare function aliasProblems(aliases: Record<string, string>, slugMap: Record<string, string>): string[];
60
73
  /** Turn a human name/title into a slug: 'Form Components' -> 'form-components'. */
61
74
  export declare function slugify(s: string): string;
62
75
  /**
@@ -133,14 +133,47 @@ export function slugForPath(pathname) {
133
133
  /**
134
134
  * Resolve a browser location.pathname to a doc filename, using a slug map.
135
135
  * Returns '' if nothing matches (caller falls back to the first/README doc).
136
+ *
137
+ * `aliases` ({ alias path -> slug }, the site config's `aliases`) are a doc's extra paths: a
138
+ * page's own slug wins, so an alias never takes a path from a page.
136
139
  */
137
- export function filenameForPath(pathname, slugMap) {
140
+ export function filenameForPath(pathname, slugMap, aliases = {}) {
138
141
  const slug = slugForPath(pathname);
139
- for (const [filename, docSlug] of Object.entries(slugMap)) {
140
- if (docSlug === slug)
141
- return filename;
142
+ const find = (wanted) => {
143
+ for (const [filename, docSlug] of Object.entries(slugMap)) {
144
+ if (docSlug === wanted)
145
+ return filename;
146
+ }
147
+ return '';
148
+ };
149
+ const own = find(slug);
150
+ return own || (Object.hasOwn(aliases, slug) ? find(aliases[slug]) : '');
151
+ }
152
+ /**
153
+ * Check a site's `aliases` against its slug map, and return the problems in words (empty when
154
+ * there are none). An alias is one lowercase path segment with no dot, takes no page's own
155
+ * path, and names a page.
156
+ *
157
+ * Lowercase because on a case-insensitive filesystem (macOS, Windows) writing `Guide/` replaces
158
+ * `guide/`, and a crawler at `/guide/` then got another page. No dot because every file the
159
+ * build writes at the root has one (`docs.json`, `sitemap.xml`, `llms.txt`).
160
+ */
161
+ export function aliasProblems(aliases, slugMap) {
162
+ const slugs = new Set(Object.values(slugMap));
163
+ const folded = new Set([...slugs].map((s) => s.toLowerCase()));
164
+ const problems = [];
165
+ for (const [alias, target] of Object.entries(aliases)) {
166
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(alias)) {
167
+ problems.push(`"${alias}" is not one lowercase path segment (a-z, 0-9, "_", "-")`);
168
+ }
169
+ else if (folded.has(alias)) {
170
+ problems.push(`"${alias}" is already a page's own path`);
171
+ }
172
+ if (!slugs.has(target)) {
173
+ problems.push(`"${alias}" names "${target}", which is no page's slug ('' is the root page)`);
174
+ }
142
175
  }
143
- return '';
176
+ return problems;
144
177
  }
145
178
  /** Turn a human name/title into a slug: 'Form Components' -> 'form-components'. */
146
179
  export function slugify(s) {
@@ -6,6 +6,8 @@ declare global {
6
6
  }
7
7
  export interface GenerateSiteConfig {
8
8
  docs: Doc[];
9
+ /** `SiteConfig.aliases`: { alias path -> slug } (board #3513) */
10
+ aliases?: Record<string, string>;
9
11
  /**
10
12
  * Mirrors `SiteConfig.liveExamples`. The static highlighter needs it so it skips the
11
13
  * fences that will become live examples — a highlighted block hands the example markup
@@ -130,4 +132,10 @@ export declare function pageDepth(slug: string): number;
130
132
  */
131
133
  export declare function relativeUrl(depth: number, p: string): string;
132
134
  export declare function withStamp(url: string, stamp?: string): string;
135
+ /**
136
+ * Rewrite the relative `href`/`src` references in rendered page HTML, written for a page at
137
+ * `fromPath` ('/' or '/slug/'), so they reach the same files from a page at `depth`. Rooted
138
+ * (`/x`), external, `#`, `?` and `data:`-style references are left alone.
139
+ */
140
+ export declare function rebaseRelativeRefs(html: string, fromPath: string, depth: number): string;
133
141
  export declare function generateSite(config: GenerateSiteConfig): Promise<number>;
@@ -12,10 +12,11 @@ component (src/doc-system/*) so static and hydrated output agree.
12
12
  */
13
13
  import { pageTitle } from '../doc-title.js';
14
14
  import { highlightHtml } from '../highlight.js';
15
- import { buildSlugMap, pathForSlug, rewriteDocLinks, withBase, wikilinkTargets, } from '../routing.js';
15
+ import { aliasProblems, buildSlugMap, pathForSlug, rewriteDocLinks, withBase, wikilinkTargets, } from '../routing.js';
16
16
  import { buildNavTree, navOpenPath } from '../nav-tree.js';
17
17
  import { renderDocMarkdown, docDescription, } from '../render.js';
18
18
  import { bareNameLinks, deadInPageAnchors, missingWikilinks, misconfigured, } from './build-warnings.js';
19
+ import { existsSync } from 'fs';
19
20
  const escapeAttr = (s) => s
20
21
  .replace(/&/g, '&amp;')
21
22
  .replace(/</g, '&lt;')
@@ -111,11 +112,14 @@ export function relativeUrl(depth, p) {
111
112
  const rel = '../'.repeat(depth) + p.slice(1);
112
113
  return rel === '' ? './' : rel;
113
114
  }
114
- async function pageHtml(doc, config, slugMap, configAttr, wikilinks) {
115
+ async function pageHtml(doc, config, slugMap, configAttr, wikilinks,
116
+ /** the slug this copy is written at: the doc's own, or an alias of it */
117
+ at = slugMap[doc.filename] ?? '') {
115
118
  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;
116
119
  // Functional URLs are emitted relative to THIS page's depth so the build is
117
120
  // mount-agnostic (issue #25); metadata URLs below stay absolute via withBase.
118
- const depth = pageDepth(slugMap[doc.filename] ?? '');
121
+ const depth = pageDepth(at);
122
+ const own = slugMap[doc.filename] ?? '';
119
123
  const localizedAttr = config.localizedStrings
120
124
  ? ` localized="${escapeAttr(relativeUrl(depth, localizedUrl))}"`
121
125
  : '';
@@ -142,7 +146,7 @@ async function pageHtml(doc, config, slugMap, configAttr, wikilinks) {
142
146
  // Rewrite legacy `?filename` content links to clean `/slug/` paths so the
143
147
  // static HTML is correct for no-JS readers and crawlers (the doc-browser also
144
148
  // does this client-side after hydration).
145
- const body = rewriteDocLinks(await highlightHtml(renderDocMarkdown(doc.text, {
149
+ const rendered = rewriteDocLinks(await highlightHtml(renderDocMarkdown(doc.text, {
146
150
  bakes: bakes?.get(doc.filename),
147
151
  wikilinks,
148
152
  }),
@@ -151,6 +155,11 @@ async function pageHtml(doc, config, slugMap, configAttr, wikilinks) {
151
155
  config.liveExamples ?? 'auto'), (filename) => slugMap[filename] !== undefined
152
156
  ? relativeUrl(depth, pathForSlug(slugMap[filename]))
153
157
  : null);
158
+ // A copy at an alias: what the author wrote relative to the page's own path must still reach
159
+ // the same file (a README's `img/logo.png` is /img/logo.png, not /readme/img/logo.png).
160
+ const body = at === own
161
+ ? rendered
162
+ : rebaseRelativeRefs(rendered, pathForSlug(own), depth);
154
163
  const nav = navHtml(config.docs, slugMap, doc.filename, depth);
155
164
  const navbar = linkListHtml('doc-navbar', config.navbarLinks);
156
165
  const jsonLd = baseUrl
@@ -289,6 +298,19 @@ export function withStamp(url, stamp) {
289
298
  return url;
290
299
  return `${url}?v=${encodeURIComponent(stamp)}`;
291
300
  }
301
+ /**
302
+ * Rewrite the relative `href`/`src` references in rendered page HTML, written for a page at
303
+ * `fromPath` ('/' or '/slug/'), so they reach the same files from a page at `depth`. Rooted
304
+ * (`/x`), external, `#`, `?` and `data:`-style references are left alone.
305
+ */
306
+ export function rebaseRelativeRefs(html, fromPath, depth) {
307
+ return html.replace(/(\s(?:href|src|poster)=")([^"]*)"/g, (match, attr, ref) => {
308
+ if (!ref || /^(?:[a-z][a-z0-9+.-]*:|\/|#|\?)/i.test(ref))
309
+ return match;
310
+ const rooted = new URL(ref, `https://x${fromPath}`);
311
+ return `${attr}${relativeUrl(depth, rooted.pathname + rooted.search + rooted.hash)}"`;
312
+ });
313
+ }
292
314
  export async function generateSite(config) {
293
315
  const { docs, outputDir } = config;
294
316
  const slugMap = buildSlugMap(docs);
@@ -303,8 +325,19 @@ export async function generateSite(config) {
303
325
  }
304
326
  seen.set(slug, doc.filename);
305
327
  }
328
+ // A wrong alias fails the build like a `home` that names no page: it is a typo or a
329
+ // renamed page, and a site with a dead path is not one to ship quietly.
330
+ const aliases = config.aliases ?? {};
331
+ const problems = aliasProblems(aliases, slugMap);
332
+ if (problems.length) {
333
+ throw new Error(`generate-site: aliases are wrong:\n${problems
334
+ .map((p) => ` ${p}`)
335
+ .join('\n')}`);
336
+ }
306
337
  const configAttr = escapeAttr(JSON.stringify({
307
338
  projectName: config.projectName,
339
+ // The browser needs them too, or an alias page hydrates as whichever doc comes first.
340
+ ...(Object.keys(aliases).length ? { aliases } : {}),
308
341
  projectLinks: config.projectLinks,
309
342
  logo: config.logo,
310
343
  bookVolumes: config.bookVolumes,
@@ -341,6 +374,26 @@ export async function generateSite(config) {
341
374
  }
342
375
  count += 1;
343
376
  }
377
+ /*
378
+ Each alias is the page again, written where the alias is served, so its relative references
379
+ (stylesheet, bundle, docs.json, nav links) resolve from there. A host rewrite to the original
380
+ page cannot do that: it serves the same bytes at a different depth and the page never
381
+ hydrates (board #3513). The canonical link stays the page's own path.
382
+ */
383
+ for (const [alias, target] of Object.entries(aliases)) {
384
+ const doc = docs.find((d) => slugMap[d.filename] === target);
385
+ const dir = `${outputDir}/${alias}`;
386
+ // The output folder starts empty, so anything already here is a static file or folder, or
387
+ // one the build wrote (`tjs/`, `_chunks/`): an alias must not write into it.
388
+ if (existsSync(dir)) {
389
+ throw new Error(`generate-site: the alias "${alias}" names /${alias}/, which the site already has ` +
390
+ `(a static file or folder, or one the build writes); choose another name`);
391
+ }
392
+ await Bun.write(`${dir}/index.html`, await pageHtml(doc, config, slugMap, configAttr, wikilinks, alias));
393
+ if (hasMarkdownPage(doc, config)) {
394
+ await Bun.write(`${dir}/index.md`, doc.text.trimEnd() + '\n');
395
+ }
396
+ }
344
397
  if (danglingWikilinks.length) {
345
398
  misconfigured(config.strict, `\n⚠️ Wikilinks that reach no doc page. A target matches a page's slug (its file name)\n` +
346
399
  ` or its title; write [[slug|label]] to link one by file name, and an ordinary\n` +
@@ -1491,6 +1491,7 @@ export async function buildSite(config, opts = {}) {
1491
1491
  const pageCount = await generateSite({
1492
1492
  liveExamples: config.liveExamples,
1493
1493
  docs,
1494
+ aliases: config.aliases,
1494
1495
  outputDir: PUBLIC,
1495
1496
  projectName: config.name,
1496
1497
  bookVolumes: printCoverManifestUrl(config),
@@ -61,6 +61,16 @@ export interface SiteConfig {
61
61
  * names no page. A doc can also claim the root itself with `{"home": true}`.
62
62
  */
63
63
  home?: string;
64
+ /**
65
+ * Extra paths a page is served at, as `{ alias: slug }`; `''` is the root page. With
66
+ * `aliases: { readme: '' }` the README (the root page) is also written to `/readme/`, with its
67
+ * stylesheet, bundle and `docs.json` references computed for that depth, and the browser
68
+ * opens it as the README. Each alias page's canonical link names the page's own path, and
69
+ * aliases stay out of the sitemap and the nav; relative references in the page are rewritten
70
+ * to reach the same files. An alias is one lowercase path segment with no dot; the build fails
71
+ * on one that is not, that takes a page's own path or a static folder's, or that names no page.
72
+ */
73
+ aliases?: Record<string, string>;
64
74
  /**
65
75
  * Directories to skip while scanning `docPaths` — by basename (skipped wherever it
66
76
  * appears) or by path (skipped only at that location). Added to the built-in