diff --git a/antora-extensions/llms-txt.js b/antora-extensions/llms-txt.js
new file mode 100644
index 00000000..e200fcf0
--- /dev/null
+++ b/antora-extensions/llms-txt.js
@@ -0,0 +1,77 @@
+'use strict'
+
+/**
+ * Resolve `latest` version segments in llms.txt at build time.
+ *
+ * ui/supplemental/llms.txt is the human-maintained template. It uses `latest`
+ * as the version segment for every component URL (e.g. /ocis/latest/admin/).
+ * Antora copies that file verbatim to public/llms.txt -- but `latest` on the
+ * static-hosted site is a tree of redirect stubs,
+ * not real content pages, so URL validators (including CI) reject it.
+ *
+ * This extension runs at sitePublished, reads public/llms.txt, and replaces
+ * every //latest/ segment with the component's actual GA version
+ * (the same version `latest-alias.js` mirrors). Explicit version numbers in the
+ * template (e.g. /ocis/9.0/developer/ for a module that only exists in a
+ * prerelease) are left untouched.
+ *
+ * Maintenance: when a prerelease version graduates to GA and `latest` moves to
+ * it, change the explicit version in the template to `latest` -- this extension
+ * will resolve it automatically from then on.
+ */
+
+const path = require('path')
+const fs = require('fs')
+
+module.exports.register = function () {
+ const latestByComponent = {}
+
+ this.once('contentClassified', ({ contentCatalog }) => {
+ contentCatalog.getComponents().forEach((component) => {
+ const latest = component.latest
+ if (latest && latest.version) latestByComponent[component.name] = latest.version
+ })
+ })
+
+ this.once('sitePublished', ({ playbook, publications }) => {
+ const logger = this.getLogger('llms-txt')
+ const outputDir = publications[0].resolvedPath
+ const llmsFile = path.join(outputDir, 'llms.txt')
+
+ let content
+ try {
+ content = fs.readFileSync(llmsFile, 'utf8')
+ } catch (e) {
+ logger.warn('llms.txt not found at: ' + llmsFile + ' -- skipping')
+ return
+ }
+
+ let changed = false
+
+ Object.entries(latestByComponent).forEach(([name, version]) => {
+ // Match //latest/ regardless of domain so the replacement works
+ // for both the production build (https://doc.owncloud.com) and local
+ // preview builds (http://localhost:8080). Escape hyphens in component
+ // names such as `ios-app`.
+ const escapedName = name.replace(/-/g, '\\-')
+ const pattern = new RegExp(`(https?:\\/\\/[^/]+\\/${escapedName})\\/latest\\/`, 'g')
+ const updated = content.replace(pattern, `$1/${version}/`)
+ if (updated !== content) {
+ content = updated
+ changed = true
+ }
+ })
+
+ if (!changed) {
+ logger.warn('llms.txt: no latest/ segments found to resolve -- check template')
+ return
+ }
+
+ try {
+ fs.writeFileSync(llmsFile, content, { encoding: 'utf8', mode: 0o664 })
+ logger.info('llms.txt: resolved latest/ segments to real version numbers')
+ } catch (e) {
+ logger.warn('llms.txt: failed to write resolved file: ' + e.message)
+ }
+ })
+}
diff --git a/extension-tests/latest-versions.js b/extension-tests/latest-versions.js
index d0c44966..9f9bbc4c 100644
--- a/extension-tests/latest-versions.js
+++ b/extension-tests/latest-versions.js
@@ -94,4 +94,27 @@ function nextTargetByComponent () {
return Object.assign(target, versionless)
}
-module.exports = { latestByComponent, nextTargetByComponent }
+// Returns a map of component name -> Set of version strings that are marked
+// prerelease: true in their antora.yml. Used by static-files.test.js to allow
+// llms.txt entries that intentionally pin a prerelease version (e.g. a module
+// that only exists in the current prerelease and has no GA version yet).
+function prereleaseVersionsByComponent () {
+ const result = {}
+ for (const product of fs.readdirSync(CONTENT)) {
+ const productDir = path.join(CONTENT, product)
+ if (!fs.statSync(productDir).isDirectory()) continue
+ 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 = (yaml.match(/^name:\s*'?([^'\s]+)/m) || [])[1]
+ const version = (yaml.match(/^version:\s*'?([^'\s]+)/m) || [])[1]
+ if (!name || !version) continue
+ ;(result[name] = result[name] || new Set()).add(version)
+ }
+ }
+ return result
+}
+
+module.exports = { latestByComponent, nextTargetByComponent, prereleaseVersionsByComponent }
diff --git a/extension-tests/static-files.test.js b/extension-tests/static-files.test.js
index 4ef53f98..614f34df 100644
--- a/extension-tests/static-files.test.js
+++ b/extension-tests/static-files.test.js
@@ -14,7 +14,7 @@ const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
-const { latestByComponent } = require('./latest-versions')
+const { latestByComponent, prereleaseVersionsByComponent } = require('./latest-versions')
const ROOT = path.join(__dirname, '..')
const PUBLIC = path.join(ROOT, 'public')
@@ -24,6 +24,7 @@ const PUBLIC = path.join(ROOT, 'public')
// Pinned versions can go stale, so the guards below hold them against the
// content tree and fail the build on the next release rollover.
const LATEST_BY_COMPONENT = latestByComponent()
+const PRERELEASE_BY_COMPONENT = prereleaseVersionsByComponent()
// Each multi-version component's `latest-*-version` attribute in
// global-attributes.yml. The attribute names do not all follow the component name
@@ -148,6 +149,10 @@ test('every version pinned in llms.txt is the current release', (t) => {
// version segment to check.
const latest = LATEST_BY_COMPONENT[component]
if (!latest || version === undefined) continue
+ // An explicit prerelease pin is intentional (the module only exists in that
+ // version) -- exempt it. It must be updated manually when the version reaches GA.
+ const prereleases = PRERELEASE_BY_COMPONENT[component]
+ if (prereleases && prereleases.has(version)) continue
if (version !== latest) stale.push(`/${component}/${version}/ (current release is ${latest})`)
}
assert.deepEqual(
diff --git a/site.yml b/site.yml
index 93a77d98..fcb28e5d 100644
--- a/site.yml
+++ b/site.yml
@@ -90,6 +90,7 @@ antora:
- require: ./antora-extensions/load-global-site-attributes.js
attributefile: ./global-attributes.yml
enabled: true
+ - ./antora-extensions/llms-txt.js
- require: ./antora-extensions/sitemap-cleanup.js
# Keep only each component's latest non-prerelease version in the sitemap
# (the /…/latest/ redirect target). Real version URLs are published but
diff --git a/ui/AGENTS.md b/ui/AGENTS.md
new file mode 100644
index 00000000..cf077e82
--- /dev/null
+++ b/ui/AGENTS.md
@@ -0,0 +1,40 @@
+# ui
+
+Agent guidelines for the UI layer (`supplemental/llms.txt`, supplemental JS/CSS). Loaded on demand; repo-wide rules live in the root `AGENTS.md`.
+
+## LLM Discovery File (`llms.txt`)
+
+`ui/supplemental/llms.txt` is served at `https://doc.owncloud.com/llms.txt` and follows the [llmstxt.org](https://llmstxt.org/) convention for AI-readable site indexes. It lists one URL per documentation section. **CI validates every URL in this file; an unresolvable URL is a build error.**
+
+### Maintenance rules
+
+- **Adding a module** to any Antora component → add a corresponding entry to `llms.txt`.
+- **Removing a module** from any component → remove its entry from `llms.txt`.
+- **Adding a new component** (new product) → add a new section with the component's index URL.
+
+### Version resolution at build time
+
+`antora-extensions/llms-txt.js` runs at `sitePublished` and rewrites every `//latest/` segment in the generated `public/llms.txt` with the component's actual GA version number (the same version `latest-alias.js` mirrors). This means:
+
+- The template in `ui/supplemental/llms.txt` uses `latest` for all entries and is human-readable.
+- The published file always contains real version numbers that CI can validate against `public/`.
+- When the GA version rolls forward (e.g. 9.0 ships), the file updates automatically on the next build.
+
+### Prerelease-only modules
+
+If a module exists **only** in a prerelease version (e.g. the `developer` module was introduced in 9.0 while `latest` still points to 8.2), `latest` cannot be used — the GA release has no such module. In this case pin the URL to the explicit prerelease version in the template:
+
+```
+https://doc.owncloud.com/ocis/9.0/developer/index.html
+```
+
+The extension leaves explicit version numbers untouched — it only resolves `latest` segments.
+
+**When to switch:** once `prerelease: true` is removed from that version's `antora.yml` and `latest` moves to it, change the pinned version in the template to `latest`. The extension resolves it from that point on.
+
+This applies to **any component** — whenever a new module is first introduced in a prerelease version, pin its template URL until GA.
+
+### Versionless components
+
+The `webui` component uses `version: ~` (no version segment in URLs). Its entries in `llms.txt` correctly omit the version segment, e.g. `https://doc.owncloud.com/webui/index.html`.
+
diff --git a/ui/supplemental/llms.txt b/ui/supplemental/llms.txt
index d932efe5..a9514024 100644
--- a/ui/supplemental/llms.txt
+++ b/ui/supplemental/llms.txt
@@ -4,6 +4,21 @@
> Classic, the ownCloud web user interfaces, and the desktop and mobile sync
> clients. Every link below points at the current release of its product; earlier
> releases live under the same paths with a different version segment.
+>
+> **SOURCE TEMPLATE** — do not validate URLs in this file directly; the built
+> output at `public/llms.txt` has all `latest` segments resolved to real version
+> numbers by `antora-extensions/llms-txt.js` at build time. Edit URLs and
+> descriptions here; the extension handles version resolution.
+>
+> **For maintainers:** This file serves AI crawlers and LLM tools only (see
+> https://llmstxt.org). It has no effect on search engine indexing — that is
+> governed by `sitemap.xml` and canonical link tags. Version numbers in the URLs
+> below are resolved automatically at build time by
+> `antora-extensions/llms-txt.js`; the source template at
+> `ui/supplemental/llms.txt` uses `latest` as a placeholder. Exception: URLs
+> pinned to an explicit version (e.g. a module that only exists in a prerelease)
+> must be updated manually once that version reaches GA — see `AGENTS.md` for
+> details.
## Overview
@@ -11,27 +26,27 @@
## Infinite Scale
-- [Infinite Scale](https://doc.owncloud.com/ocis/8.2/index.html): Infinite Scale is a data platform providing tools to integrate, organize, share and govern data and metadata.
-- [Infinite Scale Admin Documentation](https://doc.owncloud.com/ocis/8.2/admin/index.html): General topics, installation and administrative tasks for Infinite Scale.
-- [Infinite Scale Developer Documentation](https://doc.owncloud.com/ocis/8.2/developer/index.html): Developer related topics for Infinite Scale.
+- [Infinite Scale](https://doc.owncloud.com/ocis/latest/index.html): Infinite Scale is a data platform providing tools to integrate, organize, share and govern data and metadata.
+- [Infinite Scale Admin Documentation](https://doc.owncloud.com/ocis/latest/admin/index.html): General topics, installation and administrative tasks for Infinite Scale.
+- [Infinite Scale Developer Documentation](https://doc.owncloud.com/ocis/9.0/developer/index.html): Developer related topics for Infinite Scale.
## ownCloud Classic
-- [ownCloud Classic](https://doc.owncloud.com/server/11.0/index.html): Introduction to ownCloud Classic, the PHP based ownCloud server.
-- [ownCloud Classic Admin Manual](https://doc.owncloud.com/server/11.0/admin_manual/index.html): Installing, configuring, operating and maintaining an ownCloud Classic server.
-- [ownCloud Classic Developer Manual](https://doc.owncloud.com/server/11.0/developer_manual/index.html): Writing apps for and integrating with ownCloud Classic.
+- [ownCloud Classic](https://doc.owncloud.com/server/latest/index.html): Introduction to ownCloud Classic, the PHP based ownCloud server.
+- [ownCloud Classic Admin Manual](https://doc.owncloud.com/server/latest/admin_manual/index.html): Installing, configuring, operating and maintaining an ownCloud Classic server.
+- [ownCloud Classic Developer Manual](https://doc.owncloud.com/server/latest/developer_manual/index.html): Writing apps for and integrating with ownCloud Classic.
## Web User Interfaces
- [Web User Interfaces Guide](https://doc.owncloud.com/webui/index.html): Overview of the ownCloud web user interfaces.
- [ownCloud Web](https://doc.owncloud.com/webui/owncloud_web/index.html): The ownCloud web user interface optimized for Infinite Scale.
-- [Web ownCloud Classic](https://doc.owncloud.com/server/11.0/classic_ui/index.html): The classic web user interface of an ownCloud Classic server.
+- [Web ownCloud Classic](https://doc.owncloud.com/server/latest/classic_ui/index.html): The classic web user interface of an ownCloud Classic server.
## Desktop and Mobile Apps
-- [Desktop App](https://doc.owncloud.com/desktop/7.1/index.html): Synchronize your files from connected servers to your local computer.
-- [Mobile App for Android](https://doc.owncloud.com/android/4.7/index.html): Install, configure, use and troubleshoot the ownCloud app for Android.
-- [Mobile App for iOS](https://doc.owncloud.com/ios-app/12.7/index.html): Install, configure, use and troubleshoot the ownCloud app for iOS.
+- [Desktop App](https://doc.owncloud.com/desktop/latest/index.html): Synchronize your files from connected servers to your local computer.
+- [Mobile App for Android](https://doc.owncloud.com/android/latest/index.html): Install, configure, use and troubleshoot the ownCloud app for Android.
+- [Mobile App for iOS](https://doc.owncloud.com/ios-app/latest/index.html): Install, configure, use and troubleshoot the ownCloud app for iOS.
## Releases and Release Notes