Skip to content

Commit 283155e

Browse files
authored
fix(build): redirect the legacy /next/ URLs instead of 404ing (#112)
1 parent f5b4e7a commit 283155e

10 files changed

Lines changed: 541 additions & 67 deletions

File tree

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Antora + AsciiDoc are kept (native multi-version/multi-component support).
2929
```
3030
site.yml Antora playbook (local content only)
3131
package.json antora + asciidoctor + pagefind toolchain
32-
antora-extensions/ comp-version, sitemap-cleanup, global-attributes loader
32+
antora-extensions/ comp-version, latest/next-alias, sitemap-cleanup, global-attributes loader
3333
asciidoc-extensions/ tabs, remote-include
3434
global-attributes.yml site-wide AsciiDoc attributes (local)
3535
ui/supplemental/ branding + Pagefind modal search on the stock UI
@@ -71,10 +71,16 @@ be added:
7171
- **The version is legible everywhere it matters** — folder, path, PR diff, and
7272
URL. A reviewer reads `content/ocis/8.2/…` in a diff and knows the target
7373
version without consulting a branch→version mapping.
74-
- **`latest` is generated, never a source folder.**
74+
- **`latest` and `next` are generated, never source folders.**
7575
`antora-extensions/latest-alias.js` publishes `/<product>/latest/` as a tree of
7676
redirect stubs pointing at the newest non-prerelease version; `site.yml`
7777
deliberately does not set `latest_version_segment`.
78+
`antora-extensions/next-alias.js` does the same for `/<product>/next/`, the
79+
version segment the legacy site used for each product's `master` build — those
80+
URLs are still linked and indexed, so they redirect to the closest live page
81+
instead of 404ing. Its target is the component's prerelease version, falling
82+
back to the latest release (and, per page, to the latest release for anything
83+
the dev line dropped). Both trees are `noindex` and stay out of the sitemap.
7884

7985
See the dev-version note under [Versions imported](#versions-imported) for what
8086
moves together on release rollover.
@@ -121,11 +127,14 @@ Three bits of bookkeeping remain:
121127

122128
1. Update the hand-maintained `latest-*` / `previous-*` / `current-*` attributes
123129
in `global-attributes.yml` if the removed version appeared in them. The
124-
`latest` alias itself moves automatically (`latest-alias.js` derives it from
125-
the newest non-prerelease version).
130+
`latest` and `next` aliases themselves move automatically (`latest-alias.js`
131+
derives its target from the newest non-prerelease version, `next-alias.js` from
132+
the `prerelease` flag).
126133
2. **Server only:** drop the segment from `PUBLISHED_VERSIONS` in
127134
`ui/supplemental/js/go-redirect.js`; `test/go-redirect.test.js` fails the build
128-
if that list drifts from the published `public/server/*` trees. Legacy
135+
if that list drifts from the published `public/server/*` trees. Only real
136+
version numbers are maintained there — `latest` and `next` are permanent
137+
entries, because they are generated redirect trees rather than versions. Legacy
129138
`go.php?to=` links for the removed version then fall back to `latest`, which is
130139
the intended safety net.
131140
3. Accept that the version's URLs now 404 — nothing redirects a retired version
@@ -156,6 +165,9 @@ Three bits of bookkeeping remain:
156165
> are pinned deliberately, because `/latest/` is a `noindex` redirect stub).
157166
> `test/static-files.test.js` fails the build while any of the three disagree.
158167
>
168+
> Dropping `prerelease` also moves `/<product>/next/` on to the newly opened dev
169+
> line by itself — `next-alias.js` reads the flag, so there is nothing to bump.
170+
>
159171
> Then open the next dev line by copying the released folder to its new number and
160172
> re-adding the two keys. **Server only:** that copy publishes a new
161173
> `public/server/<version>/` tree, so add the segment to `PUBLISHED_VERSIONS` in

agents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ This is the consolidated documentation **monorepo**. It supersedes the previous
1616

1717
- `site.yml` -- Antora playbook; all content sources are local
1818
- `content/<product>/<version>/` -- documentation content; products are `main`, `server`, `webui`, `ocis`, `desktop`, `android`, `ios`
19-
- `antora-extensions/` -- custom Antora extensions (`comp-version`, `latest-alias`, `sitemap-cleanup`, `load-global-site-attributes`)
19+
- `antora-extensions/` -- custom Antora extensions (`comp-version`, `latest-alias`, `next-alias`, `sitemap-cleanup`, `load-global-site-attributes`)
2020
- `asciidoc-extensions/` -- custom AsciiDoc extensions (`tabs`, `remote-include-processor`)
2121
- `ui/supplemental/` -- supplemental files layered onto the stock Antora default UI
2222
- `global-attributes.yml` -- site-wide AsciiDoc attributes

antora-extensions/latest-alias.js

Lines changed: 11 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
* hides the real version), so we add the alias files ourselves. Under
1717
* redirect_facility: static each alias renders as a <meta http-equiv="refresh">
1818
* stub, so both work on GitHub Pages.
19+
*
20+
* The mirroring itself lives in lib/alias-tree.js, shared with next-alias.js.
1921
*/
22+
const { mirrorPages, mirrorMoveRedirects } = require('./lib/alias-tree')
23+
2024
module.exports.register = function () {
2125
const LATEST = 'latest'
2226

@@ -63,60 +67,18 @@ module.exports.register = function () {
6367
}
6468

6569
// (1) latest/** redirect tree mirroring every published page.
66-
contentCatalog
67-
.findBy({ component: component.name, version: latest.version, family: 'page' })
68-
.forEach((page) => {
69-
// Only real published pages are valid redirect targets. AsciiDoc
70-
// partials (_*.adoc include fragments) are in the page family but have
71-
// no pub/out; aliasing them makes @antora/redirect-producer throw
72-
// "Cannot read properties of undefined (reading 'url')". Guard on both.
73-
if (!page.pub || !page.pub.url || !page.out) return
74-
contentCatalog.addFile({
75-
src: {
76-
component: component.name,
77-
version: LATEST,
78-
module: page.src.module,
79-
family: 'alias',
80-
relative: page.src.relative,
81-
},
82-
rel: page,
83-
})
84-
})
70+
mirrorPages(contentCatalog, component, latest.version, LATEST)
8571
})
8672
})
8773

