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.
- package/CHANGELOG.md +85 -0
- package/README.md +2 -2
- package/bin/make-icon-data.js +1 -1
- package/dist/caption-icon.d.ts +2 -0
- package/dist/caption-icon.js +12 -0
- package/dist/code-editor.js +2 -3
- package/dist/data-table.js +1 -1
- package/dist/doc-browser.d.ts +4 -0
- package/dist/doc-browser.js +46 -101
- package/dist/doc-system/doc-system-styles.js +11 -0
- package/dist/doc-system/doc-system.js +1 -1
- package/dist/doc-system/highlight-styles.js +11 -16
- package/dist/doc-system/routing.d.ts +9 -0
- package/dist/doc-system/routing.js +22 -6
- package/dist/doc-system/site/build-warnings.d.ts +10 -0
- package/dist/doc-system/site/build-warnings.js +24 -0
- package/dist/doc-system/site/dev-auth.d.ts +13 -0
- package/dist/doc-system/site/dev-auth.js +40 -2
- package/dist/doc-system/site/dev-server.js +13 -1
- package/dist/doc-system/site/docs.d.ts +14 -0
- package/dist/doc-system/site/docs.js +61 -81
- package/dist/doc-system/site/epub.js +6 -2
- package/dist/doc-system/site/generate-site.js +15 -5
- package/dist/doc-system/site/orchestrator.js +1 -0
- package/dist/doc-system/site/site-config.d.ts +25 -0
- package/dist/editable-rect.js +1 -3
- package/dist/highlight-block.js +3 -10
- package/dist/icon-rules.d.ts +17 -1
- package/dist/icon-rules.js +33 -6
- package/dist/icon-svg.js +17 -20
- package/dist/icons.js +31 -15
- package/dist/iife.js +91 -95
- package/dist/iife.js.map +27 -26
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/live-example/component.js +3 -4
- package/dist/menu.js +2 -2
- package/dist/natural-compare.js +6 -2
- package/dist/password-strength.js +1 -1
- package/dist/pocket-bar.js +2 -2
- package/dist/segmented.js +3 -4
- package/dist/select.js +2 -1
- package/dist/size-break.js +4 -3
- package/dist/track-drag.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +12 -8
- 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
|
-
|
|
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
|
-
|
|
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'} — ` +
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
30
|
+
## What is collected
|
|
81
31
|
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
/*{"
|
|
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
|
|
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
|
-
|
|
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, '&')
|
|
21
21
|
.replace(/</g, '<')
|
|
@@ -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
|
-
?
|
|
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
|
|
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` +
|
|
@@ -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
|
package/dist/editable-rect.js
CHANGED
|
@@ -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
|
|
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
|
|
package/dist/highlight-block.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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
|
|
package/dist/icon-rules.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/icon-rules.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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":"
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 {
|
|
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 (
|
|
81
|
-
const split = key.includes('$') ? null :
|
|
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
|
|
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`);
|