tosijs-ui 1.16.21 → 1.16.23

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +2 -2
  3. package/bin/make-icon-data.js +1 -1
  4. package/dist/caption-icon.d.ts +2 -0
  5. package/dist/caption-icon.js +12 -0
  6. package/dist/code-editor.js +2 -3
  7. package/dist/data-table.js +1 -1
  8. package/dist/doc-browser.d.ts +4 -0
  9. package/dist/doc-browser.js +46 -101
  10. package/dist/doc-system/doc-system-styles.js +11 -0
  11. package/dist/doc-system/doc-system.js +1 -1
  12. package/dist/doc-system/highlight-styles.js +11 -16
  13. package/dist/doc-system/routing.d.ts +9 -0
  14. package/dist/doc-system/routing.js +22 -6
  15. package/dist/doc-system/site/build-warnings.d.ts +10 -0
  16. package/dist/doc-system/site/build-warnings.js +24 -0
  17. package/dist/doc-system/site/dev-auth.d.ts +13 -0
  18. package/dist/doc-system/site/dev-auth.js +40 -2
  19. package/dist/doc-system/site/dev-server.js +13 -1
  20. package/dist/doc-system/site/docs.d.ts +14 -0
  21. package/dist/doc-system/site/docs.js +61 -81
  22. package/dist/doc-system/site/epub.js +6 -2
  23. package/dist/doc-system/site/generate-site.js +15 -5
  24. package/dist/doc-system/site/orchestrator.js +1 -0
  25. package/dist/doc-system/site/site-config.d.ts +25 -0
  26. package/dist/editable-rect.js +1 -3
  27. package/dist/highlight-block.js +3 -10
  28. package/dist/icon-rules.d.ts +17 -1
  29. package/dist/icon-rules.js +33 -6
  30. package/dist/icon-svg.js +17 -20
  31. package/dist/icons.js +31 -15
  32. package/dist/iife.js +91 -95
  33. package/dist/iife.js.map +27 -26
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +1 -0
  36. package/dist/live-example/component.js +3 -4
  37. package/dist/menu.js +2 -2
  38. package/dist/natural-compare.js +6 -2
  39. package/dist/password-strength.js +1 -1
  40. package/dist/pocket-bar.js +2 -2
  41. package/dist/segmented.js +3 -4
  42. package/dist/select.js +2 -1
  43. package/dist/size-break.js +4 -3
  44. package/dist/track-drag.js +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/llms.txt +12 -8
  48. package/package.json +1 -1