88-
// (3) Mirror the latest version's own move-redirects into the `latest` tree.
89-
// Antora registers the redirect stubs declared via `page-aliases` while it
90-
// converts documents, so they do not exist yet at contentClassified and pass
91-
// (1) by unnoticed -- they are in the `alias` family, not `page`. Without this
92-
// pass, an old page path that survives in the latest version only as a
93-
// redirect (e.g. a page renamed in server 11.0) resolves under the real
94-
// version but 404s under /<component>/latest/. The legacy go.php short links
95-
// (ui/supplemental/js/go-redirect.js) are keyed on those older page paths and
96-
// fall back to /server/latest/ for any unpublished version, so they depend on
97-
// the redirects being mirrored here.
74+
// (3) Mirror the latest version's own move-redirects into the `latest` tree, so
75+
// an old page path that survives in the latest version only as a `page-aliases`
76+
// redirect keeps resolving under /<component>/latest/ too. Those stubs do not
77+
// exist yet at contentClassified, hence the second hook -- see
78+
// mirrorMoveRedirects() in lib/alias-tree.js.
9879
this.once('documentsConverted', ({ contentCatalog }) => {
9980
eachAliasableComponent(contentCatalog, (component, latest) => {
100-
contentCatalog
101-
.findBy({ component: component.name, version: latest.version, family: 'alias' })
102-
.forEach((alias) => {
103-
// Chain the mirror straight to the redirect's ultimate target instead
104-
// of to the redirect itself: one hop from /latest/ to real content,
105-
// and `rel` must be a publishable page for the redirect producer.
106-
const target = alias.rel
107-
if (!target || !target.pub || !target.pub.url || !target.out) return
108-
const src = {
109-
component: component.name,
110-
version: LATEST,
111-
module: alias.src.module,
112-
family: 'alias',
113-
relative: alias.src.relative,
114-
}
115-
// (1) already claimed this path if the latest version publishes a real
116-
// page there; re-adding it would replace a live page with a redirect.
117-
if (contentCatalog.getById(src)) return
118-
contentCatalog.addFile({ src, rel: target })
119-
})
81+
mirrorMoveRedirects(contentCatalog, component, latest.version, LATEST)
12082
})
12183
})
12284
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use strict'
2+
3+
/**
4+
* Shared catalog mechanics for publishing an alias version segment -- a segment
5+
* that is not a real version folder but a tree of redirect stubs mirroring one
6+
* (`latest`, `next`). Used by antora-extensions/latest-alias.js and
7+
* antora-extensions/next-alias.js: only the segment name and the source version
8+
* differ between them, while the two Antora quirks that make the mirroring work
9+
* are subtle enough to be worth stating once.
10+
*
11+
* Under `redirect_facility: static` (see site.yml) every file added to the `alias`
12+
* family renders as a <meta http-equiv="refresh"> stub, which is what makes such a
13+
* segment work on a static host like GitHub Pages.
14+
*/
15+
16+
/**
17+
* Mirror every published page of `sourceVersion` into `segment` as a redirect.
18+
*
19+
* @param {Object} contentCatalog Antora's content catalog
20+
* @param {Object} component the component to mirror within
21+
* @param {String} sourceVersion the real version to point at ('' for versionless)
22+
* @param {String} segment the alias version segment to publish under
23+
*/
24+
function mirrorPages (contentCatalog, component, sourceVersion, segment) {
25+
contentCatalog
26+
.findBy({ component: component.name, version: sourceVersion, family: 'page' })
27+
.forEach((page) => {
28+
// Only real published pages are valid redirect targets. AsciiDoc partials
29+
// (_*.adoc include fragments) are in the page family but have no pub/out;
30+
// aliasing them makes @antora/redirect-producer throw "Cannot read
31+
// properties of undefined (reading 'url')". Guard on both.
32+
if (!page.pub || !page.pub.url || !page.out) return
33+
addAlias(contentCatalog, component, segment, page.src.module, page.src.relative, page)
34+
})
35+
}
36+
37+
/**
38+
* Mirror the move-redirects of `sourceVersion` -- the stubs Antora registers for
39+
* `page-aliases` attributes -- into `segment`.
40+
*
41+
* Antora registers those stubs while it converts documents, so at
42+
* contentClassified they do not exist yet and mirrorPages() cannot see them: they
43+
* are in the `alias` family, not `page`. Without this pass, an old page path that
44+
* survives in the source version only as a redirect (e.g. a page renamed in server
45+
* 11.0) resolves under the real version but 404s under the alias segment. The
46+
* legacy go.php short links (ui/supplemental/js/go-redirect.js) are keyed on those
47+
* older page paths and resolve inside these trees, so they depend on the redirects
48+
* being mirrored here.
49+
*
50+
* Call this from a `documentsConverted` listener, never earlier.
51+
*
52+
* @param {Object} contentCatalog Antora's content catalog
53+
* @param {Object} component the component to mirror within
54+
* @param {String} sourceVersion the real version whose redirects to mirror
55+
* @param {String} segment the alias version segment to publish under
56+
*/
57+
function mirrorMoveRedirects (contentCatalog, component, sourceVersion, segment) {
58+
contentCatalog
59+
.findBy({ component: component.name, version: sourceVersion, family: 'alias' })
60+
.forEach((alias) => {
61+
// Chain the mirror straight to the redirect's ultimate target instead of to
62+
// the redirect itself: one hop from the alias segment to real content, and
63+
// `rel` must be a publishable page for the redirect producer.
64+
const target = alias.rel
65+
if (!target || !target.pub || !target.pub.url || !target.out) return
66+
addAlias(contentCatalog, component, segment, alias.src.module, alias.src.relative, target)
67+
})
68+
}
69+
70+
/**
71+
* Add one redirect stub at <component>/<segment>/<module>/<relative>.
72+
*
73+
* A path already claimed in `segment` is left untouched, so the first caller wins:
74+
* re-adding it would replace a live page with a redirect, or a closer redirect
75+
* target with a more distant one. next-alias.js depends on this to layer a release
76+
* version underneath a prerelease one without overwriting it.
77+
*/
78+
function addAlias (contentCatalog, component, segment, module, relative, target) {
79+
const src = { component: component.name, version: segment, module, family: 'alias', relative }
80+
if (contentCatalog.getById(src)) return
81+
contentCatalog.addFile({ src, rel: target })
82+
}
83+
84+
module.exports = { mirrorPages, mirrorMoveRedirects }

