tosijs-ui 1.6.9 → 1.6.11

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/README.md CHANGED
@@ -67,7 +67,7 @@ npm add tosijs-ui
67
67
  Then import the component `elementCreator` and create elements. A `tosijs`
68
68
  `elementCreator` is syntax sugar around `document.createElement()`.
69
69
 
70
- ```ts
70
+ ```typescript
71
71
  import { tosiTable } from 'tosijs-ui'
72
72
 
73
73
  document.body.append(tosiTable())
@@ -274,6 +274,40 @@ export function createDocBrowser(options) {
274
274
  ? document.location.search.substring(1).split('&')[0]
275
275
  : '';
276
276
  };
277
+ // Resolve a content-link anchor to one of our docs' filenames, in either the
278
+ // legacy `?filename` form or a clean `/slug/` path — or null for external
279
+ // links, in-page anchors, mailto:, downloads, etc. Used to navigate internal
280
+ // doc links client-side (via go()) instead of triggering a full page reload,
281
+ // and to canonicalize their hrefs for the current routing mode.
282
+ const docFilenameForHref = (anchor) => {
283
+ const raw = anchor.getAttribute('href') || '';
284
+ if (raw === '' || raw.startsWith('#'))
285
+ return null;
286
+ let url;
287
+ try {
288
+ url = new URL(raw, document.location.href);
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ if (url.origin !== document.location.origin)
294
+ return null; // external
295
+ const known = (filename) => docs.some((doc) => doc.filename === filename) ? filename : null;
296
+ // Legacy `?filename` form: a single bare filename, not a real query string.
297
+ const query = url.search.replace(/^\?/, '');
298
+ if (query !== '' && !query.includes('=') && !query.includes('&')) {
299
+ const filename = known(decodeURIComponent(query));
300
+ if (filename)
301
+ return filename;
302
+ }
303
+ // Clean `/slug/` path form (only meaningful when slugs are in play).
304
+ if (!memoryRouting && routing !== 'query') {
305
+ const filename = known(filenameForPath(url.pathname, slugMap));
306
+ if (filename)
307
+ return filename;
308
+ }
309
+ return null;
310
+ };
277
311
  const docName = filenameFromLocation() || docs[0]?.filename || 'README.md';
278
312
  const currentDoc = docs.find((doc) => doc.filename === docName) || docs[0];
279
313
  const { app } = tosi({
@@ -512,6 +546,20 @@ export function createDocBrowser(options) {
512
546
  // matches the initial doc. A memory-routed embed's initial doc
513
547
  // (initialRoute/docs[0]) has no relation to whatever happens to sit inside the
514
548
  // host element (often an empty placeholder), so always render it fresh.
549
+ // Canonicalize internal content links to the current routing mode's href and
550
+ // tag them — so copy-link / right-click / no-JS land on the right page, and a
551
+ // left-click can be short-circuited to client-side nav. Authored docs use the
552
+ // routing-agnostic `?filename` form (the only one resolvable in every mode);
553
+ // this rewrites them to `/slug/` on the static path-routed site, etc.
554
+ const rewriteContentLinks = () => {
555
+ for (const anchor of docContent.querySelectorAll('a')) {
556
+ const filename = docFilenameForHref(anchor);
557
+ if (filename !== null) {
558
+ anchor.setAttribute('href', hrefFor(filename));
559
+ anchor.dataset.docLink = filename;
560
+ }
561
+ }
562
+ };
515
563
  let adoptInitialContent = contentElement !== undefined && !memoryRouting;
516
564
  const showDoc = (doc) => {
517
565
  if (adoptInitialContent) {
@@ -520,6 +568,7 @@ export function createDocBrowser(options) {
520
568
  else {
521
569
  docContent.innerHTML = renderDocMarkdown(doc.text);
522
570
  }
571
+ rewriteContentLinks();
523
572
  // Stamp each example with its source file (for the source↔doc map). doc.path
524
573
  // is the extracted file (.md, or a source file with doc comments).
525
574
  LiveExample.insertExamples(docContent, context, doc.path || undefined);
@@ -554,6 +603,28 @@ export function createDocBrowser(options) {
554
603
  }
555
604
  navigateTo(filename);
556
605
  };
606
+ // Intercept clicks on internal doc links anywhere in the rendered content so
607
+ // they navigate client-side instead of reloading the page. Plain left-clicks
608
+ // only — modified clicks / middle-clicks / new-tab / download links fall
609
+ // through to the browser. Works for both freshly-rendered and statically
610
+ // pre-rendered (adopted) content, resolving the href at click time.
611
+ docContent.addEventListener('click', (event) => {
612
+ if (event.defaultPrevented ||
613
+ event.button !== 0 ||
614
+ event.metaKey ||
615
+ event.ctrlKey ||
616
+ event.shiftKey ||
617
+ event.altKey)
618
+ return;
619
+ const anchor = event.target.closest('a');
620
+ if (!anchor || anchor.target === '_blank' || anchor.hasAttribute('download'))
621
+ return;
622
+ const filename = docFilenameForHref(anchor);
623
+ if (filename === null)
624
+ return;
625
+ event.preventDefault();
626
+ go(filename);
627
+ });
557
628
  // ── Edit page source (Foundation B / #4) ──────────────────────────────────
558
629
  // The "view source" affordance opens a menu: edit the whole source file in a
559
630
  // code editor that fills the content area, preview the result in-browser, and
@@ -656,6 +727,7 @@ export function createDocBrowser(options) {
656
727
  editUI.view = view;
657
728
  if (view === 'preview') {
658
729
  docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path));
730
+ rewriteContentLinks();
659
731
  LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
660
732
  }
661
733
  else if (view === 'diff') {
@@ -12,6 +12,14 @@ export interface DocLike {
12
12
  export declare function buildSlugMap(docs: DocLike[]): Record<string, string>;
13
13
  /** site-root-relative path for a slug: '' -> '/', 'button' -> '/button/' */
14
14
  export declare function pathForSlug(slug: string): string;
15
+ /**
16
+ * Rewrite legacy `?<filename>` (and `/?<filename>`) doc links in an HTML string
17
+ * to resolved hrefs. Used by the static generator so the pre-rendered pages have
18
+ * clean links for no-JS readers, crawlers, and the brief window before the
19
+ * doc-browser hydrates. `hrefFor` returns the target href for a known filename,
20
+ * or null to leave the link untouched (unknown filename / a real query string).
21
+ */
22
+ export declare function rewriteDocLinks(html: string, hrefFor: (filename: string) => string | null): string;
15
23
  /**
16
24
  * Map a legacy `?<filename>` query (the old doc-browser's query-param routing,
17
25
  * e.g. `?button.ts`, `?README.md`) to the new slug path (`/button/`, `/`).
@@ -46,6 +46,19 @@ export function buildSlugMap(docs) {
46
46
  export function pathForSlug(slug) {
47
47
  return slug === '' ? '/' : `/${slug}/`;
48
48
  }
49
+ /**
50
+ * Rewrite legacy `?<filename>` (and `/?<filename>`) doc links in an HTML string
51
+ * to resolved hrefs. Used by the static generator so the pre-rendered pages have
52
+ * clean links for no-JS readers, crawlers, and the brief window before the
53
+ * doc-browser hydrates. `hrefFor` returns the target href for a known filename,
54
+ * or null to leave the link untouched (unknown filename / a real query string).
55
+ */
56
+ export function rewriteDocLinks(html, hrefFor) {
57
+ return html.replace(/href="\/?\?([^"&=]+)"/g, (match, query) => {
58
+ const href = hrefFor(decodeURIComponent(query));
59
+ return href === null ? match : `href="${href}"`;
60
+ });
61
+ }
49
62
  /**
50
63
  * Map a legacy `?<filename>` query (the old doc-browser's query-param routing,
51
64
  * e.g. `?button.ts`, `?README.md`) to the new slug path (`/button/`, `/`).
@@ -10,7 +10,7 @@ the IIFE bundle loads.
10
10
  Build-time only (uses Bun.write). Shares slug + markdown rendering with the runtime
11
11
  component (src/doc-system/*) so static and hydrated output agree.
12
12
  */
13
- import { buildSlugMap, pathForSlug } from '../routing';
13
+ import { buildSlugMap, pathForSlug, rewriteDocLinks } from '../routing';
14
14
  import { buildNavTree, navOpenPath } from '../nav-tree';
15
15
  import { renderDocMarkdown, docDescription } from '../render';
16
16
  const escapeAttr = (s) => s
@@ -93,7 +93,12 @@ function pageHtml(doc, config, slugMap, configAttr) {
93
93
  : doc.keywords || '';
94
94
  const canonical = baseUrl + withBase(basePath, pathForSlug(slugMap[doc.filename]));
95
95
  const imageAbs = absUrl(baseUrl, withBase(basePath, doc.image || config.ogImage || ''));
96
- const body = renderDocMarkdown(doc.text);
96
+ // Rewrite legacy `?filename` content links to clean `/slug/` paths so the
97
+ // static HTML is correct for no-JS readers and crawlers (the doc-browser also
98
+ // does this client-side after hydration).
99
+ const body = rewriteDocLinks(renderDocMarkdown(doc.text), (filename) => slugMap[filename] !== undefined
100
+ ? withBase(basePath, pathForSlug(slugMap[filename]))
101
+ : null);
97
102
  const nav = navHtml(config.docs, slugMap, doc.filename, basePath);
98
103
  const navbar = linkListHtml('doc-navbar', config.navbarLinks);
99
104
  const jsonLd = baseUrl
@@ -1,11 +1,38 @@
1
+ /** A doc as it appears in the extracted corpus (docs.json). */
2
+ interface CorpusDoc {
3
+ title?: string;
4
+ filename: string;
5
+ text?: string;
6
+ description?: string;
7
+ hidden?: boolean;
8
+ }
9
+ interface LlmsEntry {
10
+ title: string;
11
+ description: string;
12
+ /** rendered-doc URL (corpus mode) or dist/*.js path (legacy scan) */
13
+ link: string;
14
+ }
1
15
  export interface LlmsTxtMeta {
2
16
  name?: string;
3
17
  description?: string;
4
- /** site origin, used for the Docs link */
18
+ /** site origin, used for the Docs link and to make page links absolute */
5
19
  baseUrl?: string;
6
20
  /** project links — `github` / `npm` (or any) become Source/npm links */
7
21
  projectLinks?: Record<string, string | undefined>;
8
22
  /** optional framing line(s) under the description */
9
23
  tagline?: string;
10
24
  }
11
- export declare function generateLlmsTxt(outputPath: string, meta?: LlmsTxtMeta): void;
25
+ /**
26
+ * Build entries from the extracted corpus — every doc that was actually
27
+ * extracted (`.md`, `.ts`/`.js`/`.css` doc comments, auto-created sections),
28
+ * linking to its rendered URL. This is what the build uses: it reflects the real
29
+ * docs and needs no `dist/` library output.
30
+ */
31
+ export declare function entriesFromCorpus(corpus: CorpusDoc[], meta: LlmsTxtMeta): LlmsEntry[];
32
+ /**
33
+ * Write an `llms.txt` index. Pass the extracted `corpus` (the build does) to
34
+ * index every doc by its rendered URL; omit it to fall back to the legacy
35
+ * `src/*.ts`-with-`dist/*.js` scan.
36
+ */
37
+ export declare function generateLlmsTxt(outputPath: string, meta?: LlmsTxtMeta, corpus?: CorpusDoc[]): void;
38
+ export {};
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { buildSlugMap, pathForSlug } from '../routing';
3
4
  const SRC = 'src';
4
5
  const DIST = 'dist';
5
6
  function extractTitle(text) {
@@ -33,19 +34,37 @@ function extractDescription(text) {
33
34
  }
34
35
  return '';
35
36
  }
36
- export function generateLlmsTxt(outputPath, meta = {}) {
37
- // package.json supplies fallback name/version/description, but a doc site
38
- // isn't guaranteed to have one (or it may be unreadable) — degrade gracefully
39
- // rather than aborting the whole build.
40
- let pkg = {};
37
+ /**
38
+ * Build entries from the extracted corpus — every doc that was actually
39
+ * extracted (`.md`, `.ts`/`.js`/`.css` doc comments, auto-created sections),
40
+ * linking to its rendered URL. This is what the build uses: it reflects the real
41
+ * docs and needs no `dist/` library output.
42
+ */
43
+ export function entriesFromCorpus(corpus, meta) {
44
+ const slugMap = buildSlugMap(corpus);
45
+ const base = (meta.baseUrl ?? '').replace(/\/$/, '');
46
+ return corpus
47
+ .filter((doc) => doc.title && !doc.hidden)
48
+ .map((doc) => ({
49
+ title: doc.title,
50
+ description: doc.description?.trim() || extractDescription(doc.text ?? ''),
51
+ link: base + pathForSlug(slugMap[doc.filename]),
52
+ }))
53
+ .sort((a, b) => a.title.localeCompare(b.title));
54
+ }
55
+ /**
56
+ * Legacy fallback when no corpus is passed (e.g. running this file directly):
57
+ * scan `src/*.ts` for doc-comment blocks that have a built `dist/*.js` sibling.
58
+ */
59
+ function entriesFromSrcScan() {
60
+ const entries = [];
61
+ let files = [];
41
62
  try {
42
- pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
63
+ files = fs.readdirSync(SRC).filter((f) => f.endsWith('.ts'));
43
64
  }
44
65
  catch {
45
- console.warn('llms.txt: no readable package.json — using config values only');
66
+ return entries;
46
67
  }
47
- const docs = [];
48
- const files = fs.readdirSync(SRC).filter((f) => f.endsWith('.ts'));
49
68
  for (const file of files) {
50
69
  const content = fs.readFileSync(path.join(SRC, file), 'utf8');
51
70
  const match = content.match(/\/\*#([\s\S]+?)\*\//);
@@ -53,21 +72,36 @@ export function generateLlmsTxt(outputPath, meta = {}) {
53
72
  continue;
54
73
  const markdown = match[1].trim();
55
74
  const title = extractTitle(markdown);
56
- const description = extractDescription(markdown);
57
75
  if (!title)
58
76
  continue;
59
77
  const distFile = file.replace(/\.ts$/, '.js');
60
- const distPath = path.join(DIST, distFile);
61
- if (!fs.existsSync(distPath))
78
+ if (!fs.existsSync(path.join(DIST, distFile)))
62
79
  continue;
63
- docs.push({
80
+ entries.push({
64
81
  title,
65
- description,
66
- srcFile: `src/${file}`,
67
- distFile: `dist/${distFile}`,
82
+ description: extractDescription(markdown),
83
+ link: `dist/${distFile}`,
68
84
  });
69
85
  }
70
- docs.sort((a, b) => a.title.localeCompare(b.title));
86
+ return entries.sort((a, b) => a.title.localeCompare(b.title));
87
+ }
88
+ /**
89
+ * Write an `llms.txt` index. Pass the extracted `corpus` (the build does) to
90
+ * index every doc by its rendered URL; omit it to fall back to the legacy
91
+ * `src/*.ts`-with-`dist/*.js` scan.
92
+ */
93
+ export function generateLlmsTxt(outputPath, meta = {}, corpus) {
94
+ // package.json supplies fallback name/version/description, but a doc site
95
+ // isn't guaranteed to have one (or it may be unreadable) — degrade gracefully
96
+ // rather than aborting the whole build.
97
+ let pkg = {};
98
+ try {
99
+ pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
100
+ }
101
+ catch {
102
+ console.warn('llms.txt: no readable package.json — using config values only');
103
+ }
104
+ const entries = corpus ? entriesFromCorpus(corpus, meta) : entriesFromSrcScan();
71
105
  const name = meta.name ?? pkg.name ?? '';
72
106
  const description = meta.description ?? pkg.description ?? '';
73
107
  const links = [];
@@ -89,24 +123,22 @@ export function generateLlmsTxt(outputPath, meta = {}) {
89
123
  '',
90
124
  '## Documentation',
91
125
  '',
92
- 'Each component\'s full documentation (with live code examples) is',
93
- 'embedded in its distributed JS file as inline comments. Read the',
94
- 'relevant dist/*.js file for complete docs, usage examples, and API.',
126
+ 'Full documentation, with live code examples, is at the links below.',
95
127
  '',
96
- '## Components and Utilities',
128
+ '## Pages',
97
129
  '',
98
130
  ];
99
- for (const doc of docs) {
100
- lines.push(`- ${doc.distFile} — ${doc.title}`);
101
- if (doc.description) {
102
- lines.push(` ${doc.description}`);
131
+ for (const entry of entries) {
132
+ lines.push(`- ${entry.link} — ${entry.title}`);
133
+ if (entry.description) {
134
+ lines.push(` ${entry.description}`);
103
135
  }
104
136
  }
105
137
  lines.push('');
106
138
  fs.writeFileSync(outputPath, lines.join('\n'), 'utf8');
107
- console.log(`llms.txt generated (${docs.length} entries)`);
139
+ console.log(`llms.txt generated (${entries.length} entries)`);
108
140
  }
109
- // Allow running directly
141
+ // Allow running directly (no corpus — uses the legacy src/*.ts scan)
110
142
  if (import.meta.main) {
111
143
  generateLlmsTxt('dist/llms.txt');
112
144
  }
@@ -19,6 +19,7 @@ import { extractDocs } from './docs';
19
19
  import { ensureSections } from './sections';
20
20
  import { generateLlmsTxt } from './make-llms-txt';
21
21
  import { generateSite } from './generate-site';
22
+ import { findOutputDirOverlap } from './output-guard';
22
23
  // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
23
24
  // before interpolating into the require-shim detector below.
24
25
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -39,10 +40,20 @@ export async function buildSite(config) {
39
40
  console.time('prebuild');
40
41
  // Project-specific codegen (version stamp, icon data, …) before anything else.
41
42
  await config.prebuild?.();
43
+ // Guard before the destructive `rm -rf`: if a docPath overlaps the output dir,
44
+ // wiping the output would delete the source docs we're about to extract.
45
+ const docPaths = config.docPaths ?? ['src', 'README.md'];
46
+ const overlap = findOutputDirOverlap(docPaths, config.outputDir ?? 'docs', PROJECT_ROOT);
47
+ if (overlap) {
48
+ throw new Error(`doc-site build: docPath "${overlap}" overlaps outputDir "${config.outputDir ?? 'docs'}". ` +
49
+ 'buildSite() runs `rm -rf <outputDir>` before extracting docs, so this would ' +
50
+ 'delete your source docs first (producing an empty site with no error). Move the ' +
51
+ "source docs out of the output dir, or set a different outputDir (e.g. outputDir: 'site').");
52
+ }
42
53
  await $ `rm -rf ${PUBLIC}`.text();
43
54
  await $ `mkdir ${PUBLIC}`.text();
44
55
  const extract = () => extractDocs({
45
- paths: config.docPaths ?? ['src', 'README.md'],
56
+ paths: docPaths,
46
57
  // Skip the build's own output dir by path (not by the name 'docs', so a
47
58
  // source dir like src/docs is still scanned).
48
59
  ignore: ['node_modules', 'dist', 'build', PUBLIC],
@@ -139,8 +150,11 @@ export async function buildSite(config) {
139
150
  console.log(`${scriptName}: ${(bundleFile.byteLength / 1024).toFixed(1)}kb (${(bundleGzip.length / 1024).toFixed(1)}kb gzip)`);
140
151
  }
141
152
  if (config.llmsTxt !== false) {
153
+ // Drive llms.txt from the extracted corpus (every doc, by rendered URL) — not
154
+ // a re-scan of src/*.ts — so it works regardless of doc source (.md, etc.)
155
+ // and whether the project emits a dist/ library.
156
+ const corpus = JSON.parse(await Bun.file(DOCS_JSON).text());
142
157
  if (typeof config.llmsTxt === 'function') {
143
- const corpus = JSON.parse(await Bun.file(DOCS_JSON).text());
144
158
  await Bun.write('llms.txt', config.llmsTxt(corpus));
145
159
  }
146
160
  else {
@@ -149,7 +163,7 @@ export async function buildSite(config) {
149
163
  description: config.description,
150
164
  baseUrl: config.baseUrl,
151
165
  projectLinks: config.projectLinks,
152
- });
166
+ }, corpus);
153
167
  }
154
168
  // Also place it at the served web root so {baseUrl}/llms.txt resolves (the
155
169
  // root copy stays for the npm package's `files`).
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Return the first `docPaths` entry that overlaps `outputDir` (the same
3
+ * directory, or either nested inside the other), or null if none overlap.
4
+ *
5
+ * `buildSite()` runs `rm -rf <outputDir>` before it extracts docs, so an overlap
6
+ * means the source docs get deleted *before* they're read — silently producing
7
+ * an empty site with no error. Callers throw an actionable message instead.
8
+ */
9
+ export declare function findOutputDirOverlap(docPaths: string[], outputDir: string, root?: string): string | null;
@@ -0,0 +1,22 @@
1
+ import * as path from 'path';
2
+ /**
3
+ * Return the first `docPaths` entry that overlaps `outputDir` (the same
4
+ * directory, or either nested inside the other), or null if none overlap.
5
+ *
6
+ * `buildSite()` runs `rm -rf <outputDir>` before it extracts docs, so an overlap
7
+ * means the source docs get deleted *before* they're read — silently producing
8
+ * an empty site with no error. Callers throw an actionable message instead.
9
+ */
10
+ export function findOutputDirOverlap(docPaths, outputDir, root = '.') {
11
+ // Compare as directory prefixes (trailing separator) so '/docs' doesn't
12
+ // falsely match '/docs-site', while genuine nesting ('/docs' vs '/docs/api')
13
+ // and exact matches still do.
14
+ const withSep = (p) => (p.endsWith(path.sep) ? p : p + path.sep);
15
+ const out = withSep(path.resolve(root, outputDir));
16
+ for (const docPath of docPaths) {
17
+ const resolved = withSep(path.resolve(root, docPath));
18
+ if (resolved.startsWith(out) || out.startsWith(resolved))
19
+ return docPath;
20
+ }
21
+ return null;
22
+ }