Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `/<product>/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 `/<product>/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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `/<product>/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/<version>/` tree, so add the segment to `PUBLISHED_VERSIONS` in
Expand Down
2 changes: 1 addition & 1 deletion agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This is the consolidated documentation **monorepo**. It supersedes the previous

- `site.yml` -- Antora playbook; all content sources are local
- `content/<product>/<version>/` -- 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
Expand Down
60 changes: 11 additions & 49 deletions antora-extensions/latest-alias.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
* hides the real version), so we add the alias files ourselves. Under
* redirect_facility: static each alias renders as a <meta http-equiv="refresh">
* 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'

Expand Down Expand Up @@ -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 /<component>/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 /<component>/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)
})
})
}
84 changes: 84 additions & 0 deletions antora-extensions/lib/alias-tree.js
Original file line number Diff line number Diff line change
@@ -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 <meta http-equiv="refresh"> 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 <component>/<segment>/<module>/<relative>.
*
* 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 }
69 changes: 69 additions & 0 deletions antora-extensions/next-alias.js
Original file line number Diff line number Diff line change
@@ -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 `/<product>/next/<path>` 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 <meta http-equiv="refresh"> 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)
}
})
})
}
4 changes: 4 additions & 0 deletions site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion test/go-redirect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading