From e339cbff8a37dc3671fd85ac8a6de67a3089dfc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <169163068+ThomasMuellerKiteworks@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:27:23 +0200 Subject: [PATCH] fix(build): redirect the legacy /next/ URLs instead of 404ing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the pre-monorepo site `next` was the version segment of each product's upstream `master` build. This repo publishes real version numbers only, so every inbound `…/next/…` link and every indexed `next` page dead-ended after the cutover -- the SEO problem reported in #111. The issue suggests mapping /next/* -> /dev/*, but no /dev/ path exists or should: the in-development line already lives at its real number marked `prerelease: true`. So `next` now resolves to that prerelease version per component (ocis -> 8.3, desktop -> 7.2, android -> 4.8), falling back to the latest release where a product has no dev line (server -> 11.0, ios-app -> 12.7) and, per page, to the latest release for anything the dev line dropped. antora-extensions/next-alias.js publishes the tree the same way latest-alias.js publishes `latest`: static meta-refresh stubs, noindex, one hop to real content, kept out of the sitemap and the Pagefind index. The catalog mirroring both extensions need moves to lib/alias-tree.js, so latest-alias.js keeps only what is specific to `latest`. The versionless webui is included -- docs-webui only ever had master, so all of its legacy URLs were /webui/next/**. The ROOT landing component is not: it was versionless on the legacy site too, so a /next/ tree there would invent URLs rather than rescue them. `next` also joins PUBLISHED_VERSIONS in go-redirect.js, both because /server/next/go.php?to= now resolves inside the tree and because the list is asserted against the published public/server/* segments. Nothing has to be maintained on release rollover: the target follows the `prerelease` flag. Co-Authored-By: Claude Opus 5 Signed-off-by: Thomas Müller <169163068+ThomasMuellerKiteworks@users.noreply.github.com> --- README.md | 22 ++- agents.md | 2 +- antora-extensions/latest-alias.js | 60 ++---- antora-extensions/lib/alias-tree.js | 84 ++++++++ antora-extensions/next-alias.js | 69 +++++++ site.yml | 4 + test/go-redirect.test.js | 4 +- test/helpers/latest-versions.js | 59 +++++- test/next-alias.test.js | 292 ++++++++++++++++++++++++++++ ui/supplemental/js/go-redirect.js | 12 +- 10 files changed, 541 insertions(+), 67 deletions(-) create mode 100644 antora-extensions/lib/alias-tree.js create mode 100644 antora-extensions/next-alias.js create mode 100644 test/next-alias.test.js diff --git a/README.md b/README.md index 7c509ec..66d504c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Antora + AsciiDoc are kept (native multi-version/multi-component support). ``` site.yml Antora playbook (local content only) package.json antora + asciidoctor + pagefind toolchain -antora-extensions/ comp-version, sitemap-cleanup, global-attributes loader +antora-extensions/ comp-version, latest/next-alias, sitemap-cleanup, global-attributes loader asciidoc-extensions/ tabs, remote-include global-attributes.yml site-wide AsciiDoc attributes (local) ui/supplemental/ branding + Pagefind modal search on the stock UI @@ -71,10 +71,16 @@ be added: - **The version is legible everywhere it matters** — folder, path, PR diff, and URL. A reviewer reads `content/ocis/8.2/…` in a diff and knows the target version without consulting a branch→version mapping. -- **`latest` is generated, never a source folder.** +- **`latest` and `next` are generated, never source folders.** `antora-extensions/latest-alias.js` publishes `//latest/` as a tree of redirect stubs pointing at the newest non-prerelease version; `site.yml` deliberately does not set `latest_version_segment`. + `antora-extensions/next-alias.js` does the same for `//next/`, the + version segment the legacy site used for each product's `master` build — those + URLs are still linked and indexed, so they redirect to the closest live page + instead of 404ing. Its target is the component's prerelease version, falling + back to the latest release (and, per page, to the latest release for anything + the dev line dropped). Both trees are `noindex` and stay out of the sitemap. See the dev-version note under [Versions imported](#versions-imported) for what moves together on release rollover. @@ -121,11 +127,14 @@ Three bits of bookkeeping remain: 1. Update the hand-maintained `latest-*` / `previous-*` / `current-*` attributes in `global-attributes.yml` if the removed version appeared in them. The - `latest` alias itself moves automatically (`latest-alias.js` derives it from - the newest non-prerelease version). + `latest` and `next` aliases themselves move automatically (`latest-alias.js` + derives its target from the newest non-prerelease version, `next-alias.js` from + the `prerelease` flag). 2. **Server only:** drop the segment from `PUBLISHED_VERSIONS` in `ui/supplemental/js/go-redirect.js`; `test/go-redirect.test.js` fails the build - if that list drifts from the published `public/server/*` trees. Legacy + if that list drifts from the published `public/server/*` trees. Only real + version numbers are maintained there — `latest` and `next` are permanent + entries, because they are generated redirect trees rather than versions. Legacy `go.php?to=` links for the removed version then fall back to `latest`, which is the intended safety net. 3. Accept that the version's URLs now 404 — nothing redirects a retired version @@ -156,6 +165,9 @@ Three bits of bookkeeping remain: > are pinned deliberately, because `/latest/` is a `noindex` redirect stub). > `test/static-files.test.js` fails the build while any of the three disagree. > +> Dropping `prerelease` also moves `//next/` on to the newly opened dev +> line by itself — `next-alias.js` reads the flag, so there is nothing to bump. +> > Then open the next dev line by copying the released folder to its new number and > re-adding the two keys. **Server only:** that copy publishes a new > `public/server//` tree, so add the segment to `PUBLISHED_VERSIONS` in diff --git a/agents.md b/agents.md index a5dd690..12ebea3 100644 --- a/agents.md +++ b/agents.md @@ -16,7 +16,7 @@ This is the consolidated documentation **monorepo**. It supersedes the previous - `site.yml` -- Antora playbook; all content sources are local - `content///` -- documentation content; products are `main`, `server`, `webui`, `ocis`, `desktop`, `android`, `ios` -- `antora-extensions/` -- custom Antora extensions (`comp-version`, `latest-alias`, `sitemap-cleanup`, `load-global-site-attributes`) +- `antora-extensions/` -- custom Antora extensions (`comp-version`, `latest-alias`, `next-alias`, `sitemap-cleanup`, `load-global-site-attributes`) - `asciidoc-extensions/` -- custom AsciiDoc extensions (`tabs`, `remote-include-processor`) - `ui/supplemental/` -- supplemental files layered onto the stock Antora default UI - `global-attributes.yml` -- site-wide AsciiDoc attributes diff --git a/antora-extensions/latest-alias.js b/antora-extensions/latest-alias.js index dbb7688..6d151f2 100644 --- a/antora-extensions/latest-alias.js +++ b/antora-extensions/latest-alias.js @@ -16,7 +16,11 @@ * hides the real version), so we add the alias files ourselves. Under * redirect_facility: static each alias renders as a * stub, so both work on GitHub Pages. + * + * The mirroring itself lives in lib/alias-tree.js, shared with next-alias.js. */ +const { mirrorPages, mirrorMoveRedirects } = require('./lib/alias-tree') + module.exports.register = function () { const LATEST = 'latest' @@ -63,60 +67,18 @@ module.exports.register = function () { } // (1) latest/** redirect tree mirroring every published page. - contentCatalog - .findBy({ component: component.name, version: latest.version, family: 'page' }) - .forEach((page) => { - // Only real published pages are valid redirect targets. AsciiDoc - // partials (_*.adoc include fragments) are in the page family but have - // no pub/out; aliasing them makes @antora/redirect-producer throw - // "Cannot read properties of undefined (reading 'url')". Guard on both. - if (!page.pub || !page.pub.url || !page.out) return - contentCatalog.addFile({ - src: { - component: component.name, - version: LATEST, - module: page.src.module, - family: 'alias', - relative: page.src.relative, - }, - rel: page, - }) - }) + mirrorPages(contentCatalog, component, latest.version, LATEST) }) }) - // (3) Mirror the latest version's own move-redirects into the `latest` tree. - // Antora registers the redirect stubs declared via `page-aliases` while it - // converts documents, so they do not exist yet at contentClassified and pass - // (1) by unnoticed -- they are in the `alias` family, not `page`. Without this - // pass, an old page path that survives in the latest version only as a - // redirect (e.g. a page renamed in server 11.0) resolves under the real - // version but 404s under //latest/. The legacy go.php short links - // (ui/supplemental/js/go-redirect.js) are keyed on those older page paths and - // fall back to /server/latest/ for any unpublished version, so they depend on - // the redirects being mirrored here. + // (3) Mirror the latest version's own move-redirects into the `latest` tree, so + // an old page path that survives in the latest version only as a `page-aliases` + // redirect keeps resolving under //latest/ too. Those stubs do not + // exist yet at contentClassified, hence the second hook -- see + // mirrorMoveRedirects() in lib/alias-tree.js. this.once('documentsConverted', ({ contentCatalog }) => { eachAliasableComponent(contentCatalog, (component, latest) => { - contentCatalog - .findBy({ component: component.name, version: latest.version, family: 'alias' }) - .forEach((alias) => { - // Chain the mirror straight to the redirect's ultimate target instead - // of to the redirect itself: one hop from /latest/ to real content, - // and `rel` must be a publishable page for the redirect producer. - const target = alias.rel - if (!target || !target.pub || !target.pub.url || !target.out) return - const src = { - component: component.name, - version: LATEST, - module: alias.src.module, - family: 'alias', - relative: alias.src.relative, - } - // (1) already claimed this path if the latest version publishes a real - // page there; re-adding it would replace a live page with a redirect. - if (contentCatalog.getById(src)) return - contentCatalog.addFile({ src, rel: target }) - }) + mirrorMoveRedirects(contentCatalog, component, latest.version, LATEST) }) }) } diff --git a/antora-extensions/lib/alias-tree.js b/antora-extensions/lib/alias-tree.js new file mode 100644 index 0000000..ddd57b9 --- /dev/null +++ b/antora-extensions/lib/alias-tree.js @@ -0,0 +1,84 @@ +'use strict' + +/** + * Shared catalog mechanics for publishing an alias version segment -- a segment + * that is not a real version folder but a tree of redirect stubs mirroring one + * (`latest`, `next`). Used by antora-extensions/latest-alias.js and + * antora-extensions/next-alias.js: only the segment name and the source version + * differ between them, while the two Antora quirks that make the mirroring work + * are subtle enough to be worth stating once. + * + * Under `redirect_facility: static` (see site.yml) every file added to the `alias` + * family renders as a stub, which is what makes such a + * segment work on a static host like GitHub Pages. + */ + +/** + * Mirror every published page of `sourceVersion` into `segment` as a redirect. + * + * @param {Object} contentCatalog Antora's content catalog + * @param {Object} component the component to mirror within + * @param {String} sourceVersion the real version to point at ('' for versionless) + * @param {String} segment the alias version segment to publish under + */ +function mirrorPages (contentCatalog, component, sourceVersion, segment) { + contentCatalog + .findBy({ component: component.name, version: sourceVersion, family: 'page' }) + .forEach((page) => { + // Only real published pages are valid redirect targets. AsciiDoc partials + // (_*.adoc include fragments) are in the page family but have no pub/out; + // aliasing them makes @antora/redirect-producer throw "Cannot read + // properties of undefined (reading 'url')". Guard on both. + if (!page.pub || !page.pub.url || !page.out) return + addAlias(contentCatalog, component, segment, page.src.module, page.src.relative, page) + }) +} + +/** + * Mirror the move-redirects of `sourceVersion` -- the stubs Antora registers for + * `page-aliases` attributes -- into `segment`. + * + * Antora registers those stubs while it converts documents, so at + * contentClassified they do not exist yet and mirrorPages() cannot see them: they + * are in the `alias` family, not `page`. Without this pass, an old page path that + * survives in the source version only as a redirect (e.g. a page renamed in server + * 11.0) resolves under the real version but 404s under the alias segment. The + * legacy go.php short links (ui/supplemental/js/go-redirect.js) are keyed on those + * older page paths and resolve inside these trees, so they depend on the redirects + * being mirrored here. + * + * Call this from a `documentsConverted` listener, never earlier. + * + * @param {Object} contentCatalog Antora's content catalog + * @param {Object} component the component to mirror within + * @param {String} sourceVersion the real version whose redirects to mirror + * @param {String} segment the alias version segment to publish under + */ +function mirrorMoveRedirects (contentCatalog, component, sourceVersion, segment) { + contentCatalog + .findBy({ component: component.name, version: sourceVersion, family: 'alias' }) + .forEach((alias) => { + // Chain the mirror straight to the redirect's ultimate target instead of to + // the redirect itself: one hop from the alias segment to real content, and + // `rel` must be a publishable page for the redirect producer. + const target = alias.rel + if (!target || !target.pub || !target.pub.url || !target.out) return + addAlias(contentCatalog, component, segment, alias.src.module, alias.src.relative, target) + }) +} + +/** + * Add one redirect stub at ///. + * + * A path already claimed in `segment` is left untouched, so the first caller wins: + * re-adding it would replace a live page with a redirect, or a closer redirect + * target with a more distant one. next-alias.js depends on this to layer a release + * version underneath a prerelease one without overwriting it. + */ +function addAlias (contentCatalog, component, segment, module, relative, target) { + const src = { component: component.name, version: segment, module, family: 'alias', relative } + if (contentCatalog.getById(src)) return + contentCatalog.addFile({ src, rel: target }) +} + +module.exports = { mirrorPages, mirrorMoveRedirects } diff --git a/antora-extensions/next-alias.js b/antora-extensions/next-alias.js new file mode 100644 index 0000000..f3e4afd --- /dev/null +++ b/antora-extensions/next-alias.js @@ -0,0 +1,69 @@ +'use strict' + +/** + * Publish a `next` version segment as a tree of redirect stubs, so the legacy + * `…/next/…` documentation URLs keep resolving. + * + * On the pre-monorepo site `next` was the build of each product's upstream + * `master` branch (sync/manifest.yml still records those mappings). This repo + * publishes real version numbers only -- there is deliberately no `next`, `dev` or + * `master` folder (see README, "Only explicit version numbers as folder names") -- + * so every inbound `//next/` link and every indexed `next` page + * dead-ended after the cutover. + * + * The successor of `master` is the in-development line, which lives at its real + * number marked `prerelease: true`. So per component the `next` segment mirrors: + * + * 1. the newest prerelease version, if the component has one + * (ocis -> 8.3, desktop -> 7.2, android -> 4.8), else its latest release; + * 2. plus, where (1) is a prerelease, the latest RELEASE version as a fallback + * layer, so a page that the dev line dropped still lands on live content + * instead of a 404. mirrorPages() never overwrites a claimed path, so the + * prerelease always wins where both have the page. + * + * Every stub therefore points one hop at a real version, never at /latest/. + * + * This mirrors what antora-extensions/latest-alias.js does for `latest`; the + * catalog mechanics are shared in lib/alias-tree.js. As there, the tree only + * works on a static host because `redirect_facility: static` (site.yml) renders + * each alias as a stub. + */ +const { mirrorPages, mirrorMoveRedirects } = require('./lib/alias-tree') + +module.exports.register = function () { + const NEXT = 'next' + + // Antora exposes the newest prerelease as component.latestPrerelease; the scan + // of component.versions (sorted newest first) is a fallback so a change in that + // property cannot silently turn every `next` stub into a `latest` duplicate. + const newestPrerelease = (component) => + component.latestPrerelease || component.versions.find((version) => version.prerelease) + + this.once('documentsConverted', ({ contentCatalog }) => { + contentCatalog.getComponents().forEach((component) => { + // The ROOT component is the versionless site landing page, published at the + // site root: legacy docs-main had no `next` segment either, so a /next/ tree + // here would invent URLs rather than rescue them. Every other component -- + // including the versionless `webui`, whose legacy URLs were ALL /webui/next/** + // because docs-webui only ever had master -- gets one. + if (component.name === 'ROOT') return + + const latest = component.latest + const target = newestPrerelease(component) || latest + // Nothing to alias: no versions at all, or a real version already named + // `next` (there is none today -- this guards a future folder from being + // shadowed by its own redirect tree). + if (!target || target.version === NEXT) return + + mirrorPages(contentCatalog, component, target.version, NEXT) + mirrorMoveRedirects(contentCatalog, component, target.version, NEXT) + + // Fallback layer (2). Skipped when the target IS the latest release, which + // is also the versionless case (webui: both are version ''). + if (latest && latest.version !== target.version) { + mirrorPages(contentCatalog, component, latest.version, NEXT) + mirrorMoveRedirects(contentCatalog, component, latest.version, NEXT) + } + }) + }) +} diff --git a/site.yml b/site.yml index 859e75a..82d4f5f 100644 --- a/site.yml +++ b/site.yml @@ -37,6 +37,9 @@ urls: # antora-extensions/latest-alias.js publishes a `latest` segment as a tree of # static meta-refresh redirect stubs pointing at each component's latest # non-prerelease version -- explicit real URLs AND a working /…/latest/. + # antora-extensions/next-alias.js publishes a `next` segment the same way, for + # the legacy `…/next/…` URLs (the old per-product `master` builds); it points at + # each component's prerelease version, falling back to the latest release. runtime: # local content only -- nothing to fetch @@ -69,6 +72,7 @@ antora: extensions: - ./antora-extensions/comp-version.js - ./antora-extensions/latest-alias.js + - ./antora-extensions/next-alias.js - require: ./antora-extensions/load-global-site-attributes.js attributefile: ./global-attributes.yml enabled: true diff --git a/test/go-redirect.test.js b/test/go-redirect.test.js index 1058ccc..5d03fa9 100644 --- a/test/go-redirect.test.js +++ b/test/go-redirect.test.js @@ -38,7 +38,9 @@ test('a user key redirects into the classic_ui module', () => { }) test('a published version segment is preserved for per-version fidelity', () => { - for (const v of ['10.15', '10.16', '11.0']) { + // `latest` and `next` are served redirect trees, so they are preserved too: the + // stub the go.php link lands on carries the reader on to the real version. + for (const v of ['10.15', '10.16', '11.0', 'latest', 'next']) { const p = `/owncloud-docs-monorepo/server/${v}/` assert.equal( resolveGoPhp(p + 'go.php', '?to=admin-sharing'), diff --git a/test/helpers/latest-versions.js b/test/helpers/latest-versions.js index d4f1488..2ca17da 100644 --- a/test/helpers/latest-versions.js +++ b/test/helpers/latest-versions.js @@ -1,11 +1,16 @@ 'use strict' -// Each multi-version component's latest NON-prerelease version, derived from the -// content tree so a version rollover cannot leave a test asserting yesterday's -// numbers. Mirrors how Antora computes component.latest: the highest version that -// is not marked `prerelease`. Shared by latest-alias.test.js (the redirect target -// the extension points its `latest` and component-root stubs at) and by -// static-files.test.js (the versions llms.txt is allowed to link to). +// The version each generated alias segment points at, derived from the content +// tree so a version rollover cannot leave a test asserting yesterday's numbers. +// +// - latestByComponent(): each multi-version component's latest NON-prerelease +// version, mirroring how Antora computes component.latest (the highest version +// not marked `prerelease`). Shared by latest-alias.test.js (the redirect target +// the extension points its `latest` and component-root stubs at) and by +// static-files.test.js (the versions llms.txt is allowed to link to). +// - nextTargetByComponent(): what antora-extensions/next-alias.js points `next` +// at -- the newest prerelease version, else the latest release. Used by +// next-alias.test.js. const fs = require('node:fs') const path = require('node:path') @@ -49,4 +54,44 @@ function latestByComponent () { return latest } -module.exports = { latestByComponent } +// Component name declared by an antora.yml, or undefined if it has none. +function componentName (descriptor) { + return (fs.readFileSync(descriptor, 'utf8').match(/^name:\s*'?([^'\s]+)/m) || [])[1] +} + +// The version antora-extensions/next-alias.js mirrors into `//next/`: +// the newest `prerelease: true` folder where the component has one, else the +// latest release. Versionless components carry their antora.yml in +// content// and have a single version '' -- webui is in here (its legacy +// URLs were all /webui/next/**), the ROOT landing component is deliberately not. +function nextTargetByComponent () { + const prereleases = {} // component name -> [version, …] + const versionless = {} // component name -> '' + for (const product of fs.readdirSync(CONTENT)) { + const productDir = path.join(CONTENT, product) + if (!fs.statSync(productDir).isDirectory()) continue + const own = path.join(productDir, 'antora.yml') + if (fs.existsSync(own)) { + const name = componentName(own) + if (name && name !== 'ROOT') versionless[name] = '' + } + for (const entry of fs.readdirSync(productDir)) { + const descriptor = path.join(productDir, entry, 'antora.yml') + if (!fs.existsSync(descriptor)) continue + const yaml = fs.readFileSync(descriptor, 'utf8') + if (!/^prerelease:\s*true\s*$/m.test(yaml)) continue + const name = componentName(descriptor) + const version = (yaml.match(/^version:\s*'?([^'\s]+)/m) || [])[1] + if (!name || !version) continue + ;(prereleases[name] = prereleases[name] || []).push(version) + } + } + const target = {} + for (const [name, version] of Object.entries(latestByComponent())) { + const list = prereleases[name] + target[name] = list ? list.sort(compareVersions)[list.length - 1] : version + } + return Object.assign(target, versionless) +} + +module.exports = { latestByComponent, nextTargetByComponent } diff --git a/test/next-alias.test.js b/test/next-alias.test.js new file mode 100644 index 0000000..bb45647 --- /dev/null +++ b/test/next-alias.test.js @@ -0,0 +1,292 @@ +'use strict' + +// Guards for antora-extensions/next-alias.js, which rescues the legacy `…/next/…` +// URLs. Two layers: +// +// 1. Build-output guards -- what the published tree must look like. Like +// latest-alias.js the extension needs a real content catalog, so these assert +// against public/ and skip when the site has not been built (`npm run antora`). +// 2. Catalog unit tests against a fake content catalog, for the precedence rules +// the current content cannot exercise: today every release page also exists in +// the prerelease line, so the release-version fallback layer never fires in a +// real build, and a regression there would go unnoticed. + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const { latestByComponent, nextTargetByComponent } = require('./helpers/latest-versions') + +const PUBLIC = path.join(__dirname, '..', 'public') + +// --------------------------------------------------------------------------- +// 1. Build-output guards +// --------------------------------------------------------------------------- + +// component -> the real version its `next` tree must redirect to ('' for the +// versionless webui, whose pages live directly under /webui/). +const NEXT_TARGET_BY_COMPONENT = nextTargetByComponent() + +function notBuilt (t) { + if (fs.existsSync(path.join(PUBLIC, 'ocis'))) return false + t.skip('public/ not built (run `npm run antora` to enable)') + return true +} + +// The refresh target of a redirect stub, relative to the stub's own directory, or +// null when the stub is missing / is not a stub at all. +function refreshTarget (...segments) { + const file = path.join(PUBLIC, ...segments) + if (!fs.existsSync(file)) return null + const m = fs.readFileSync(file, 'utf8').match(/url=([^"]+)/) + return m ? m[1] : null +} + +// Every .html file under `dir`, as paths relative to `dir`. +function htmlFiles (dir, base = dir, found = []) { + if (!fs.existsSync(dir)) return found + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) htmlFiles(p, base, found) + else if (entry.name.endsWith('.html')) found.push(path.relative(base, p)) + } + return found +} + +test('every component publishes a /next/ tree redirecting to its target version', (t) => { + if (notBuilt(t)) return + const missing = [] + const wrongTarget = [] + for (const [component, version] of Object.entries(NEXT_TARGET_BY_COMPONENT)) { + const url = refreshTarget(component, 'next', 'index.html') + if (url == null) { + missing.push(`/${component}/next/index.html`) + continue + } + // One hop straight to real content: the target version's own segment for a + // multi-version component, the component root for a versionless one -- never + // /latest/ (a redirect to a redirect) and never back into /next/. + const expected = version ? `${version}/` : '../' + if (url.indexOf(expected) < 0 || /\/latest\//.test(url) || /\/next\//.test(url)) { + wrongTarget.push(`/${component}/next/ -> ${url} (want ${expected})`) + } + } + assert.deepEqual( + { missing, wrongTarget }, + { missing: [], wrongTarget: [] }, + '/next/ redirects missing or pointing at the wrong version' + ) +}) + +test('every /next/ redirect target page actually exists', (t) => { + if (notBuilt(t)) return + const broken = [] + for (const component of Object.keys(NEXT_TARGET_BY_COMPONENT)) { + const url = refreshTarget(component, 'next', 'index.html') + if (url == null) continue // reported by the test above + // Resolve the relative target against //next/ on disk. + if (!fs.existsSync(path.join(PUBLIC, component, 'next', url))) { + broken.push(`/${component}/next/ -> ${url}`) + } + } + assert.deepEqual(broken, [], '/next/ redirect targets do not exist under public/') +}) + +test('the versionless webui redirects /webui/next/ into /webui/ proper', (t) => { + if (notBuilt(t)) return + const url = refreshTarget('webui', 'next', 'index.html') + assert.ok(url, '/webui/next/index.html is not a meta-refresh redirect stub') + const target = path.relative(path.join(PUBLIC, 'webui'), path.join(PUBLIC, 'webui', 'next', url)) + assert.equal(target, 'index.html', `/webui/next/ must redirect to /webui/index.html, got ${target}`) +}) + +// The union fallback: /next/ mirrors the prerelease line, then fills the gaps from +// the latest release, so a page the dev line dropped does not dead-end. +test('every page of the latest release has a /next/ counterpart', (t) => { + if (notBuilt(t)) return + const missing = [] + for (const [component, latest] of Object.entries(latestByComponent())) { + for (const page of htmlFiles(path.join(PUBLIC, component, latest))) { + if (!fs.existsSync(path.join(PUBLIC, component, 'next', page))) { + missing.push(`/${component}/next/${page.split(path.sep).join('/')}`) + } + } + } + assert.deepEqual(missing.slice(0, 20), [], `${missing.length} release pages have no /next/ stub`) +}) + +test('a page missing from the prerelease line redirects to the latest release', (t) => { + if (notBuilt(t)) return + const latest = latestByComponent() + const dropped = [] + for (const [component, target] of Object.entries(NEXT_TARGET_BY_COMPONENT)) { + // Only components whose `next` target is a prerelease can have such a gap. + if (!target || target === latest[component]) continue + for (const page of htmlFiles(path.join(PUBLIC, component, latest[component]))) { + if (fs.existsSync(path.join(PUBLIC, component, target, page))) continue + dropped.push({ component, latest: latest[component], page }) + } + } + if (!dropped.length) { + t.skip('no page is published in a release but absent from its prerelease line') + return + } + const wrong = dropped + .map(({ component, latest: version, page }) => { + const url = refreshTarget(component, 'next', page) + return { where: `/${component}/next/${page.split(path.sep).join('/')}`, url, version } + }) + .filter(({ url, version }) => !url || url.indexOf(`${version}/`) < 0) + .map(({ where, url }) => `${where} -> ${url || '(no refresh)'}`) + assert.deepEqual(wrong, [], 'release-only pages must redirect to the latest release under /next/') +}) + +// SEO: the `next` tree is redirect stubs, not content. sitemap-cleanup.js runs in +// `latest-per-component` mode and should keep only the latest release version. +test('no sitemap advertises a /next/ URL', (t) => { + if (notBuilt(t)) return + const offenders = [] + for (const file of fs.readdirSync(PUBLIC)) { + if (!/^sitemap.*\.xml$/.test(file)) continue + const urls = fs.readFileSync(path.join(PUBLIC, file), 'utf8').match(/([^<]*)<\/loc>/g) || [] + for (const url of urls) if (/\/next\//.test(url)) offenders.push(`${file}: ${url}`) + } + assert.deepEqual(offenders, [], 'redirect stubs must stay out of the sitemap') +}) + +// --------------------------------------------------------------------------- +// 2. Catalog unit tests +// --------------------------------------------------------------------------- + +const { register } = require('../antora-extensions/next-alias') + +// A publishable page, shaped like the fields lib/alias-tree.js reads. +const page = (component, version, relative, family = 'page') => ({ + src: { component, version, module: 'ROOT', family, relative }, + // Versionless components publish without a version segment, as Antora does. + pub: { url: '/' + [component, version, relative.replace(/\.adoc$/, '.html')].filter(Boolean).join('/') }, + out: { path: 'irrelevant' }, +}) + +// A `page-aliases` move-redirect that Antora registers during conversion: an alias +// at `relative` whose `rel` is the page it was renamed to. +const moveRedirect = (component, version, relative, target) => + Object.assign(page(component, version, relative, 'alias'), { rel: target }) + +// The slice of Antora's ContentCatalog that lib/alias-tree.js uses. +function fakeCatalog (components, files) { + const added = [] + const key = (src) => [src.component, src.version, src.module, src.family, src.relative].join('|') + const all = () => files.concat(added) + return { + added, + getComponents: () => components, + findBy: ({ component, version, family }) => + all().filter((f) => f.src.component === component && f.src.version === version && f.src.family === family), + getById: (src) => all().find((f) => key(f.src) === key(src)), + addFile: (file) => { + // Antora throws on a duplicate alias, which is what the getById guards in + // lib/alias-tree.js exist to prevent -- reproduce that here. + if (all().some((f) => key(f.src) === key(file.src))) throw new Error(`Duplicate alias: ${key(file.src)}`) + added.push(file) + return file + }, + } +} + +// Run the extension over a fake catalog and return the `next` aliases it added, as +// { : }. +function nextAliases (components, files) { + const contentCatalog = fakeCatalog(components, files) + const handlers = {} + register.call({ once: (event, fn) => (handlers[event] = fn) }) + handlers.documentsConverted({ contentCatalog }) + return Object.fromEntries( + contentCatalog.added.map((f) => { + assert.equal(f.src.version, 'next', `alias added outside the next segment: ${f.src.relative}`) + assert.equal(f.src.family, 'alias') + return [f.src.relative, f.rel.pub.url] + }) + ) +} + +// ocis-shaped: a prerelease dev line (8.3) on top of the latest release (8.2). +function ocisComponent () { + const dev = { version: '8.3', prerelease: true } + const release = { version: '8.2' } + return { name: 'ocis', versions: [dev, release], latest: release, latestPrerelease: dev } +} + +test('the prerelease line wins where both versions publish the page', () => { + const component = ocisComponent() + const aliases = nextAliases([component], [ + page('ocis', '8.3', 'shared.adoc'), + page('ocis', '8.2', 'shared.adoc'), + ]) + assert.deepEqual(aliases, { 'shared.adoc': '/ocis/8.3/shared.html' }) +}) + +test('a page dropped from the prerelease line falls back to the latest release', () => { + const component = ocisComponent() + const aliases = nextAliases([component], [ + page('ocis', '8.3', 'shared.adoc'), + page('ocis', '8.3', 'dev-only.adoc'), + page('ocis', '8.2', 'shared.adoc'), + page('ocis', '8.2', 'dropped.adoc'), + ]) + assert.deepEqual(aliases, { + 'shared.adoc': '/ocis/8.3/shared.html', + 'dev-only.adoc': '/ocis/8.3/dev-only.html', + 'dropped.adoc': '/ocis/8.2/dropped.html', // the fallback layer + }) +}) + +test('a page renamed in the prerelease line redirects to its new location', () => { + // `old.adoc` is still a real page in 8.2 but only a move-redirect in 8.3. The + // prerelease redirect must win over the older real page, so /next/old.html lands + // where the dev line says the content now lives. + const component = ocisComponent() + const renamed = page('ocis', '8.3', 'new.adoc') + const aliases = nextAliases([component], [ + renamed, + moveRedirect('ocis', '8.3', 'old.adoc', renamed), + page('ocis', '8.2', 'old.adoc'), + ]) + assert.deepEqual(aliases, { + 'new.adoc': '/ocis/8.3/new.html', + 'old.adoc': '/ocis/8.3/new.html', + }) +}) + +test('a component with no prerelease line mirrors its latest release', () => { + const release = { version: '11.0' } + const component = { name: 'server', versions: [release], latest: release } + const aliases = nextAliases([component], [page('server', '11.0', 'index.adoc')]) + assert.deepEqual(aliases, { 'index.adoc': '/server/11.0/index.html' }) +}) + +test('a versionless component mirrors its single version', () => { + // webui: version '' -- the fallback layer must not run a second time over the + // same version, or Antora would throw "Duplicate alias". + const only = { version: '' } + const component = { name: 'webui', versions: [only], latest: only } + const aliases = nextAliases([component], [page('webui', '', 'index.adoc')]) + assert.deepEqual(aliases, { 'index.adoc': '/webui/index.html' }) +}) + +test('the ROOT landing component gets no next tree', () => { + const only = { version: '' } + const component = { name: 'ROOT', versions: [only], latest: only } + assert.deepEqual(nextAliases([component], [page('ROOT', '', 'index.adoc')]), {}) +}) + +test('a prerelease Antora does not report is still found via versions', () => { + // component.latestPrerelease is only set when the NEWEST version is a + // prerelease; the versions scan in next-alias.js is the backstop. + const dev = { version: '8.3', prerelease: true } + const release = { version: '8.2' } + const component = { name: 'ocis', versions: [dev, release], latest: release } + const aliases = nextAliases([component], [page('ocis', '8.3', 'index.adoc')]) + assert.deepEqual(aliases, { 'index.adoc': '/ocis/8.3/index.html' }) +}) diff --git a/ui/supplemental/js/go-redirect.js b/ui/supplemental/js/go-redirect.js index 8b2a375..5768fea 100644 --- a/ui/supplemental/js/go-redirect.js +++ b/ui/supplemental/js/go-redirect.js @@ -42,12 +42,16 @@ // real version is now published explicitly, so these pass through untouched; // only unknown/old segments with no tree fall back to `latest`. `latest` // itself is included because it is a valid served segment (a redirect tree - // built by the latest-alias extension). This is the ONE version list that - // must be maintained by hand on each server release — the latest-alias - // extension and the sitemap keep-set both derive `latest` automatically from + // built by the latest-alias extension), and so is `next` (the same, built by + // the next-alias extension for the legacy `…/next/…` URLs) -- a go.php link + // under either resolves inside that tree and its stub carries the reader on to + // the real version. Both are permanent entries, not versions to maintain. + // The real version numbers are the ONE part of this list that must be + // maintained by hand on each server release — the latest-alias extension and + // the sitemap keep-set both derive `latest` automatically from // component.latest. The unit tests fail the build if this list drifts from // the published server segments. - var PUBLISHED_VERSIONS = ['10.15', '10.16', '11.0', 'latest'] + var PUBLISHED_VERSIONS = ['10.15', '10.16', '11.0', 'latest', 'next'] // key -> path relative to the version root (…/server//). var MAPPING = {