@inditextech/docouture-antora-extensions 0.1.0-SNAPSHOT.40.1
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/index.js +113 -0
- package/lib/duplicate-latest-version.js +148 -0
- package/lib/duplicate-latest-version.spec.js +204 -0
- package/lib/footer.js +122 -0
- package/lib/html-to-markdown.js +259 -0
- package/lib/html-to-markdown.spec.js +73 -0
- package/lib/kroki-docker.js +162 -0
- package/lib/kroki-docker.spec.js +92 -0
- package/lib/kroki-prewarm.js +182 -0
- package/lib/kroki-prewarm.spec.js +209 -0
- package/lib/lifecycle-log.js +61 -0
- package/lib/lifecycle-log.spec.js +59 -0
- package/lib/llms-txt.js +185 -0
- package/lib/llms-txt.spec.js +172 -0
- package/lib/nav-modules.js +233 -0
- package/lib/not-found-page.js +170 -0
- package/lib/redirects.js +249 -0
- package/lib/redirects.spec.js +129 -0
- package/lib/resolve-url.js +45 -0
- package/lib/search-index.js +279 -0
- package/lib/search-index.spec.js +104 -0
- package/lib/shiki-prewarm.js +76 -0
- package/lib/version-report.js +40 -0
- package/lib/version-report.spec.js +83 -0
- package/package.json +33 -0
- package/resources/kroki-compose.yml +72 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const createPageComposer = require('@antora/page-composer')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gives the generated 404 page a real side menu, version tag and site
|
|
7
|
+
* footer — instead of the bare shell it gets by default.
|
|
8
|
+
*
|
|
9
|
+
* Why: @antora/page-composer's own `create404Page` (site-generator's
|
|
10
|
+
* generate-site.js, only invoked when `site.url` is set) builds a page whose
|
|
11
|
+
* `src` has no `component` at all:
|
|
12
|
+
*
|
|
13
|
+
* src: { stem: '404' }
|
|
14
|
+
*
|
|
15
|
+
* `buildPageUiModel` special-cases exactly that shape — `src.stem === '404'
|
|
16
|
+
* && !('component' in src)` — and returns immediately with
|
|
17
|
+
* `{ 404: true, attributes, layout: '404', title }`, before it would ever
|
|
18
|
+
* compute `page.navigation`, `page.componentVersion` or `page.versions`. On
|
|
19
|
+
* top of that, `create404Page` calls `composePage(file)` with only ONE
|
|
20
|
+
* argument, so `navigationCatalog` is `undefined` regardless of `src` —
|
|
21
|
+
* `attachNavProperties` is gated on `if (navigationCatalog)` and would never
|
|
22
|
+
* run even for a page with a real component. There is no playbook or
|
|
23
|
+
* descriptor knob that changes any of this: it is what the library's own
|
|
24
|
+
* 404 page composer does, full stop.
|
|
25
|
+
*
|
|
26
|
+
* So this extension composes its OWN 404 page instead, using a `src` that
|
|
27
|
+
* names a real, existing component/version/module, and swaps it in for
|
|
28
|
+
* Antora's own once that one has already been added to the site catalog.
|
|
29
|
+
* Every template partial that reads `page.component`, `page.componentVersion`
|
|
30
|
+
* or `page.navigation` — the side menu, the header's version tag and search
|
|
31
|
+
* trigger, the footer's per-module links — then renders exactly as it would
|
|
32
|
+
* on a real page in that module, because as far as the UI model is
|
|
33
|
+
* concerned, it IS one; only `page-layout: 404` (set on the synthetic file's
|
|
34
|
+
* `asciidoc.attributes`, the same attribute `:page-layout: 404` would set on
|
|
35
|
+
* a real page) tells `main.hbs` to swap in `article-404.hbs` instead of the
|
|
36
|
+
* hero/toc/article trio.
|
|
37
|
+
*
|
|
38
|
+
* Component and module selection, deliberately conservative:
|
|
39
|
+
*
|
|
40
|
+
* - Only ever runs for a site with EXACTLY ONE component. A multi-component
|
|
41
|
+
* site (this repo has none) would need its own choice of which
|
|
42
|
+
* component's chrome a component-less 404 page should borrow, which is a
|
|
43
|
+
* bigger decision than this extension makes for you — the plain,
|
|
44
|
+
* unnavigated 404 page ships instead, same as if this extension were
|
|
45
|
+
* absent, and a warning says why.
|
|
46
|
+
* - That component's LATEST version — a 404 page isn't itself versioned,
|
|
47
|
+
* so "the current one" is the only sensible choice.
|
|
48
|
+
* - The module whose navigation the side menu shows: if the component has
|
|
49
|
+
* exactly one navigation tree (the common case — see nav-modules.js's
|
|
50
|
+
* own comment on what a single-nav-file component's `page.navigation`
|
|
51
|
+
* looks like), that's it, no configuration needed. With more than one —
|
|
52
|
+
* `example`'s seven modules are exactly this — `not_found_module` in
|
|
53
|
+
* the component descriptor (`docs/antora.yml`) names which one:
|
|
54
|
+
*
|
|
55
|
+
* not_found_module: main
|
|
56
|
+
*
|
|
57
|
+
* Left unset on a multi-module component, the 404 page still gets its
|
|
58
|
+
* real version tag, search trigger and footer (all keyed off the
|
|
59
|
+
* component/version alone) — only the side menu's tree stays empty,
|
|
60
|
+
* which is what nav-menu.hbs already does for a `page.module` that
|
|
61
|
+
* matches no tree — same "no fallback to first tree, an empty menu is a
|
|
62
|
+
* missing attribute" rule nav-modules.js's own header argues for the
|
|
63
|
+
* switcher.
|
|
64
|
+
*/
|
|
65
|
+
module.exports = function registerNotFoundPage(context) {
|
|
66
|
+
const logger = context.getLogger('docouture-not-found-page')
|
|
67
|
+
// Keyed the same way nav-modules.js and footer.js key their own copies.
|
|
68
|
+
const notFoundModules = new Map()
|
|
69
|
+
let navigationCatalog
|
|
70
|
+
|
|
71
|
+
// Same two-phase shape as nav-modules.js/footer.js: `not_found_module`
|
|
72
|
+
// only survives on the raw aggregate, so it has to be copied out here,
|
|
73
|
+
// before `vars.remove('contentAggregate')` (generate-site.js) drops it.
|
|
74
|
+
context.on('contentAggregated', ({ contentAggregate }) => {
|
|
75
|
+
for (const bucket of contentAggregate) {
|
|
76
|
+
if (!bucket.notFoundModule) continue
|
|
77
|
+
notFoundModules.set(bucket.version + '@' + bucket.name, bucket.notFoundModule)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// `navigationBuilt` is the last event carrying `navigationCatalog` — see
|
|
82
|
+
// this file's own header on why it's needed and why create404Page never
|
|
83
|
+
// gets it. Captured here, read at `pagesComposed`, once nav-modules.js's
|
|
84
|
+
// own `navigationBuilt` listener (registered before this one, see index.js)
|
|
85
|
+
// has already stamped `tree.module` on every tree.
|
|
86
|
+
context.on('navigationBuilt', ({ navigationCatalog: catalog }) => {
|
|
87
|
+
navigationCatalog = catalog
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
context.on('pagesComposed', ({ playbook, contentCatalog, uiCatalog, siteCatalog, siteAsciiDocConfig }) => {
|
|
91
|
+
// Antora only composes a 404 page at all when site.url is set
|
|
92
|
+
// (generate-site.js) — nothing to enhance otherwise.
|
|
93
|
+
if (!playbook.site.url) return
|
|
94
|
+
if (!navigationCatalog) return // defensive; always set by this point when site.url is present
|
|
95
|
+
|
|
96
|
+
const components = contentCatalog.getComponents()
|
|
97
|
+
if (components.length !== 1) {
|
|
98
|
+
logger.warn(
|
|
99
|
+
'Not enhancing the generated 404 page: expected exactly one component, found %s. ' +
|
|
100
|
+
'It keeps the plain, unnavigated layout Antora composes by default.',
|
|
101
|
+
components.length
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const component = components[0]
|
|
107
|
+
const componentVersion = component.latest
|
|
108
|
+
const navigation = navigationCatalog.getNavigation(component.name, componentVersion.version) || []
|
|
109
|
+
|
|
110
|
+
let module_
|
|
111
|
+
if (navigation.length === 1) {
|
|
112
|
+
// The common case: one nav file, so nothing to pick between — see
|
|
113
|
+
// nav-modules.js's own comment on why `page.navigation` looks like
|
|
114
|
+
// this for a single-nav-file component. `tree.module` is usually
|
|
115
|
+
// unset here too (no nav_modules declared), which nav-menu.hbs treats
|
|
116
|
+
// the same way: `(not ./module)` renders it unconditionally.
|
|
117
|
+
module_ = navigation[0].module
|
|
118
|
+
} else if (navigation.length > 1) {
|
|
119
|
+
const where = `${component.name}@${componentVersion.version || 'default'}`
|
|
120
|
+
const configured = notFoundModules.get(componentVersion.version + '@' + component.name)
|
|
121
|
+
if (!configured) {
|
|
122
|
+
logger.warn(
|
|
123
|
+
'%s has %s navigation modules and no not_found_module configured in its antora.yml: ' +
|
|
124
|
+
"the 404 page's side menu will render empty. Set not_found_module to the module whose " +
|
|
125
|
+
'navigation it should show.',
|
|
126
|
+
where,
|
|
127
|
+
navigation.length
|
|
128
|
+
)
|
|
129
|
+
} else if (!navigation.some((tree) => tree.module === configured)) {
|
|
130
|
+
logger.warn(
|
|
131
|
+
"not_found_module '%s' in %s matches no navigation file; the 404 page's side menu will render empty.",
|
|
132
|
+
configured,
|
|
133
|
+
where
|
|
134
|
+
)
|
|
135
|
+
} else {
|
|
136
|
+
module_ = configured
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const file = {
|
|
141
|
+
mediaType: 'text/html',
|
|
142
|
+
out: { path: '404.html' },
|
|
143
|
+
pub: { url: '/404.html' },
|
|
144
|
+
src: {
|
|
145
|
+
component: component.name,
|
|
146
|
+
version: componentVersion.version,
|
|
147
|
+
module: module_,
|
|
148
|
+
family: 'page',
|
|
149
|
+
relative: 'index.adoc',
|
|
150
|
+
},
|
|
151
|
+
title: siteAsciiDocConfig?.attributes['404-page-title'] || 'Page Not Found',
|
|
152
|
+
// Cloned, not shared: mutating the site-wide asciidoc config in place
|
|
153
|
+
// would leak `page-layout: 404` onto every other page composePage
|
|
154
|
+
// touches from here on.
|
|
155
|
+
asciidoc: Object.assign({}, siteAsciiDocConfig, {
|
|
156
|
+
attributes: Object.assign({}, siteAsciiDocConfig?.attributes, { 'page-layout': '404' }),
|
|
157
|
+
}),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A fresh composer, not the pipeline's own (createPageComposer's result
|
|
161
|
+
// isn't threaded through to extensions) — built from the same
|
|
162
|
+
// `uiCatalog`, so it compiles the identical layouts/partials/helpers the
|
|
163
|
+
// rest of the site just rendered with.
|
|
164
|
+
createPageComposer(playbook, contentCatalog, uiCatalog).composePage(file, contentCatalog, navigationCatalog)
|
|
165
|
+
|
|
166
|
+
const previous = siteCatalog.getFiles().find((candidate) => candidate.out?.path === '404.html')
|
|
167
|
+
if (previous) siteCatalog.removeFile(previous)
|
|
168
|
+
siteCatalog.addFile(file)
|
|
169
|
+
})
|
|
170
|
+
}
|
package/lib/redirects.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const produceRedirects = require('@antora/redirect-producer')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates static redirect stubs from literal, arbitrary legacy URLs (e.g.
|
|
7
|
+
* a Fumadocs-era `/weavejs/docs/main/quickstart`) to whatever real page
|
|
8
|
+
* currently answers the equivalent URL in this site (e.g.
|
|
9
|
+
* `/weavejs/latest/main/quickstart`) — for migrated content whose old URL
|
|
10
|
+
* shape doesn't fit Antora's own `component:version:module:family/relative`
|
|
11
|
+
* page-ID grammar, so the built-in `page-aliases` attribute can't reach it.
|
|
12
|
+
*
|
|
13
|
+
* Configured on the EXTENSION'S OWN registration entry in the playbook, not
|
|
14
|
+
* in `docs/antora.yml` — unlike nav_modules/footer/llms, which are
|
|
15
|
+
* versioned per ref (see nav-modules.js's own header for why those live in
|
|
16
|
+
* the component descriptor instead of `site.keys`), these rules are
|
|
17
|
+
* evaluated fresh against whatever pages a given build actually resolves,
|
|
18
|
+
* regardless of which refs that build aggregates, so there is nothing to
|
|
19
|
+
* gain from repeating the list on every tag — one copy, in the one
|
|
20
|
+
* playbook, covers every build:
|
|
21
|
+
*
|
|
22
|
+
* antora:
|
|
23
|
+
* extensions:
|
|
24
|
+
* - require: '@inditextech/docouture-antora-extensions'
|
|
25
|
+
* redirects:
|
|
26
|
+
* - from: '/weavejs/docs/main/build/node/comment'
|
|
27
|
+
* to: '/weavejs/latest/main/build/nodes/comment'
|
|
28
|
+
* - from: '/weavejs/docs/**'
|
|
29
|
+
* to: '/weavejs/latest/**'
|
|
30
|
+
*
|
|
31
|
+
* Both `from` and `to` are literal URL templates, not resource IDs — `*`
|
|
32
|
+
* captures exactly one path segment, `**` captures everything remaining
|
|
33
|
+
* (potentially several segments). `to` is the side that gets matched
|
|
34
|
+
* against every REAL page's own already-computed `pub.url`: only actually
|
|
35
|
+
* published content can be a redirect target, so matching against it (and
|
|
36
|
+
* never against another alias/redirect Antora itself may have produced,
|
|
37
|
+
* e.g. urls.latest_version_segment's own stubs) is what keeps a rule from
|
|
38
|
+
* ever chaining two redirects together. A `to` naming a segment that's
|
|
39
|
+
* currently just an alias itself — `/weavejs/stable/**` while
|
|
40
|
+
* `latest_version_segment`'s default `replace` strategy is active — matches
|
|
41
|
+
* nothing, on purpose: `/weavejs/latest/**` is the only target guaranteed
|
|
42
|
+
* to always be real content, release after release.
|
|
43
|
+
*
|
|
44
|
+
* `from`'s wildcards are filled in, in the order they appear, with the
|
|
45
|
+
* segment(s) `to`'s matching wildcards captured — so a rule's `from` and
|
|
46
|
+
* `to` must carry the same number of wildcards, checked once per rule at
|
|
47
|
+
* registration time, not per match.
|
|
48
|
+
*
|
|
49
|
+
* Rules are tried in authoring order and are first-match-wins: once a
|
|
50
|
+
* legacy URL has been claimed by an earlier rule, a later rule computing
|
|
51
|
+
* the same legacy URL is silently skipped — this is the intended mechanism
|
|
52
|
+
* for layering a handful of exact overrides ahead of one broad `**`
|
|
53
|
+
* catch-all, not a bug. A rule whose computed legacy URL collides with a
|
|
54
|
+
* REAL page's own URL is different — that's never silently allowed, since
|
|
55
|
+
* it would mean overwriting real content with a redirect stub.
|
|
56
|
+
*
|
|
57
|
+
* A rule that matches zero real pages warns (and, under this site's
|
|
58
|
+
* `runtime.log.failure_level: warn`, fails the build) — the same treatment
|
|
59
|
+
* as a broken xref — rather than silently shipping a rule that never does
|
|
60
|
+
* anything, which is how a typo, or a `to` accidentally naming an
|
|
61
|
+
* alias-only segment, would otherwise go unnoticed.
|
|
62
|
+
*
|
|
63
|
+
* Reuses @antora/redirect-producer, the same library `urls.
|
|
64
|
+
* latest_version_segment` and the built-in `page-aliases` attribute are
|
|
65
|
+
* built on, so whatever `redirect_facility` the playbook is configured with
|
|
66
|
+
* (static — the default, and what this site and any plain static host like
|
|
67
|
+
* GitHub Pages need — or nginx/httpd/netlify/gitlab) gets the right output
|
|
68
|
+
* format for these rules too, for free.
|
|
69
|
+
*/
|
|
70
|
+
module.exports = function registerRedirects(context, rules) {
|
|
71
|
+
const logger = context.getLogger('docouture-redirects')
|
|
72
|
+
if (rules === undefined) return
|
|
73
|
+
if (!Array.isArray(rules)) {
|
|
74
|
+
logger.warn('Ignoring redirects extension config: expected a list of {from, to} rules, got %s', typeof rules)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const compiled = compileRules(rules, logger)
|
|
79
|
+
if (!compiled.length) return
|
|
80
|
+
|
|
81
|
+
context.on('navigationBuilt', ({ contentCatalog, siteCatalog, playbook }) => {
|
|
82
|
+
const htmlExtensionStyle = playbook.urls?.htmlExtensionStyle
|
|
83
|
+
const pages = contentCatalog.getPages((page) => page.out)
|
|
84
|
+
// Trailing-slash-normalized: a real page's own pub.url and a computed
|
|
85
|
+
// alias's pub.url are both built from this same html_extension_style,
|
|
86
|
+
// so in a real build they already agree — normalizing here is just
|
|
87
|
+
// cheap insurance, not a workaround for a real discrepancy.
|
|
88
|
+
const realUrls = new Set(pages.map((page) => stripTrailingSlash(page.pub.url)))
|
|
89
|
+
const aliasFiles = []
|
|
90
|
+
|
|
91
|
+
// Raw match count per rule, computed independently of precedence below
|
|
92
|
+
// — a rule that never matches ANY real page is worth flagging even when
|
|
93
|
+
// every page it WOULD have matched was going to lose to an earlier,
|
|
94
|
+
// higher-priority rule anyway (so it can't be derived from the
|
|
95
|
+
// precedence loop, which stops looking at a page once an earlier rule
|
|
96
|
+
// already claims it).
|
|
97
|
+
const rawMatchCounts = compiled.map((rule) => pages.filter((page) => rule.toRegex.test(page.pub.url)).length)
|
|
98
|
+
|
|
99
|
+
for (const page of pages) {
|
|
100
|
+
// First rule (in authoring order) whose `to` matches this page wins —
|
|
101
|
+
// a later rule (e.g. a broad `**` catch-all) never gets a say once an
|
|
102
|
+
// earlier, more specific rule already claims the same real page, even
|
|
103
|
+
// if it would have computed a different legacy URL.
|
|
104
|
+
for (const rule of compiled) {
|
|
105
|
+
const match = rule.toRegex.exec(page.pub.url)
|
|
106
|
+
if (!match) continue
|
|
107
|
+
|
|
108
|
+
const legacyPath = substitute(rule.from, match.slice(1))
|
|
109
|
+
const { pubUrl, outPath } = buildUrlAndOutPath(legacyPath, htmlExtensionStyle)
|
|
110
|
+
|
|
111
|
+
if (realUrls.has(stripTrailingSlash(pubUrl))) {
|
|
112
|
+
logger.warn(
|
|
113
|
+
"Skipping redirects rule (from: '%s', to: '%s'): computed legacy URL %s collides with a real page",
|
|
114
|
+
rule.from,
|
|
115
|
+
rule.to,
|
|
116
|
+
pubUrl
|
|
117
|
+
)
|
|
118
|
+
} else {
|
|
119
|
+
aliasFiles.push({ out: { path: outPath }, pub: { url: pubUrl }, rel: { pub: { url: page.pub.url } } })
|
|
120
|
+
}
|
|
121
|
+
break // this page is claimed (matched or collided) — no lower-priority rule gets to touch it
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
compiled.forEach((rule, i) => {
|
|
126
|
+
if (!rawMatchCounts[i]) {
|
|
127
|
+
logger.warn("redirects rule (from: '%s', to: '%s') matched no real pages", rule.from, rule.to)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (!aliasFiles.length) return
|
|
132
|
+
|
|
133
|
+
// Mirrors what @antora/site-generator's own generate-site.js does with
|
|
134
|
+
// Antora's own alias family: for the 'static' facility (this site's
|
|
135
|
+
// default, and the only one a plain static host like GitHub Pages can
|
|
136
|
+
// use) produceRedirects mutates each file's `contents` in place and
|
|
137
|
+
// returns an empty array; for nginx/httpd/netlify/gitlab it instead
|
|
138
|
+
// returns the one rewrite-rule file to add, having stripped `out` off
|
|
139
|
+
// the (now unpublished as individual files) aliases itself.
|
|
140
|
+
const produced = produceRedirects(playbook, aliasFiles)
|
|
141
|
+
if (produced.length) {
|
|
142
|
+
for (const file of produced) siteCatalog.addFile(file)
|
|
143
|
+
} else {
|
|
144
|
+
for (const file of aliasFiles) if (file.out) siteCatalog.addFile(file)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function compileRules(rules, logger) {
|
|
150
|
+
const compiled = []
|
|
151
|
+
for (const rule of rules) {
|
|
152
|
+
if (!rule || typeof rule.from !== 'string' || typeof rule.to !== 'string') {
|
|
153
|
+
logger.warn('Ignoring malformed redirects rule: %s', JSON.stringify(rule))
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const { regex, wildcardCount } = compilePattern(rule.to)
|
|
157
|
+
const fromWildcardCount = countWildcards(rule.from)
|
|
158
|
+
if (fromWildcardCount !== wildcardCount) {
|
|
159
|
+
logger.warn(
|
|
160
|
+
"Ignoring redirects rule (from: '%s', to: '%s'): wildcard count mismatch (%s vs %s)",
|
|
161
|
+
rule.from,
|
|
162
|
+
rule.to,
|
|
163
|
+
fromWildcardCount,
|
|
164
|
+
wildcardCount
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
compiled.push({ from: rule.from, to: rule.to, toRegex: regex })
|
|
169
|
+
}
|
|
170
|
+
return compiled
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Compiles a literal URL template into a matching RegExp: '*' captures one
|
|
174
|
+
// path segment, '**' captures everything remaining (including slashes).
|
|
175
|
+
// Longest-token-first so '**' is never mistaken for two '*' matches.
|
|
176
|
+
function compilePattern(template) {
|
|
177
|
+
let pattern = ''
|
|
178
|
+
let wildcardCount = 0
|
|
179
|
+
let i = 0
|
|
180
|
+
while (i < template.length) {
|
|
181
|
+
if (template[i] === '*') {
|
|
182
|
+
if (template[i + 1] === '*') {
|
|
183
|
+
pattern += '(.+)'
|
|
184
|
+
i += 2
|
|
185
|
+
} else {
|
|
186
|
+
pattern += '([^/]+)'
|
|
187
|
+
i += 1
|
|
188
|
+
}
|
|
189
|
+
wildcardCount++
|
|
190
|
+
} else {
|
|
191
|
+
pattern += escapeRegExp(template[i])
|
|
192
|
+
i += 1
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { regex: new RegExp('^' + pattern + '$'), wildcardCount }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function countWildcards(template) {
|
|
199
|
+
let count = 0
|
|
200
|
+
let i = 0
|
|
201
|
+
while (i < template.length) {
|
|
202
|
+
if (template[i] === '*') {
|
|
203
|
+
count++
|
|
204
|
+
i += template[i + 1] === '*' ? 2 : 1
|
|
205
|
+
} else {
|
|
206
|
+
i += 1
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return count
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Fills a template's wildcards, in the order they appear, with the given
|
|
213
|
+
// captured segments — the counterpart to compilePattern's capture order.
|
|
214
|
+
function substitute(template, captures) {
|
|
215
|
+
let result = ''
|
|
216
|
+
let captureIdx = 0
|
|
217
|
+
let i = 0
|
|
218
|
+
while (i < template.length) {
|
|
219
|
+
if (template[i] === '*') {
|
|
220
|
+
result += captures[captureIdx++]
|
|
221
|
+
i += template[i + 1] === '*' ? 2 : 1
|
|
222
|
+
} else {
|
|
223
|
+
result += template[i]
|
|
224
|
+
i += 1
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return result
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function escapeRegExp(char) {
|
|
231
|
+
return /[.*+?^${}()|[\]\\]/.test(char) ? '\\' + char : char
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Mirrors @antora/content-classifier's own URL/out-path computation for the
|
|
235
|
+
// two html_extension_style values that matter here — 'indexify' (this
|
|
236
|
+
// site's own setting) and Antora's 'default' — since a synthetic redirect
|
|
237
|
+
// stub gets no calculatePub pass of its own; it has to be built by hand
|
|
238
|
+
// to match the shape everything else on the site already uses.
|
|
239
|
+
function buildUrlAndOutPath(literalPath, htmlExtensionStyle) {
|
|
240
|
+
const trimmed = literalPath.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
241
|
+
if (htmlExtensionStyle === 'indexify') {
|
|
242
|
+
return { pubUrl: '/' + trimmed + '/', outPath: trimmed + '/index.html' }
|
|
243
|
+
}
|
|
244
|
+
return { pubUrl: '/' + trimmed + '.html', outPath: trimmed + '.html' }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function stripTrailingSlash(url) {
|
|
248
|
+
return url.endsWith('/') ? url.slice(0, -1) : url
|
|
249
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const registerRedirects = require('./redirects')
|
|
6
|
+
|
|
7
|
+
function createContext(logger = { warn: vi.fn(), info: () => {} }) {
|
|
8
|
+
const listeners = {}
|
|
9
|
+
return {
|
|
10
|
+
logger,
|
|
11
|
+
getLogger: () => logger,
|
|
12
|
+
on(event, fn) {
|
|
13
|
+
;(listeners[event] ||= []).push(fn)
|
|
14
|
+
},
|
|
15
|
+
async emit(event, payload) {
|
|
16
|
+
for (const fn of listeners[event] || []) await fn(payload)
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createPage(url) {
|
|
22
|
+
return { out: true, pub: { url } }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function warnedAbout(logger, text) {
|
|
26
|
+
return logger.warn.mock.calls.some((call) => typeof call[0] === 'string' && call[0].includes(text))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function run(rules, { pages, htmlExtensionStyle = 'indexify', logger } = {}) {
|
|
30
|
+
const context = createContext(logger)
|
|
31
|
+
registerRedirects(context, rules)
|
|
32
|
+
|
|
33
|
+
const contentCatalog = { getPages: (filterFn) => pages.filter(filterFn) }
|
|
34
|
+
const files = []
|
|
35
|
+
const siteCatalog = { addFile: (f) => files.push(f) }
|
|
36
|
+
const playbook = { site: {}, urls: { htmlExtensionStyle, redirectFacility: 'static' } }
|
|
37
|
+
|
|
38
|
+
await context.emit('navigationBuilt', { contentCatalog, siteCatalog, playbook })
|
|
39
|
+
return { files, logger: context.logger }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('registerRedirects', () => {
|
|
43
|
+
it('redirects an exact legacy URL to the real page it now lives at', async () => {
|
|
44
|
+
const { files } = await run([{ from: '/weavejs/docs/main', to: '/weavejs/latest/main' }], {
|
|
45
|
+
pages: [createPage('/weavejs/latest/main')],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(files).toHaveLength(1)
|
|
49
|
+
const [file] = files
|
|
50
|
+
expect(file.pub.url).toBe('/weavejs/docs/main/')
|
|
51
|
+
expect(file.out.path).toBe('weavejs/docs/main/index.html')
|
|
52
|
+
expect(file.contents.toString()).toContain('location="../../latest/main"')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('carries a single-segment wildcard through from `to` into `from`', async () => {
|
|
56
|
+
const { files } = await run([{ from: '/weavejs/docs/main/*', to: '/weavejs/latest/main/*' }], {
|
|
57
|
+
pages: [createPage('/weavejs/latest/main/quickstart'), createPage('/weavejs/latest/sdk/index')],
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
expect(files).toHaveLength(1)
|
|
61
|
+
expect(files[0].pub.url).toBe('/weavejs/docs/main/quickstart/')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('carries a multi-segment ** wildcard through unchanged', async () => {
|
|
65
|
+
const { files } = await run([{ from: '/weavejs/docs/**', to: '/weavejs/latest/**' }], {
|
|
66
|
+
pages: [
|
|
67
|
+
createPage('/weavejs/latest/main/build/nodes/comment/'),
|
|
68
|
+
createPage('/weavejs/prerelease/main/build/nodes/comment/'), // different version, should not match
|
|
69
|
+
],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
expect(files).toHaveLength(1)
|
|
73
|
+
expect(files[0].pub.url).toBe('/weavejs/docs/main/build/nodes/comment/')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('applies an exact override ahead of a broader ** catch-all, first-match-wins', async () => {
|
|
77
|
+
const { files } = await run(
|
|
78
|
+
[
|
|
79
|
+
{ from: '/weavejs/docs/main/build/node/comment', to: '/weavejs/latest/main/build/nodes/comment' },
|
|
80
|
+
{ from: '/weavejs/docs/**', to: '/weavejs/latest/**' },
|
|
81
|
+
],
|
|
82
|
+
{ pages: [createPage('/weavejs/latest/main/build/nodes/comment')] }
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// Both rules match the same real page; only the first rule's computed
|
|
86
|
+
// legacy URL should win — the catch-all's own (different) legacy URL
|
|
87
|
+
// for the same page must not also be emitted.
|
|
88
|
+
expect(files).toHaveLength(1)
|
|
89
|
+
expect(files[0].pub.url).toBe('/weavejs/docs/main/build/node/comment/')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('warns and skips a rule whose from/to wildcard counts do not match', async () => {
|
|
93
|
+
const logger = { warn: vi.fn(), info: () => {} }
|
|
94
|
+
const { files } = await run([{ from: '/weavejs/docs/*/*', to: '/weavejs/latest/**' }], {
|
|
95
|
+
pages: [createPage('/weavejs/latest/main/quickstart/')],
|
|
96
|
+
logger,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
expect(files).toHaveLength(0)
|
|
100
|
+
expect(warnedAbout(logger, 'wildcard count mismatch')).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('warns when a rule matches no real pages', async () => {
|
|
104
|
+
const logger = { warn: vi.fn(), info: () => {} }
|
|
105
|
+
const { files } = await run([{ from: '/weavejs/docs/main', to: '/weavejs/stable/main' }], {
|
|
106
|
+
pages: [createPage('/weavejs/latest/main/')],
|
|
107
|
+
logger,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
expect(files).toHaveLength(0)
|
|
111
|
+
expect(warnedAbout(logger, 'matched no real pages')).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('warns and skips when a computed legacy URL collides with a real page', async () => {
|
|
115
|
+
const logger = { warn: vi.fn(), info: () => {} }
|
|
116
|
+
const { files } = await run([{ from: '/weavejs/latest/main', to: '/weavejs/docs/main' }], {
|
|
117
|
+
pages: [createPage('/weavejs/docs/main'), createPage('/weavejs/latest/main')],
|
|
118
|
+
logger,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(files).toHaveLength(0)
|
|
122
|
+
expect(warnedAbout(logger, 'collides with a real page')).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('does nothing when no redirects config is provided', async () => {
|
|
126
|
+
const { files } = await run(undefined, { pages: [] })
|
|
127
|
+
expect(files).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// A page ID always names an AsciiDoc source file, so that extension is what
|
|
4
|
+
// tells the two forms apart — not the presence of a colon, which both use:
|
|
5
|
+
//
|
|
6
|
+
// ROOT:index.adoc page ID (module-qualified)
|
|
7
|
+
// weavejs:sdk:index.adoc page ID (component-qualified)
|
|
8
|
+
// https://example.com URL
|
|
9
|
+
// mailto:docs@example URL
|
|
10
|
+
// /weavejs/index.html URL (root-relative)
|
|
11
|
+
//
|
|
12
|
+
// Matching on "looks like a URI scheme" instead is what a first cut of this
|
|
13
|
+
// did, and it is wrong: `ROOT:` is a perfectly good scheme as far as a
|
|
14
|
+
// case-insensitive pattern is concerned, so every module-qualified page ID
|
|
15
|
+
// written in the conventional uppercase ROOT sailed through unresolved and
|
|
16
|
+
// rendered as a literal href.
|
|
17
|
+
const PAGE_ID_RX = /\.adoc$/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve an authored link target to a URL.
|
|
21
|
+
*
|
|
22
|
+
* Page IDs are resolved against `context` — a component/version/module triple
|
|
23
|
+
* — so a bare `index.adoc` or a module-qualified `sdk:index.adoc` both work
|
|
24
|
+
* from a component descriptor, the same way they would from a page in that
|
|
25
|
+
* component's ROOT module. A trailing `#fragment` is kept and re-attached to
|
|
26
|
+
* the resolved URL; Antora's own resolvePage does not take one.
|
|
27
|
+
*
|
|
28
|
+
* Returns undefined for a page ID that resolves to nothing, so the caller can
|
|
29
|
+
* drop the link rather than render one that goes nowhere. Anything that is
|
|
30
|
+
* not a page ID is returned unchanged.
|
|
31
|
+
*
|
|
32
|
+
* @param {String} spec - a page ID or a literal URL
|
|
33
|
+
* @param {Object} contentCatalog - Antora's content catalog
|
|
34
|
+
* @param {Object} context - { component, version, module } to resolve against
|
|
35
|
+
* @returns {String|undefined}
|
|
36
|
+
*/
|
|
37
|
+
module.exports = function resolveUrl(spec, contentCatalog, context) {
|
|
38
|
+
if (typeof spec !== 'string' || !spec) return undefined
|
|
39
|
+
const hashIdx = spec.indexOf('#')
|
|
40
|
+
const target = hashIdx === -1 ? spec : spec.slice(0, hashIdx)
|
|
41
|
+
const hash = hashIdx === -1 ? '' : spec.slice(hashIdx)
|
|
42
|
+
if (!PAGE_ID_RX.test(target)) return spec
|
|
43
|
+
const url = contentCatalog.resolvePage(target, context)?.pub?.url
|
|
44
|
+
return url ? url + hash : undefined
|
|
45
|
+
}
|