antora-extensions/next-alias.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use strict'
2+
3+
/**
4+
* Publish a `next` version segment as a tree of redirect stubs, so the legacy
5+
* `…/next/…` documentation URLs keep resolving.
6+
*
7+
* On the pre-monorepo site `next` was the build of each product's upstream
8+
* `master` branch (sync/manifest.yml still records those mappings). This repo
9+
* publishes real version numbers only -- there is deliberately no `next`, `dev` or
10+
* `master` folder (see README, "Only explicit version numbers as folder names") --
11+
* so every inbound `/<product>/next/<path>` link and every indexed `next` page
12+
* dead-ended after the cutover.
13+
*
14+
* The successor of `master` is the in-development line, which lives at its real
15+
* number marked `prerelease: true`. So per component the `next` segment mirrors:
16+
*
17+
* 1. the newest prerelease version, if the component has one
18+
* (ocis -> 8.3, desktop -> 7.2, android -> 4.8), else its latest release;
19+
* 2. plus, where (1) is a prerelease, the latest RELEASE version as a fallback
20+
* layer, so a page that the dev line dropped still lands on live content
21+
* instead of a 404. mirrorPages() never overwrites a claimed path, so the
22+
* prerelease always wins where both have the page.
23+
*
24+
* Every stub therefore points one hop at a real version, never at /latest/.
25+
*
26+
* This mirrors what antora-extensions/latest-alias.js does for `latest`; the
27+
* catalog mechanics are shared in lib/alias-tree.js. As there, the tree only
28+
* works on a static host because `redirect_facility: static` (site.yml) renders
29+
* each alias as a <meta http-equiv="refresh"> stub.
30+
*/
31+
const { mirrorPages, mirrorMoveRedirects } = require('./lib/alias-tree')
32+
33+
module.exports.register = function () {
34+
const NEXT = 'next'
35+
36+
// Antora exposes the newest prerelease as component.latestPrerelease; the scan
37+
// of component.versions (sorted newest first) is a fallback so a change in that
38+
// property cannot silently turn every `next` stub into a `latest` duplicate.
39+
const newestPrerelease = (component) =>
40+
component.latestPrerelease || component.versions.find((version) => version.prerelease)
41+
42+
this.once('documentsConverted', ({ contentCatalog }) => {
43+
contentCatalog.getComponents().forEach((component) => {
44+
// The ROOT component is the versionless site landing page, published at the
45+
// site root: legacy docs-main had no `next` segment either, so a /next/ tree
46+
// here would invent URLs rather than rescue them. Every other component --
47+
// including the versionless `webui`, whose legacy URLs were ALL /webui/next/**
48+
// because docs-webui only ever had master -- gets one.
49+
if (component.name === 'ROOT') return
50+
51+
const latest = component.latest
52+
const target = newestPrerelease(component) || latest
53+
// Nothing to alias: no versions at all, or a real version already named
54+
// `next` (there is none today -- this guards a future folder from being
55+
// shadowed by its own redirect tree).
56+
if (!target || target.version === NEXT) return
57+
58+
mirrorPages(contentCatalog, component, target.version, NEXT)
59+
mirrorMoveRedirects(contentCatalog, component, target.version, NEXT)
60+
61+
// Fallback layer (2). Skipped when the target IS the latest release, which
62+
// is also the versionless case (webui: both are version '').
63+
if (latest && latest.version !== target.version) {
64+
mirrorPages(contentCatalog, component, latest.version, NEXT)
65+
mirrorMoveRedirects(contentCatalog, component, latest.version, NEXT)
66+
}
67+
})
68+
})
69+
}

