@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.
@@ -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
+ }
@@ -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
+ }