@@ -646,7 +646,17 @@ export function isSameOriginRequest(request) {
646
646
  export function mayWriteSource(opts) {
647
647
  if (opts.viaTunnel)
648
648
  return opts.hasValidSession && opts.sessionMayWrite !== false;
649
- return isLoopbackAddressForAuth(opts.peer);
649
+ if (isLoopbackAddressForAuth(opts.peer))
650
+ return true;
651
+ /*
652
+ LAN trust leans on the same-origin check the caller applies (`isSameOriginRequest`), and that
653
+ check rules out DNS rebinding from a LAN browser only because the dev server is HTTPS-only,
654
+ with a certificate for localhost and <host>.local: a rebound hostname fails TLS. A plain-HTTP
655
+ listener would turn this into a rebinding path. Do not add one without revisiting this.
656
+ */
657
+ return (opts.trustLan === true &&
658
+ opts.proxied !== true &&
659
+ isPrivateNetworkAddress(opts.peer));
650
660
  }
651
661
  /**
652
662
  * May the holder of this session token write?
@@ -682,7 +692,10 @@ export function cookieMayWrite(state, cookieValue) {
682
692
  * diverge, this is the one place to change, and every caller already says which it means.
683
693
  */
684
694
  export function mayDriveWithAgent(opts) {
685
- return mayWriteSource(opts);
695
+ // LAN trust (#227) is for reading and editing source, and is never passed here: driving a
696
+ // page with an agent stays this machine and authorized tunnel sessions only.
697
+ const { viaTunnel, peer, hasValidSession } = opts;
698
+ return mayWriteSource({ viaTunnel, peer, hasValidSession });
686
699
  }
687
700
  /**
688
701
  * THE loopback test. dev-server re-exports this as `isLoopbackAddress`; it used to
@@ -703,6 +716,31 @@ export function isLoopbackAddressForAuth(address) {
703
716
  const mapped = a.startsWith('::ffff:') ? a.slice(7) : a;
704
717
  return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(mapped);
705
718
  }
719
+ /**
720
+ * A private-network peer: RFC 1918 IPv4 (10/8, 172.16/12, 192.168/16), IPv6 unique-local
721
+ * (fc00::/7) or link-local (fe80::/10), including IPv4 mapped into IPv6. Loopback is not
722
+ * included; ask `isLoopbackAddressForAuth` for that. Used ONLY behind the `trustLan` opt-in.
723
+ */
724
+ export function isPrivateNetworkAddress(address) {
725
+ if (!address)
726
+ return false;
727
+ const a = address
728
+ .trim()
729
+ .toLowerCase()
730
+ .replace(/^\[|\]$/g, '')
731
+ .replace(/%.*$/, ''); // an IPv6 zone id (fe80::1%en0)
732
+ const v4 = (a.startsWith('::ffff:') ? a.slice(7) : a).match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
733
+ if (v4) {
734
+ const [first, second] = [Number(v4[1]), Number(v4[2])];
735
+ return (first === 10 ||
736
+ (first === 172 && second >= 16 && second <= 31) ||
737
+ (first === 192 && second === 168));
738
+ }
739
+ if (!a.includes(':'))
740
+ return false;
741
+ const head = parseInt(a.split(':')[0] || '0', 16);
742
+ return (head & 0xfe00) === 0xfc00 || (head & 0xffc0) === 0xfe80;
743
+ }
706
744
  /*
707
745
  Was this request proxied (i.e. did it arrive through something in front of us)?
708
746
 
@@ -1899,6 +1899,8 @@ export async function devServer(config, opts = {}) {
1899
1899
  // A share link's session may browse and may not save (asked AFTER validity, which
1900
1900
  // is the call-order `cookieMayWrite` documents).
1901
1901
  sessionMayWrite: cookieMayWrite(auth, session),
1902
+ trustLan: config.trustLan === true,
1903
+ proxied: isProxiedRequest(request.headers),
1902
1904
  }) &&
1903
1905
  /*
1904
1906
  CSRF (#90). The direct path is peer-address-only, so without this any page the
@@ -1908,7 +1910,9 @@ export async function devServer(config, opts = {}) {
1908
1910
  */
1909
1911
  csrfOk(request, viaTunnel);
1910
1912
  if (!authorized) {
1911
- console.warn(`⚠️ refused ${request.method} /__docstore/source from ${peer ?? 'unknown'} — ` + `no session (use \`${TUNNEL_LINK_CMD}\` for an edit link).`);
1913
+ console.warn(`⚠️ refused ${request.method} /__docstore/source from ${peer ?? 'unknown'} — ` +
1914
+ `no session (use \`${TUNNEL_LINK_CMD}\` for an edit link` +
1915
+ `${config.trustLan ? '' : ', or set trustLan: true for LAN devices'}).`);
1912
1916
  return new Response('not authorized to edit source', {
1913
1917
  status: 403,
1914
1918
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
@@ -2225,6 +2229,14 @@ export async function devServer(config, opts = {}) {
2225
2229
  process.exit(code);
2226
2230
  };
2227
2231
  console.log(`Listening on https://localhost:${PORT}`);
2232
+ // trustLan widens who may write; say so every launch, never silently (#227).
2233
+ if (config.trustLan && config.editableSources) {
2234
+ console.warn(`⚠️ trustLan: any device on a private network that can reach this machine may read and ` +
2235
+ `edit source. Use it on a network you control.`);
2236
+ }
2237
+ else if (config.trustLan) {
2238
+ console.warn(`⚠️ trustLan does nothing without editableSources: true (there is no source endpoint to trust).`);
2239
+ }
2228
2240
  if (SINK_PATH) {
2229
2241
  console.log(`Debug sink: POST /__debug-sink -> tail -f ${SINK_PATH}`);
2230
2242
  }
@@ -22,6 +22,18 @@ export interface Doc {
22
22
  * pre-rendered public page — so "drafts don't ship" was false twice over.
23
23
  */
24
24
  hidden?: boolean;
25
+ /**
26
+ * This doc is the site root (`/`) instead of README, which then gets `/readme/`. Set by the
27
+ * site's `home` option, or by the doc's own `{"home": true}`. At most one doc may be home.
28
+ */
29
+ home?: boolean;
30
+ /**
31
+ * `false` drops the site's header bar on this page, as `layout: "full-screen"` drops the
32
+ * nav: for an app page with its own toolbar, above all as an installed PWA, where the bar
33
+ * is the only thing between the app and the whole screen. The page links back to the docs
34
+ * its own way; the builder adds nothing. Unset means the header shows.
35
+ */
36
+ header?: boolean;
25
37
  /**
26
38
  * Which book(s) this doc binds into. A name, a list of names, or `'none'` to keep it
27
39
  * on the site and out of every book. Unset means the default book; `'default'` names
@@ -91,6 +103,8 @@ export interface ExtractDocsOptions {
91
103
  paths: string[];
92
104
  ignore?: string[];
93
105
  output?: string;
106
+ /** the doc served at `/` instead of README, by slug or filename (the site's `home`) */
107
+ home?: string;
94
108
  }
95
109
  /**
96
110
  * The title a markdown file implies, when nothing declares one.
@@ -1,97 +1,43 @@
1
1
  /*#
2
2
  # docs
3
3
 
4
- Utility for extracting documentation from markdown files and inline comments in source code.
5
-
6
- > `docs.ts` is intended to be run directly using `bun`. You can transpile it to javascript if you
7
- want to run it using node.
8
-
9
- This is used by the `doc-browser` component to build searchable, navigable documentation
10
- from your project's source files.
4
+ `extractDocs` collects documentation from markdown files and `/*#` comment blocks in source
5
+ code into one corpus, the `docs.json` that the [doc-browser](/doc-browser/) and the static
6
+ site render. `tosijs-ui/site` runs it for you; call it yourself only to build a corpus
7
+ outside that pipeline. It reads files, so it runs under bun or node.
11
8
 
12
9
  ## Usage
13
10
 
14
- import { extractDocs } from 'docs'
15
-
16
- extractDocs({
17
- paths: ['src', 'README.md'],
18
- ignore: ['node_modules', 'dist', 'build']
19
- path: 'public/docs.json'
20
- })
21
-
22
- ## API
23
-
24
- ### `extractDocs(options)`
25
-
26
- Scans directories for markdown files and source code comments.
27
-
28
- **Options:**
29
- - `paths`: Array of directory paths or file paths to scan
30
- - `ignore`: Array of directory names to ignore (default: ['node_modules', 'dist'])
31
- - `output`: if provided, path to write json result.
32
-
33
- **Returns:** Array of `Doc` objects
34
-
35
- ### `Doc` object structure
36
-
37
- {
38
- text: string, // Markdown content
39
- title: string, // First heading or filename
40
- filename: string, // Just the filename
41
- path: string, // Full file path
42
- pin?: 'top' | 'bottom' // Optional pinning for sort order
43
- }
44
-
45
- ## Documentation Format
46
-
47
- ### Markdown files
48
-
49
- Any `.md` file will be included in its entirety.
50
-
51
- ### Source code comments
11
+ ```typescript
12
+ import { extractDocs } from 'tosijs-ui/site'
52
13
 
53
- Multi-line comments that start with `/*#` will be extracted as markdown:
14
+ const docs = extractDocs({
15
+ paths: ['src', 'README.md'],
16
+ output: 'public/docs.json',
17
+ })
18
+ ```
54
19
 
55
- /*#
56
- # My Component
20
+ ## `extractDocs(options)`
57
21
 
58
- This is documentation for my component.
22
+ - `paths`: directories to scan, and individual files (list root `.md` files explicitly)
23
+ - `ignore`: directory names to skip (default `['node_modules', 'dist', 'build']`)
24
+ - `output`: if given, the path the corpus is written to as JSON
25
+ - `home`: the doc served at `/` instead of README, by slug or filename
59
26
 
60
- ```html
61
- <my-component></my-component>
62
- ```
63
- ```js
64
- console.log('hello world')
65
- ```
66
- ```css
67
- my-componet {
68
- color: blue
69
- }
70
- ```
71
- *‎/
72
-
73
- export class MyComponent extends Component {
74
- // implementation
75
- }
76
- ...
77
-
78
- The [doc-browser](/?doc-browser.ts) will render the output as a test-bed project with documentation and live examples.
27
+ Returns an array of `Doc` objects: `text` (the markdown), `title`, `filename`, `path`, and
28
+ whatever metadata the doc declares (`pin`, `order`, `parent`, `layout`, `description`, …).
79
29
 
80
- ### Metadata
30
+ ## What is collected
81
31
 
82
- You can include JSON metadata in comments to control sorting:
32
+ - **`.md` files**, whole. YAML frontmatter is read and removed.
33
+ - **`/*#` … `*‎/` blocks** in `.ts`, `.tjs`, `.js` and `.css` files, as markdown. The first heading
34
+ is the page title. A block counts only when `/*#` starts a line.
35
+ - **Files whose name starts with `_`** are skipped: use the prefix for drafts and fragments.
83
36
 
84
- html:
85
- <!--{ "pin": "bottom" }-->
86
-
87
- ts, js, css:
88
- /*{ "pin": "bottom" }*‎/
89
-
90
- This pins the document to the top or bottom of the navigation list. Within a
91
- pin bucket, add `"order"` (a number, lower first; default 500) to rank items —
92
- e.g. `{ "pin": "top", "order": 1 }` above `{ "pin": "top", "order": 2 }`.
37
+ Metadata blocks (`<!--{ … }-->` in markdown, `/*{ … }*‎/` in code), nav order, sections and
38
+ which code fences run live are described under [Doc format](/doc-site-system/#doc-format).
93
39
  */
94
- /*{"pin":"bottom","parent":"Appendices"}*/
40
+ /*{ "parent": "Documentation System", "order": 60 }*/
95
41
  // TODO CLI options
96
42
  import * as fs from 'fs';
97
43
  import * as path from 'path';
@@ -208,6 +154,14 @@ export function parseFrontmatter(content) {
208
154
  else if (val)
209
155
  console.error(`unknown layout ${JSON.stringify(val)} in frontmatter`);
210
156
  }
157
+ else if (key === 'header') {
158
+ if (/^(false|no|0)$/i.test(val))
159
+ data.header = false;
160
+ }
161
+ else if (key === 'home') {
162
+ if (/^(true|yes|1)$/i.test(val))
163
+ data.home = true;
164
+ }
211
165
  }
212
166
  if (!matched)
213
167
  return { data: {}, body: content }; // a bare `---`, not frontmatter
@@ -513,7 +467,7 @@ walked) {
513
467
  return markdownFiles.sort(pinnedSort);
514
468
  }
515
469
  export function extractDocs(options) {
516
- const { paths, ignore = ['node_modules', 'dist', 'build'], output } = options;
470
+ const { paths, ignore = ['node_modules', 'dist', 'build'], output, home, } = options;
517
471
  const walked = new Set();
518
472
  const found = findMarkdownFiles(paths, ignore, new Set(DEFAULT_DOC_IGNORES), walked);
519
473
  /*
@@ -529,7 +483,18 @@ export function extractDocs(options) {
529
483
  Filtering at extraction makes it structural: hidden docs do not exist as far as the
530
484
  rest of the pipeline is concerned. Descendants of a hidden doc go with it.
531
485
  */
486
+ if (home != null)
487
+ markHome(found, home);
488
+ const homes = found.filter((doc) => doc.home);
489
+ if (homes.length > 1) {
490
+ throw new Error(`more than one doc is the home page: ${homes
491
+ .map((doc) => doc.path)
492
+ .join(', ')}. Mark one, with the site's \`home\` option or {"home": true}.`);
493
+ }
532
494
  const docs = withoutHidden(found, buildSlugMap(found));
495
+ if (homes.length === 1 && !docs.includes(homes[0])) {
496
+ throw new Error(`the home page ${homes[0].path} is hidden, so the site would have no root page.`);
497
+ }
533
498
  /*
534
499
  Insets and conditional text are resolved HERE, for the same reason hidden docs are dropped
535
500
  here: the corpus is the thing that ships, so anything assembled later is assembled by each
@@ -550,6 +515,21 @@ export function extractDocs(options) {
550
515
  }
551
516
  return docs;
552
517
  }
518
+ /*
519
+ The site's `home` option names the root doc by slug or filename. Matched against the slugs
520
+ the corpus would have with README as root, which are the names an author sees in the nav
521
+ today. Naming nothing fails the build: a site whose root is not the page its config asks for
522
+ is the silent-failure class this system refuses.
523
+ */
524
+ function markHome(docs, home) {
525
+ const slugs = buildSlugMap(docs.map(({ filename }) => ({ filename })));
526
+ const wanted = home.replace(/^\/+|\/+$/g, '');
527
+ const match = docs.filter((doc) => doc.filename === wanted || slugs[doc.filename] === wanted);
528
+ if (match.length !== 1) {
529
+ throw new Error(`site option home: "${home}" names ${match.length === 0 ? 'no doc' : `${match.length} docs`}. Use a page's slug (as in its URL) or its filename.`);
530
+ }
531
+ match[0].home = true;
532
+ }
553
533
  export function saveDocsJSON(docs, outputPath) {
554
534
  const jsonData = JSON.stringify(docs, null, 2);
555
535
  fs.writeFileSync(outputPath, jsonData, 'utf8');
@@ -620,7 +620,8 @@ export async function buildEpub(config, opts = {}) {
620
620
  docs and anything marked `book: "none"`, so a doc that belongs to no volume cannot leak
621
621
  into the default one just by not naming a book.
622
622
  */
623
- const volumes = partitionByBook(corpus, buildSlugMap(corpus));
623
+ const siteSlugs = buildSlugMap(corpus);
624
+ const volumes = partitionByBook(corpus, siteSlugs);
624
625
  const visible = volumes.get(opts.bookTarget ?? DEFAULT_BOOK) ?? [];
625
626
  /*
626
627
  A volume with no chapters is a failure, not a book.
@@ -659,7 +660,10 @@ export async function buildEpub(config, opts = {}) {
659
660
  }
660
661
  // Curate/reorder for the book (a no-op when config.book is absent).
661
662
  const docs = selectBookDocs(placed, config.book);
662
- const slugMap = buildSlugMap(docs);
663
+ // Slugs come from the whole published corpus, as the site's do: computed from this book's
664
+ // subset, README would take the root whenever the site's `home` doc is outside the book,
665
+ // and the book's links would disagree with the site's URLs.
666
+ const slugMap = siteSlugs;
663
667
  const roots = buildNavTree(docs, slugMap);
664
668
  const fileFor = (d) => `${slugMap[d.filename] || 'index'}.xhtml`;
665
669
  // slug → chapter filename, for the docs actually IN this book — the target set for
@@ -15,7 +15,7 @@ import { highlightHtml } from '../highlight.js';
15
15
  import { buildSlugMap, pathForSlug, rewriteDocLinks, withBase, } from '../routing.js';
16
16
  import { buildNavTree, navOpenPath } from '../nav-tree.js';
17
17
  import { renderDocMarkdown, docDescription, } from '../render.js';
18
- import { deadInPageAnchors, misconfigured } from './build-warnings.js';
18
+ import { bareNameLinks, deadInPageAnchors, misconfigured, } from './build-warnings.js';
19
19
  const escapeAttr = (s) => s
20
20
  .replace(/&/g, '&amp;')
21
21
  .replace(/</g, '&lt;')
@@ -127,9 +127,8 @@ async function pageHtml(doc, config, slugMap, configAttr) {
127
127
  flicker the nav-hydration fix chased in 1.7.3. An attribute in the served markup means the
128
128
  first paint is already correct, and the doc-browser keeps it in step on SPA navigation.
129
129
  */
130
- const layoutAttr = doc.layout
131
- ? ` data-layout="${escapeAttr(doc.layout)}"`
132
- : '';
130
+ const layoutAttr = (doc.layout ? ` data-layout="${escapeAttr(doc.layout)}"` : '') +
131
+ (doc.header === false ? ' data-no-header' : '');
133
132
  // ONE rule, shared with the doc-browser's hydration path — see doc-title.ts. Writing
134
133
  // it twice is what made the title change on hydration (issue #6).
135
134
  const title = pageTitle(doc, projectName);
@@ -311,10 +310,16 @@ export async function generateSite(config) {
311
310
  // ./generate-css.ts; pages here just <link> to it.
312
311
  let count = 0;
313
312
  const deadAnchors = [];
313
+ const bareLinks = [];
314
314
  for (const doc of docs) {
315
315
  const slug = slugMap[doc.filename];
316
316
  const dir = slug === '' ? outputDir : `${outputDir}/${slug}`;
317
- const dead = deadInPageAnchors(renderDocMarkdown(doc.text));
317
+ const rendered = renderDocMarkdown(doc.text);
318
+ const dead = deadInPageAnchors(rendered);
319
+ // On the root page (`/`) a bare name resolves to `/name`, which works; elsewhere it does not.
320
+ const bare = slug === '' ? [] : bareNameLinks(rendered);
321
+ if (bare.length)
322
+ bareLinks.push(` ${doc.path || doc.filename}: ${bare.join(', ')}`);
318
323
  if (dead.length)
319
324
  deadAnchors.push(` ${doc.path || doc.filename}: ${dead
320
325
  .map((d) => `#${d}`)
@@ -326,6 +331,11 @@ export async function generateSite(config) {
326
331
  }
327
332
  count += 1;
328
333
  }
334
+ if (bareLinks.length) {
335
+ misconfigured(config.strict, `\n⚠️ Links written as a bare page name. Every page is served at /slug/, so these\n` +
336
+ ` resolve under the page they are on and reach nothing; write /name/ instead:\n` +
337
+ `${bareLinks.join('\n')}\n`);
338
+ }
329
339
  if (deadAnchors.length) {
330
340
  misconfigured(config.strict, `\n⚠️ In-page links that point at nothing on their page. A heading's id is its text,\n` +
331
341
  ` lowercased, punctuation dropped, spaces as "-" (a repeat gets -1, -2, …):\n` +
@@ -640,6 +640,7 @@ export async function buildSite(config, opts = {}) {
640
640
  ...(config.ignoreDocPaths ?? []),
641
641
  ],
642
642
  output: DOCS_JSON,
643
+ home: config.home,
643
644
  });
644
645
  extract();
645
646
  /*
@@ -53,6 +53,13 @@ export interface SiteConfig {
53
53
  * files explicitly, e.g. ['src', 'README.md', 'Building-Apps.md'].
54
54
  */
55
55
  docPaths?: string[];
56
+ /**
57
+ * The page served at the site root (`/`), by slug or filename, e.g. `home: 'host'` for a
58
+ * site that is an app. Default: README. README then has its own page at `/readme/`, and
59
+ * the nav, canonical links, sitemap, llms.txt and books follow. The build fails if it
60
+ * names no page. A doc can also claim the root itself with `{"home": true}`.
61
+ */
62
+ home?: string;
56
63
  /**
57
64
  * Directories to skip while scanning `docPaths` — by basename (skipped wherever it
58
65
  * appears) or by path (skipped only at that location). Added to the built-in
@@ -603,6 +610,24 @@ export interface SiteConfig {
603
610
  * are confined to the repo root as correctness hygiene. Off by default; opt in.
604
611
  */
605
612
  editableSources?: boolean;
613
+ /**
614
+ * Trust the local network for reading and editing source (#227). With `editableSources` on,
615
+ * a browser on another device on your LAN (a phone, a headset) can open and save source
616
+ * directly, as one at this keyboard can, instead of needing the tunnel and an invite link.
617
+ * That is "Edit page source"; a live example's "Save to source" is still offered only on
618
+ * `localhost`.
619
+ *
620
+ * Trusted: a direct peer at a private address (RFC 1918 IPv4, IPv6 unique-local or
621
+ * link-local). Never trusted: tunnel traffic (it needs a session as before) or a request
622
+ * carrying forwarding headers. Writes still have to come from the dev server's own origin,
623
+ * so a page another site serves cannot write through a LAN browser.
624
+ *
625
+ * What it means: ANYONE on the network can edit your working tree. Turn it on for a network
626
+ * you control, never on shared wifi. And a reverse proxy on your LAN that forwards outside
627
+ * traffic WITHOUT forwarding headers would make that traffic look like a LAN peer. Agent
628
+ * driving, invite links, builds and test reports stay this machine only. Off by default.
629
+ */
630
+ trustLan?: boolean;
606
631
  /**
607
632
  * Give a coding agent (Claude) eyes + hands on your running dev page via
608
633
  * [haltija](https://github.com/tonioloewald/haltija). When on, the dev server
@@ -53,13 +53,11 @@ preview.addEventListener('change', event => console.log(event))
53
53
  background: transparent;
54
54
  box-shadow: none;
55
55
  }
56
-
57
- .previw button
58
56
  ```
59
57
 
60
58
  ## Snapping
61
59
 
62
- When `EditableRect.snapToGrid === true` or the shift-key is depresseed, position will snap to `EditableRect.gridSize` pixels (default = 8).
60
+ When `EditableRect.snapToGrid === true` or the shift-key is depressed, position will snap to `EditableRect.gridSize` pixels (default = 8).
63
61
 
64
62
  Similarly `EditableRect.snapAngle === true` or the shift-key will snap rotation to increments of `EditableRect.angleSize` degrees (default = 15).
65
63
 
@@ -1,3 +1,4 @@
1
+ /*{ "parent": "Documentation System", "order": 70 }*/
1
2
  /*#
2
3
  # highlight-block
3
4
 
@@ -30,16 +31,8 @@ and an unknown language renders as plain, readable code rather than failing.
30
31
  **Colours work out of the box, anywhere.** The element injects its token palette on first use,
31
32
  so a `<tosi-highlight>` on an ordinary page is coloured without importing anything.
32
33
 
33
- That was not always true, and the way it failed is worth knowing if you are debugging
34
- something similar: this package ships **no CSS files at all** — every style is a `StyleSheet()`
35
- call — and the palette used to live inside the doc-system stylesheet. So a standalone element
36
- produced perfectly correct `<span class="token …">` markup rendering in flat black, and this
37
- very paragraph told you to "import the doc-system CSS", which was not an import that existed.
38
-
39
- There are **two palettes** — a light set (the default) and a dark set under `.darkmode` — each
40
- contrast-checked against the `--code-bg` it actually sits on. Not one recomputed from the
41
- other: a light-mode default derived from dark-mode literals is unreadable, which is how this
42
- shipped failing WCAG AA on every token type.
34
+ There are **two palettes**, a light set (the default) and a dark set under `.darkmode`, each
35
+ contrast-checked against the `--code-bg` it sits on (see [highlight-styles](/highlight-styles/)).
43
36
 
44
37
  Every colour is a `varDefault`, so retheme one token type without touching the rest:
45
38
 
@@ -3,6 +3,16 @@ export interface IconRule {
3
3
  prefix: string | RegExp;
4
4
  apply: (baseName: string, match: RegExpMatchArray | string, parts: ElementPart[]) => Element | string | null;
5
5
  }
6
+ /**
7
+ * Style suffixes on a registered icon (`checkCircle90r`), split, or null. Such a name is
8
+ * that icon styled, and it wins over a composition rule (#3295): `checkCircle90r` is
9
+ * `checkCircle` turned, not the `check` rule over `circle90r`. Both the DOM renderer and
10
+ * `iconSvg` resolve in that order by asking this.
11
+ */
12
+ export declare function suffixedIcon(name: string): {
13
+ baseName: string;
14
+ tokens: string[];
15
+ } | null;
6
16
  /** A registered icon, or style suffixes on one (`chevronRight50o`). */
7
17
  export declare function canResolve(name: string): boolean;
8
18
  /**
@@ -11,7 +21,13 @@ export declare function canResolve(name: string): boolean;
11
21
  */
12
22
  export declare const spinRule: IconRule;
13
23
  export declare const iconRules: IconRule[];
14
- /** The icon a rule would apply to in `name`, or null if the rule does not match it. */
24
+ /**
25
+ * The icon a rule would apply to in `name`, or null if the rule does not match it.
26
+ *
27
+ * A `:` makes the boundary explicit: `check:box` is the `check` rule applied to `box`, with
28
+ * no camelCase guess, so it never reads as an icon called `checkBox`. Everything before the
29
+ * first `:` must be the whole prefix (`spin90:loader`, `un:lock`).
30
+ */
15
31
  export declare function matchRule(rule: IconRule, name: string): {
16
32
  baseName: string;
17
33
  match: RegExpMatchArray | string;
@@ -10,13 +10,21 @@ before the name is asked for.
10
10
  */
11
11
  import iconData from './icon-data.js';
12
12
  import { splitSuffixes } from './icon-suffixes.js';
13
+ /**
14
+ * Style suffixes on a registered icon (`checkCircle90r`), split, or null. Such a name is
15
+ * that icon styled, and it wins over a composition rule (#3295): `checkCircle90r` is
16
+ * `checkCircle` turned, not the `check` rule over `circle90r`. Both the DOM renderer and
17
+ * `iconSvg` resolve in that order by asking this.
18
+ */
19
+ export function suffixedIcon(name) {
20
+ const split = splitSuffixes(name);
21
+ return split != null && iconData[split.baseName]
22
+ ? split
23
+ : null;
24
+ }
13
25
  /** A registered icon, or style suffixes on one (`chevronRight50o`). */
14
26
  export function canResolve(name) {
15
- const data = iconData;
16
- if (data[name])
17
- return true;
18
- const split = splitSuffixes(name);
19
- return split != null && !!data[split.baseName];
27
+ return (!!iconData[name] || suffixedIcon(name) != null);
20
28
  }
21
29
  /**
22
30
  * `spinN<Icon>` animates, so it is drawn by the DOM renderer, which installs `apply` when
@@ -47,8 +55,27 @@ export const iconRules = [
47
55
  apply: (baseName) => `search80s30x30y$${baseName}50o`,
48
56
  },
49
57
  ];
50
- /** The icon a rule would apply to in `name`, or null if the rule does not match it. */
58
+ /**
59
+ * The icon a rule would apply to in `name`, or null if the rule does not match it.
60
+ *
61
+ * A `:` makes the boundary explicit: `check:box` is the `check` rule applied to `box`, with
62
+ * no camelCase guess, so it never reads as an icon called `checkBox`. Everything before the
63
+ * first `:` must be the whole prefix (`spin90:loader`, `un:lock`).
64
+ */
51
65
  export function matchRule(rule, name) {
66
+ const colon = name.indexOf(':');
67
+ if (colon !== -1) {
68
+ const head = name.slice(0, colon);
69
+ const tail = name.slice(colon + 1);
70
+ if (!tail)
71
+ return null;
72
+ const baseName = tail[0].toLowerCase() + tail.slice(1);
73
+ if (rule.prefix instanceof RegExp) {
74
+ const m = head.match(new RegExp(rule.prefix.source + '$'));
75
+ return m && m.index === 0 ? { baseName, match: m } : null;
76
+ }
77
+ return head === rule.prefix ? { baseName, match: rule.prefix } : null;
78
+ }
52
79
  if (rule.prefix instanceof RegExp) {
53
80
  const m = name.match(new RegExp(rule.prefix.source + '(.+)$'));
54
81
  if (!m)
package/dist/icon-svg.js CHANGED
@@ -1,4 +1,4 @@
1
- /*{"parent":"Appendices","order":600}*/
1
+ /*{ "parent": "icons", "order": 20 }*/
2
2
  /*#
3
3
  # `iconSvg` — icon markup without a DOM
4
4
 
@@ -27,21 +27,17 @@ other style suffix (`check50o`), a redirect to a name that is not an icon, or a
27
27
  cycle. Render those with `icons.<name>()` where there is a DOM; the suffixes and rules are
28
28
  described on the [[icons]] page. Only a name that nothing would draw returns `undefined`.
29
29
 
30
- Deliberately a separate module from `icons.ts`: that one imports tosijs to build
31
- elements, so importing it drags in code that needs `HTMLElement` and throws in a build
32
- script — the exact context that wanted the markup in the first place. This module
33
- imports nothing but the data.
34
-
35
- Background: `defineIcons()` could write to the icon map but nothing could read it, and
36
- `icons.foo()` returns a DOM `SVGElement`. So a static-page generator, an ePub pass or a
37
- server-rendered template had no way to get markup except by parsing
38
- `src/icons/data/*.ts` out of the installed package — which is exactly what this project
39
- resorted to when putting its own logo on a generated page. There is nothing to protect:
40
- the map ships in the bundle either way.
30
+ It needs no DOM, so it works in a build script, a static-page generator, an ePub pass or a
31
+ server-rendered template, where `icons.<name>()` (which builds DOM elements) cannot run.
32
+ */
33
+ /*
34
+ Deliberately a separate module from `icons.ts`: that one imports tosijs to build elements, so
35
+ importing it drags in code that needs `HTMLElement` and throws in a build script, the exact
36
+ context that wanted the markup. This module imports nothing but the data and the grammars.
41
37
  */
42
38
  import iconData from './icon-data.js';
43
- import { splitSuffixes, suffixNumber } from './icon-suffixes.js';
44
- import { canResolve, ruleDraws } from './icon-rules.js';
39
+ import { suffixNumber } from './icon-suffixes.js';
40
+ import { canResolve, ruleDraws, suffixedIcon } from './icon-rules.js';
45
41
  const data = iconData;
46
42
  // Rotation and flip, the only suffixes the built-in redirects use (`chevronDown` is
47
43
  // `chevronRight90r`). Anything else needs the DOM renderer.
@@ -64,6 +60,9 @@ export function iconSvg(name) {
64
60
  return undefined;
65
61
  return resolve(name, key, new Set());
66
62
  }
63
+ // How to call the creator for a name: `icons.lock()`, or `icons['check:box']()` for a name
64
+ // that is not an identifier.
65
+ const creatorCall = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? `icons.${name}()` : `icons['${name}']()`;
67
66
  function resolve(asked, name, seen) {
68
67
  const key = stripEscape(name);
69
68
  const value = data[key];
@@ -77,17 +76,15 @@ function resolve(asked, name, seen) {
77
76
  return resolve(asked, value, seen);
78
77
  }
79
78
  // The renderer's order for a name that is not registered: a stack, then suffixes on a
80
- // registered icon (they win over a rule, #3295), then a rule.
81
- const split = key.includes('$') ? null : splitSuffixes(key);
82
- if (split &&
83
- data[split.baseName] !== undefined &&
84
- split.tokens.every(isGeometry)) {
79
+ // registered icon (suffixedIcon, #3295), then a rule.
80
+ const split = key.includes('$') ? null : suffixedIcon(key);
81
+ if (split && split.tokens.every(isGeometry)) {
85
82
  return transformed(asked, resolve(asked, split.baseName, seen), split.tokens);
86
83
  }
87
84
  const what = key === stripEscape(asked) ? `"${key}"` : `it resolves to "${key}", which`;
88
85
  if (isComposition(key)) {
89
86
  throw new Error(`iconSvg("${asked}"): ${what} is not a single SVG (a stack, a rule, or a style ` +
90
- `suffix other than rotation or flip). Render it with icons.${stripEscape(asked)}() ` +
87
+ `suffix other than rotation or flip). Render it with ${creatorCall(stripEscape(asked))} ` +
91
88
  `where there is a DOM.`);
92
89
  }
93
90
  throw new Error(`iconSvg("${asked}"): it redirects to "${key}", which is not a registered icon`);