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