tosijs-ui 1.16.28 → 1.16.29
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 +24 -0
- package/dist/doc-browser.js +34 -16
- package/dist/doc-system/book-html.js +5 -2
- package/dist/doc-system/render.d.ts +2 -0
- package/dist/doc-system/render.js +13 -8
- package/dist/doc-system/routing.d.ts +12 -0
- package/dist/doc-system/routing.js +30 -0
- package/dist/doc-system/site/build-warnings.d.ts +8 -0
- package/dist/doc-system/site/build-warnings.js +27 -0
- package/dist/doc-system/site/dev-server.js +1 -1
- package/dist/doc-system/site/epub.js +3 -2
- package/dist/doc-system/site/generate-site.js +22 -6
- package/dist/doc-system/site/invite-page.d.ts +7 -1
- package/dist/doc-system/site/invite-page.js +15 -2
- package/dist/iife.js +105 -105
- package/dist/iife.js.map +7 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.29
|
|
4
|
+
|
|
5
|
+
### Doc system
|
|
6
|
+
|
|
7
|
+
- **A wikilink can name a page by its title.** `[[The feature registry]]` now reaches the page
|
|
8
|
+
at `/registry/`. Before, a target was only ever slugified, so it went to
|
|
9
|
+
`/the-feature-registry/` and 404'd. The slug (file name) still wins when a page has one, so
|
|
10
|
+
no link that worked changes target. A title two pages share reaches neither.
|
|
11
|
+
Reported by tosijs-3d-ensemble (#234).
|
|
12
|
+
- **The build reports a wikilink that reaches no doc page**, naming the file and the target,
|
|
13
|
+
and fails under `strict`. One site had nine of these, live 404s from as far back as its 0.3,
|
|
14
|
+
and no build had said a word. **If you build with `strict: true`:** a wikilink to something
|
|
15
|
+
that is not a doc page, such as a hand-written page in your static folder, now fails the
|
|
16
|
+
build even though the link works. Write it as an ordinary `[label](/path/)`.
|
|
17
|
+
- **The test-status widget sits in the header bar.** It was fixed at the bottom left over the
|
|
18
|
+
page, so a full-page component's last list row lost clicks to it (#233). A page with
|
|
19
|
+
`{"header": false}` still floats it there, having nowhere else to put it.
|
|
20
|
+
|
|
21
|
+
### Tunnel
|
|
22
|
+
|
|
23
|
+
- **The "needs an invite link" page offers the lasting link too.** When the dev server takes
|
|
24
|
+
the preview box's global keys, it now names `tosijs-tunnel --link --global` beside the
|
|
25
|
+
per-visit `--link`. That link keeps the browser in for 30 days across restarts (#232).
|
|
26
|
+
|
|
3
27
|
## 1.16.28
|
|
4
28
|
|
|
5
29
|
### Tunnel
|
package/dist/doc-browser.js
CHANGED
|
@@ -95,7 +95,7 @@ history, for a browser embedded in another page: drive it with `initialRoute`,
|
|
|
95
95
|
*/
|
|
96
96
|
/*{ "parent": "Documentation System", "order": 50 }*/
|
|
97
97
|
import { elements, vars, varDefault, bindings, touch, getListItem, debounce, tosi, StyleSheet, } from 'tosijs';
|
|
98
|
-
import { buildSlugMap, pathForSlug, filenameForPath, homeDoc, } from './doc-system/routing.js';
|
|
98
|
+
import { buildSlugMap, pathForSlug, filenameForPath, homeDoc, wikilinkTargets, } from './doc-system/routing.js';
|
|
99
99
|
import { buildNavTree } from './doc-system/nav-tree.js';
|
|
100
100
|
import { renderDocMarkdown } from './doc-system/render.js';
|
|
101
101
|
import { pageTitle } from './doc-system/doc-title.js';
|
|
@@ -202,21 +202,28 @@ const testIndicatorStyleSpec = {
|
|
|
202
202
|
background: testColor.fail,
|
|
203
203
|
animation: 'test-pulse 2s ease-in-out infinite',
|
|
204
204
|
},
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
/*
|
|
206
|
+
The widget sits in the header bar, not over the page (GitHub #233).
|
|
207
|
+
|
|
208
|
+
It used to be `position: fixed` at the bottom left, on top of whatever the page drew there:
|
|
209
|
+
a full-page component's list ran under it and a click on its last row landed on the widget
|
|
210
|
+
instead. Only a page with no header (`{"header": false}`) still floats it, because there is
|
|
211
|
+
nowhere else to put it. Bottom-LEFT, because the haltija dev widget takes bottom-right.
|
|
212
|
+
*/
|
|
208
213
|
'.test-widget': {
|
|
209
214
|
_testBg: testColor.running,
|
|
210
215
|
_testFg: testTextColor.running,
|
|
211
|
-
position: 'fixed',
|
|
212
|
-
bottom: vars.spacing,
|
|
213
|
-
left: vars.spacing,
|
|
214
|
-
zIndex: '1000',
|
|
215
216
|
background: vars.testBg,
|
|
216
217
|
// Paired with the background, not fixed to white — see testTextColor above.
|
|
217
218
|
color: vars.testFg,
|
|
218
219
|
gap: vars.spacing50,
|
|
219
220
|
},
|
|
221
|
+
'.test-widget.-floating': {
|
|
222
|
+
position: 'fixed',
|
|
223
|
+
bottom: vars.spacing,
|
|
224
|
+
left: vars.spacing,
|
|
225
|
+
zIndex: '1000',
|
|
226
|
+
},
|
|
220
227
|
'.test-widget[hidden]': { display: 'none' },
|
|
221
228
|
'.test-widget.-running': {
|
|
222
229
|
_testBg: testColor.running,
|
|
@@ -368,6 +375,7 @@ export function createDocBrowser(options) {
|
|
|
368
375
|
// while letting <tosi-doc-system> drive clean `/slug/` URLs off the same docs.
|
|
369
376
|
// Both path and memory routing key off slugs; only legacy query routing doesn't.
|
|
370
377
|
const slugMap = routing === 'query' ? {} : buildSlugMap(docs);
|
|
378
|
+
const wikilinks = wikilinkTargets(docs);
|
|
371
379
|
const slugFor = (filename) => slugMap[filename] ?? filename;
|
|
372
380
|
const filenameForSlug = (slug) => {
|
|
373
381
|
for (const d of docs)
|
|
@@ -974,6 +982,7 @@ export function createDocBrowser(options) {
|
|
|
974
982
|
// without loading the tjs transpiler. See self-contained-examples-plan.md.
|
|
975
983
|
docContent.innerHTML = renderDocMarkdown(doc.text, {
|
|
976
984
|
bakes: doc.bakes ? new Map(doc.bakes) : undefined,
|
|
985
|
+
wikilinks,
|
|
977
986
|
});
|
|
978
987
|
}
|
|
979
988
|
/*
|
|
@@ -1029,6 +1038,7 @@ export function createDocBrowser(options) {
|
|
|
1029
1038
|
// to follow the doc, so this does not ride on that path succeeding.
|
|
1030
1039
|
refreshNavToggle();
|
|
1031
1040
|
}
|
|
1041
|
+
placeTestWidget(doc);
|
|
1032
1042
|
rewriteContentLinks();
|
|
1033
1043
|
// Stamp each example with its source file (for the source↔doc map). doc.path
|
|
1034
1044
|
// is the extracted file (.md, or a source file with doc comments).
|
|
@@ -1262,7 +1272,7 @@ export function createDocBrowser(options) {
|
|
|
1262
1272
|
return;
|
|
1263
1273
|
editUI.view = view;
|
|
1264
1274
|
if (view === 'preview') {
|
|
1265
|
-
docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path));
|
|
1275
|
+
docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path), { wikilinks });
|
|
1266
1276
|
rewriteContentLinks();
|
|
1267
1277
|
LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
|
|
1268
1278
|
}
|
|
@@ -1615,6 +1625,21 @@ export function createDocBrowser(options) {
|
|
|
1615
1625
|
height: '100%',
|
|
1616
1626
|
},
|
|
1617
1627
|
}, docContent)));
|
|
1628
|
+
// Test status widget
|
|
1629
|
+
const testWidget = button({
|
|
1630
|
+
class: 'test-widget',
|
|
1631
|
+
hidden: true,
|
|
1632
|
+
onClick: showTestMenu,
|
|
1633
|
+
}, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
|
|
1634
|
+
const headerBar = container.querySelector('header');
|
|
1635
|
+
// In the header unless this doc hides the header; see `.test-widget` in the styles.
|
|
1636
|
+
const placeTestWidget = (doc) => {
|
|
1637
|
+
const floating = doc.header === false;
|
|
1638
|
+
testWidget.classList.toggle('-floating', floating);
|
|
1639
|
+
const home = floating ? container : headerBar;
|
|
1640
|
+
if (testWidget.parentElement !== home)
|
|
1641
|
+
home.appendChild(testWidget);
|
|
1642
|
+
};
|
|
1618
1643
|
// Render the landing doc (adopts pre-rendered HTML when hydrating).
|
|
1619
1644
|
showDoc(currentDoc);
|
|
1620
1645
|
// Inject test indicator styles
|
|
@@ -1622,13 +1647,6 @@ export function createDocBrowser(options) {
|
|
|
1622
1647
|
// Header brand-mark sizing (icon / <img> / inline-<svg>), one CSS var each.
|
|
1623
1648
|
StyleSheet('doc-logo-mark', logoMarkStyleSpec);
|
|
1624
1649
|
StyleSheet('doc-nav-search', navSearchStyleSpec);
|
|
1625
|
-
// Floating widget for test status
|
|
1626
|
-
const testWidget = button({
|
|
1627
|
-
class: 'test-widget',
|
|
1628
|
-
hidden: true,
|
|
1629
|
-
onClick: showTestMenu,
|
|
1630
|
-
}, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
|
|
1631
|
-
container.appendChild(testWidget);
|
|
1632
1650
|
/*
|
|
1633
1651
|
The far end has something to tell you.
|
|
1634
1652
|
|
|
@@ -7,7 +7,7 @@ Node-only APIs — so it bundles into the iife. Live examples are pretty-printed
|
|
|
7
7
|
force-wrapped code listings, not executed.
|
|
8
8
|
*/
|
|
9
9
|
import { renderDocMarkdown } from './render.js';
|
|
10
|
-
import { buildSlugMap } from './routing.js';
|
|
10
|
+
import { buildSlugMap, wikilinkTargets } from './routing.js';
|
|
11
11
|
import { buildNavTree } from './nav-tree.js';
|
|
12
12
|
import { bookTextOf } from './book-target.js';
|
|
13
13
|
// Clean book typography; force-wraps code so listings never overflow a page.
|
|
@@ -149,10 +149,13 @@ function tocHtml(nodes) {
|
|
|
149
149
|
export function buildBookHtml(docs, opts) {
|
|
150
150
|
const visible = docs.filter((d) => !d.hidden);
|
|
151
151
|
const slugMap = buildSlugMap(visible);
|
|
152
|
+
const wikilinks = wikilinkTargets(visible);
|
|
152
153
|
const roots = buildNavTree(visible, slugMap);
|
|
153
154
|
const css = opts.css ?? `${DEFAULT_BOOK_CSS}\n${PRINT_CSS}`;
|
|
154
155
|
const chapters = flatten(roots)
|
|
155
|
-
.map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc))
|
|
156
|
+
.map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc)), {
|
|
157
|
+
wikilinks,
|
|
158
|
+
})}\n</section>`)
|
|
156
159
|
.join('\n');
|
|
157
160
|
return `<!DOCTYPE html>
|
|
158
161
|
<html lang="${opts.lang ?? 'en'}">
|
|
@@ -6,6 +6,8 @@ export type ExampleBakes = Map<string, ExampleBake>;
|
|
|
6
6
|
export declare function unwrapLoneCustomElements(html: string): string;
|
|
7
7
|
export declare function renderDocMarkdown(text: string, opts?: {
|
|
8
8
|
bakes?: ExampleBakes;
|
|
9
|
+
/** the corpus's `wikilinkTargets`; without it a `[[target]]` is slugified as written */
|
|
10
|
+
wikilinks?: Map<string, string>;
|
|
9
11
|
}): string;
|
|
10
12
|
/**
|
|
11
13
|
* Derive a clean <meta name="description"> from a doc's first prose paragraph.
|
|
@@ -11,8 +11,10 @@ give that example a stable anchor — see the docMarked renderer below.
|
|
|
11
11
|
import { Marked, Renderer } from 'marked';
|
|
12
12
|
import { parseFenceInfo } from './example-policy.js';
|
|
13
13
|
import { HeadingIds, headingSlug } from './heading-id.js';
|
|
14
|
+
import { pathForSlug, slugify } from './routing.js';
|
|
14
15
|
const baseRenderer = new Renderer();
|
|
15
16
|
let currentBakes;
|
|
17
|
+
let currentWikilinks;
|
|
16
18
|
// Per-parse, reset in `preprocess` below (same pattern as the footnote state).
|
|
17
19
|
let headingIds = new HeadingIds();
|
|
18
20
|
const docMarked = new Marked();
|
|
@@ -69,12 +71,6 @@ docMarked.use({
|
|
|
69
71
|
// Enabled by default because each activates ONLY on its own syntax ([[…]] /
|
|
70
72
|
// [^id]) — a doc that doesn't use them renders byte-identically (tosijs-ui's own
|
|
71
73
|
// docs use neither). marked core supports neither; both are common in prose.
|
|
72
|
-
function slugify(s) {
|
|
73
|
-
return s
|
|
74
|
-
.toLowerCase()
|
|
75
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
76
|
-
.replace(/^-+|-+$/g, '');
|
|
77
|
-
}
|
|
78
74
|
function escapeHtml(s) {
|
|
79
75
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
80
76
|
}
|
|
@@ -112,7 +108,9 @@ docMarked.use({
|
|
|
112
108
|
extensions: [
|
|
113
109
|
{
|
|
114
110
|
// [[slug]] / [[slug|label]] → link to /slug/. Not matched inside code spans
|
|
115
|
-
// (Marked tokenizes those first).
|
|
111
|
+
// (Marked tokenizes those first). Given the corpus's `wikilinkTargets`, a target is
|
|
112
|
+
// looked up there (by slug, then title) and one that reaches no page is marked
|
|
113
|
+
// `-missing` for the build to report; without them it is slugified as written.
|
|
116
114
|
name: 'wikilink',
|
|
117
115
|
level: 'inline',
|
|
118
116
|
start(src) {
|
|
@@ -130,7 +128,12 @@ docMarked.use({
|
|
|
130
128
|
};
|
|
131
129
|
},
|
|
132
130
|
renderer(token) {
|
|
133
|
-
|
|
131
|
+
const key = slugify(token.target);
|
|
132
|
+
const slug = currentWikilinks ? currentWikilinks.get(key) : key;
|
|
133
|
+
const missing = slug === undefined
|
|
134
|
+
? ` -missing" data-target="${escapeAttr(token.target)}`
|
|
135
|
+
: '';
|
|
136
|
+
return `<a href="${pathForSlug(slug ?? key)}" class="wikilink${missing}">${escapeHtml(token.label)}</a>`;
|
|
134
137
|
},
|
|
135
138
|
},
|
|
136
139
|
{
|
|
@@ -205,11 +208,13 @@ export function unwrapLoneCustomElements(html) {
|
|
|
205
208
|
}
|
|
206
209
|
export function renderDocMarkdown(text, opts = {}) {
|
|
207
210
|
currentBakes = opts.bakes;
|
|
211
|
+
currentWikilinks = opts.wikilinks;
|
|
208
212
|
try {
|
|
209
213
|
return unwrapLoneCustomElements(docMarked.parse(text));
|
|
210
214
|
}
|
|
211
215
|
finally {
|
|
212
216
|
currentBakes = undefined;
|
|
217
|
+
currentWikilinks = undefined;
|
|
213
218
|
}
|
|
214
219
|
}
|
|
215
220
|
/**
|
|
@@ -19,6 +19,18 @@ export declare function homeDoc(docs: DocLike[]): string | null;
|
|
|
19
19
|
* on array order.
|
|
20
20
|
*/
|
|
21
21
|
export declare function buildSlugMap(docs: DocLike[]): Record<string, string>;
|
|
22
|
+
/**
|
|
23
|
+
* What each `[[target]]` in a corpus can reach: { slugify(target) -> slug }.
|
|
24
|
+
*
|
|
25
|
+
* A page is reached by its slug, and also by its title when no page has that slug and no
|
|
26
|
+
* other page has the same title (GitHub #234). Authors write a page's TITLE —
|
|
27
|
+
* `[[The feature registry]]` — while the page lives at its file name, `/registry/`; before
|
|
28
|
+
* this, every such link went to `/the-feature-registry/` and 404'd without a word. A slug
|
|
29
|
+
* always wins over a title, so no link that already worked changes target.
|
|
30
|
+
*/
|
|
31
|
+
export declare function wikilinkTargets(docs: Array<DocLike & {
|
|
32
|
+
title?: string;
|
|
33
|
+
}>): Map<string, string>;
|
|
22
34
|
/** site-root-relative path for a slug: '' -> '/', 'button' -> '/button/' */
|
|
23
35
|
export declare function pathForSlug(slug: string): string;
|
|
24
36
|
/**
|
|
@@ -56,6 +56,36 @@ export function buildSlugMap(docs) {
|
|
|
56
56
|
}
|
|
57
57
|
return map;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* What each `[[target]]` in a corpus can reach: { slugify(target) -> slug }.
|
|
61
|
+
*
|
|
62
|
+
* A page is reached by its slug, and also by its title when no page has that slug and no
|
|
63
|
+
* other page has the same title (GitHub #234). Authors write a page's TITLE —
|
|
64
|
+
* `[[The feature registry]]` — while the page lives at its file name, `/registry/`; before
|
|
65
|
+
* this, every such link went to `/the-feature-registry/` and 404'd without a word. A slug
|
|
66
|
+
* always wins over a title, so no link that already worked changes target.
|
|
67
|
+
*/
|
|
68
|
+
export function wikilinkTargets(docs) {
|
|
69
|
+
const slugs = buildSlugMap(docs);
|
|
70
|
+
const targets = new Map();
|
|
71
|
+
for (const doc of docs) {
|
|
72
|
+
const slug = slugs[doc.filename];
|
|
73
|
+
if (slug !== '')
|
|
74
|
+
targets.set(slugify(slug), slug);
|
|
75
|
+
}
|
|
76
|
+
const byTitle = new Map();
|
|
77
|
+
for (const doc of docs) {
|
|
78
|
+
const key = doc.title ? slugify(doc.title) : '';
|
|
79
|
+
if (!key || targets.has(key))
|
|
80
|
+
continue;
|
|
81
|
+
// Two pages with one title: neither is reachable by it, so the link is reported.
|
|
82
|
+
byTitle.set(key, byTitle.has(key) ? null : slugs[doc.filename]);
|
|
83
|
+
}
|
|
84
|
+
for (const [key, slug] of byTitle)
|
|
85
|
+
if (slug !== null)
|
|
86
|
+
targets.set(key, slug);
|
|
87
|
+
return targets;
|
|
88
|
+
}
|
|
59
89
|
/** site-root-relative path for a slug: '' -> '/', 'button' -> '/button/' */
|
|
60
90
|
export function pathForSlug(slug) {
|
|
61
91
|
return slug === '' ? '/' : `/${slug}/`;
|
|
@@ -32,3 +32,11 @@ export declare function deadInPageAnchors(html: string): string[];
|
|
|
32
32
|
* (`diagram.svg`, `img/x.png`) is left alone. The fix is the root-relative `/value-renderer/`.
|
|
33
33
|
*/
|
|
34
34
|
export declare function bareNameLinks(html: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* The `[[wikilinks]]` in a RENDERED page that reach no page, as their authors wrote them.
|
|
37
|
+
*
|
|
38
|
+
* The renderer marks one `-missing` (with its target in `data-target`) when it was given the
|
|
39
|
+
* corpus's `wikilinkTargets` and neither a slug nor a title matched. Nine of these sat in one
|
|
40
|
+
* site for five releases, every one a live 404, because nothing said so (GitHub #234).
|
|
41
|
+
*/
|
|
42
|
+
export declare function missingWikilinks(html: string): string[];
|
|
@@ -128,3 +128,30 @@ export function bareNameLinks(html) {
|
|
|
128
128
|
}
|
|
129
129
|
return [...found];
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* The `[[wikilinks]]` in a RENDERED page that reach no page, as their authors wrote them.
|
|
133
|
+
*
|
|
134
|
+
* The renderer marks one `-missing` (with its target in `data-target`) when it was given the
|
|
135
|
+
* corpus's `wikilinkTargets` and neither a slug nor a title matched. Nine of these sat in one
|
|
136
|
+
* site for five releases, every one a live 404, because nothing said so (GitHub #234).
|
|
137
|
+
*/
|
|
138
|
+
export function missingWikilinks(html) {
|
|
139
|
+
const found = new Set();
|
|
140
|
+
for (const tag of html.matchAll(START_TAG)) {
|
|
141
|
+
if (tag[1].toLowerCase() !== 'a')
|
|
142
|
+
continue;
|
|
143
|
+
let missing = false;
|
|
144
|
+
let target = '';
|
|
145
|
+
for (const attr of tag[2].matchAll(ATTRIBUTE)) {
|
|
146
|
+
const key = attr[1].toLowerCase();
|
|
147
|
+
const value = unescapeAttr(attr[2] ?? attr[3] ?? attr[4] ?? '');
|
|
148
|
+
if (key === 'class')
|
|
149
|
+
missing = value.split(/\s+/).includes('-missing');
|
|
150
|
+
else if (key === 'data-target')
|
|
151
|
+
target = value;
|
|
152
|
+
}
|
|
153
|
+
if (missing && target)
|
|
154
|
+
found.add(target);
|
|
155
|
+
}
|
|
156
|
+
return [...found];
|
|
157
|
+
}
|
|
@@ -1904,7 +1904,7 @@ export async function devServer(config, opts = {}) {
|
|
|
1904
1904
|
sentence differs, and that sentence was the entire report.
|
|
1905
1905
|
*/
|
|
1906
1906
|
const rejection = sessionRejection(readCookie(request.headers.get('cookie'), SESSION_COOKIE), Date.now());
|
|
1907
|
-
return new Response(invitePageHtml(rejection, TUNNEL_LINK_CMD), {
|
|
1907
|
+
return new Response(invitePageHtml(rejection, TUNNEL_LINK_CMD, globalKeys.keys.edit ? `${TUNNEL_LINK_CMD} --global` : undefined), {
|
|
1908
1908
|
status: 401,
|
|
1909
1909
|
headers: {
|
|
1910
1910
|
'Content-Type': 'text/html; charset=utf-8',
|
|
@@ -18,7 +18,7 @@ import * as fs from 'fs';
|
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import { renderDocMarkdown } from '../render.js';
|
|
20
20
|
import { highlightHtml } from '../highlight.js';
|
|
21
|
-
import { buildSlugMap, pathForSlug, resolveParent, slugForPath, withBase, } from '../routing.js';
|
|
21
|
+
import { buildSlugMap, pathForSlug, resolveParent, slugForPath, withBase, wikilinkTargets, } from '../routing.js';
|
|
22
22
|
import { buildNavTree } from '../nav-tree.js';
|
|
23
23
|
import { partitionByBook, placeInBook, bookTextOf, DEFAULT_BOOK, } from '../book-target.js';
|
|
24
24
|
import { COVER_MEDIA_TYPES, coverStem, epubVolumeIdentity, } from './epub-volumes.js';
|
|
@@ -621,6 +621,7 @@ export async function buildEpub(config, opts = {}) {
|
|
|
621
621
|
into the default one just by not naming a book.
|
|
622
622
|
*/
|
|
623
623
|
const siteSlugs = buildSlugMap(corpus);
|
|
624
|
+
const wikilinks = wikilinkTargets(corpus);
|
|
624
625
|
const volumes = partitionByBook(corpus, siteSlugs);
|
|
625
626
|
const visible = volumes.get(opts.bookTarget ?? DEFAULT_BOOK) ?? [];
|
|
626
627
|
/*
|
|
@@ -750,7 +751,7 @@ export async function buildEpub(config, opts = {}) {
|
|
|
750
751
|
`injectExampleLinks` matches on the `language-*` class, never on the code text, so
|
|
751
752
|
highlighting cannot break the "Run this example live" links.
|
|
752
753
|
*/
|
|
753
|
-
await highlightHtml(renderDocMarkdown(stripDocMeta(bookTextOf(doc))), 'none'), bookFiles, slugMap, config.basePath);
|
|
754
|
+
await highlightHtml(renderDocMarkdown(stripDocMeta(bookTextOf(doc)), { wikilinks }), 'none'), bookFiles, slugMap, config.basePath);
|
|
754
755
|
// happy-dom occasionally throws on exotic content (e.g. an internal selector
|
|
755
756
|
// bug); fall back to the regex pass for that doc rather than aborting.
|
|
756
757
|
let bodyHtml;
|
|
@@ -12,10 +12,10 @@ 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, } from '../routing.js';
|
|
15
|
+
import { 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
|
-
import { bareNameLinks, deadInPageAnchors, misconfigured, } from './build-warnings.js';
|
|
18
|
+
import { bareNameLinks, deadInPageAnchors, missingWikilinks, misconfigured, } from './build-warnings.js';
|
|
19
19
|
const escapeAttr = (s) => s
|
|
20
20
|
.replace(/&/g, '&')
|
|
21
21
|
.replace(/</g, '<')
|
|
@@ -111,7 +111,7 @@ export function relativeUrl(depth, p) {
|
|
|
111
111
|
const rel = '../'.repeat(depth) + p.slice(1);
|
|
112
112
|
return rel === '' ? './' : rel;
|
|
113
113
|
}
|
|
114
|
-
async function pageHtml(doc, config, slugMap, configAttr) {
|
|
114
|
+
async function pageHtml(doc, config, slugMap, configAttr, wikilinks) {
|
|
115
115
|
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
116
|
// Functional URLs are emitted relative to THIS page's depth so the build is
|
|
117
117
|
// mount-agnostic (issue #25); metadata URLs below stay absolute via withBase.
|
|
@@ -142,7 +142,10 @@ async function pageHtml(doc, config, slugMap, configAttr) {
|
|
|
142
142
|
// Rewrite legacy `?filename` content links to clean `/slug/` paths so the
|
|
143
143
|
// static HTML is correct for no-JS readers and crawlers (the doc-browser also
|
|
144
144
|
// does this client-side after hydration).
|
|
145
|
-
const body = rewriteDocLinks(await highlightHtml(renderDocMarkdown(doc.text, {
|
|
145
|
+
const body = rewriteDocLinks(await highlightHtml(renderDocMarkdown(doc.text, {
|
|
146
|
+
bakes: bakes?.get(doc.filename),
|
|
147
|
+
wikilinks,
|
|
148
|
+
}),
|
|
146
149
|
// The SAME policy the client will apply, so the build and the browser agree about
|
|
147
150
|
// which blocks are live examples and which are static code to highlight.
|
|
148
151
|
config.liveExamples ?? 'auto'), (filename) => slugMap[filename] !== undefined
|
|
@@ -289,6 +292,7 @@ export function withStamp(url, stamp) {
|
|
|
289
292
|
export async function generateSite(config) {
|
|
290
293
|
const { docs, outputDir } = config;
|
|
291
294
|
const slugMap = buildSlugMap(docs);
|
|
295
|
+
const wikilinks = wikilinkTargets(docs);
|
|
292
296
|
// Surface any slug collisions (the slug map disambiguates them, but a warning
|
|
293
297
|
// tells the maintainer two docs share a base name).
|
|
294
298
|
const seen = new Map();
|
|
@@ -311,26 +315,38 @@ export async function generateSite(config) {
|
|
|
311
315
|
let count = 0;
|
|
312
316
|
const deadAnchors = [];
|
|
313
317
|
const bareLinks = [];
|
|
318
|
+
const danglingWikilinks = [];
|
|
314
319
|
for (const doc of docs) {
|
|
315
320
|
const slug = slugMap[doc.filename];
|
|
316
321
|
const dir = slug === '' ? outputDir : `${outputDir}/${slug}`;
|
|
317
|
-
const rendered = renderDocMarkdown(doc.text);
|
|
322
|
+
const rendered = renderDocMarkdown(doc.text, { wikilinks });
|
|
318
323
|
const dead = deadInPageAnchors(rendered);
|
|
319
324
|
// On the root page (`/`) a bare name resolves to `/name`, which works; elsewhere it does not.
|
|
320
325
|
const bare = slug === '' ? [] : bareNameLinks(rendered);
|
|
326
|
+
const dangling = missingWikilinks(rendered);
|
|
327
|
+
if (dangling.length)
|
|
328
|
+
danglingWikilinks.push(` ${doc.path || doc.filename}: ${dangling
|
|
329
|
+
.map((t) => `[[${t}]]`)
|
|
330
|
+
.join(', ')}`);
|
|
321
331
|
if (bare.length)
|
|
322
332
|
bareLinks.push(` ${doc.path || doc.filename}: ${bare.join(', ')}`);
|
|
323
333
|
if (dead.length)
|
|
324
334
|
deadAnchors.push(` ${doc.path || doc.filename}: ${dead
|
|
325
335
|
.map((d) => `#${d}`)
|
|
326
336
|
.join(', ')}`);
|
|
327
|
-
await Bun.write(`${dir}/index.html`, await pageHtml(doc, config, slugMap, configAttr));
|
|
337
|
+
await Bun.write(`${dir}/index.html`, await pageHtml(doc, config, slugMap, configAttr, wikilinks));
|
|
328
338
|
// The page's own source, for readers that want text rather than a page.
|
|
329
339
|
if (hasMarkdownPage(doc, config)) {
|
|
330
340
|
await Bun.write(`${dir}/index.md`, doc.text.trimEnd() + '\n');
|
|
331
341
|
}
|
|
332
342
|
count += 1;
|
|
333
343
|
}
|
|
344
|
+
if (danglingWikilinks.length) {
|
|
345
|
+
misconfigured(config.strict, `\n⚠️ Wikilinks that reach no doc page. A target matches a page's slug (its file name)\n` +
|
|
346
|
+
` or its title; write [[slug|label]] to link one by file name, and an ordinary\n` +
|
|
347
|
+
` [label](/path/) for anything that is not a doc page (a static file or app):\n` +
|
|
348
|
+
`${danglingWikilinks.join('\n')}\n`);
|
|
349
|
+
}
|
|
334
350
|
if (bareLinks.length) {
|
|
335
351
|
misconfigured(config.strict, `\n⚠️ Links written as a bare page name. Every page is served at /slug/, so these\n` +
|
|
336
352
|
` resolve under the page they are on and reach nothing; write /name/ instead:\n` +
|
|
@@ -17,4 +17,10 @@ export type SessionRejection = 'none' | 'stale' | 'unknown';
|
|
|
17
17
|
* installed app captures it would bake a credential into a home-screen icon that works once and
|
|
18
18
|
* then fails forever, with no address bar to correct it. Eight characters is the better trade.
|
|
19
19
|
*/
|
|
20
|
-
export declare function invitePageHtml(rejection: SessionRejection, linkCommand: string
|
|
20
|
+
export declare function invitePageHtml(rejection: SessionRejection, linkCommand: string,
|
|
21
|
+
/**
|
|
22
|
+
* The command for a link that keeps the browser in for good, when this server takes the
|
|
23
|
+
* preview box's global keys. Without it the page offered only per-visit links, and a reader
|
|
24
|
+
* whose key cookie had lapsed read that as the daily-link problem coming back (#232).
|
|
25
|
+
*/
|
|
26
|
+
lastingCommand?: string): string;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
2
|
The screen someone sees when their session is not good enough.
|
|
3
3
|
*/
|
|
4
|
+
import { GLOBAL_KEY_COOKIE_MAX_AGE_S } from './dev-auth.js';
|
|
5
|
+
const LASTING_DAYS = Math.round(GLOBAL_KEY_COOKIE_MAX_AGE_S / 86400);
|
|
4
6
|
/**
|
|
5
7
|
* Why they are here, said plainly.
|
|
6
8
|
*
|
|
@@ -36,7 +38,13 @@ function explain(rejection) {
|
|
|
36
38
|
* installed app captures it would bake a credential into a home-screen icon that works once and
|
|
37
39
|
* then fails forever, with no address bar to correct it. Eight characters is the better trade.
|
|
38
40
|
*/
|
|
39
|
-
export function invitePageHtml(rejection, linkCommand
|
|
41
|
+
export function invitePageHtml(rejection, linkCommand,
|
|
42
|
+
/**
|
|
43
|
+
* The command for a link that keeps the browser in for good, when this server takes the
|
|
44
|
+
* preview box's global keys. Without it the page offered only per-visit links, and a reader
|
|
45
|
+
* whose key cookie had lapsed read that as the daily-link problem coming back (#232).
|
|
46
|
+
*/
|
|
47
|
+
lastingCommand) {
|
|
40
48
|
return (`<!doctype html><meta charset=utf-8>` +
|
|
41
49
|
`<title>Link required</title>` +
|
|
42
50
|
`<meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
@@ -62,5 +70,10 @@ export function invitePageHtml(rejection, linkCommand) {
|
|
|
62
70
|
`border:0;border-radius:6px;background:#0064d2;color:#fff">Open</button>` +
|
|
63
71
|
`</form>` +
|
|
64
72
|
`<p style="opacity:.7">Or, at the machine running the server:</p>` +
|
|
65
|
-
`<p><code>${linkCommand}</code></p>`
|
|
73
|
+
`<p><code>${linkCommand}</code></p>` +
|
|
74
|
+
(lastingCommand
|
|
75
|
+
? `<p style="opacity:.7">For a link that keeps this browser in every workspace on the ` +
|
|
76
|
+
`preview box, across restarts, for ${LASTING_DAYS} days:</p>` +
|
|
77
|
+
`<p><code>${lastingCommand}</code></p>`
|
|
78
|
+
: ''));
|
|
66
79
|
}
|