site.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ urls:
3737
# antora-extensions/latest-alias.js publishes a `latest` segment as a tree of
3838
# static meta-refresh redirect stubs pointing at each component's latest
3939
# non-prerelease version -- explicit real URLs AND a working /…/latest/.
40+
# antora-extensions/next-alias.js publishes a `next` segment the same way, for
41+
# the legacy `…/next/…` URLs (the old per-product `master` builds); it points at
42+
# each component's prerelease version, falling back to the latest release.
4043

4144
runtime:
4245
# local content only -- nothing to fetch
@@ -69,6 +72,7 @@ antora:
6972
extensions:
7073
- ./antora-extensions/comp-version.js
7174
- ./antora-extensions/latest-alias.js
75+
- ./antora-extensions/next-alias.js
7276
- require: ./antora-extensions/load-global-site-attributes.js
7377
attributefile: ./global-attributes.yml
7478
enabled: true

test/go-redirect.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ test('a user key redirects into the classic_ui module', () => {
3838
})
3939

4040
test('a published version segment is preserved for per-version fidelity', () => {
41-
for (const v of ['10.15', '10.16', '11.0']) {
41+
// `latest` and `next` are served redirect trees, so they are preserved too: the
42+
// stub the go.php link lands on carries the reader on to the real version.
43+
for (const v of ['10.15', '10.16', '11.0', 'latest', 'next']) {
4244
const p = `/owncloud-docs-monorepo/server/${v}/`
4345
assert.equal(
4446
resolveGoPhp(p + 'go.php', '?to=admin-sharing'),

0 commit comments

Comments
 (0)