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
77 changes: 77 additions & 0 deletions antora-extensions/llms-txt.js
Original file line number Diff line number Diff line change
@@ -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 <meta http-equiv="refresh"> 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 /<component>/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 /<component>/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)
}
})
}
25 changes: 24 additions & 1 deletion extension-tests/latest-versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
7 changes: 6 additions & 1 deletion extension-tests/static-files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions ui/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 `/<component>/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`.

35 changes: 25 additions & 10 deletions ui/supplemental/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,49 @@
> 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

- [ownCloud Documentation Overview](https://doc.owncloud.com/index.html): Entry page linking the documentation of every ownCloud product.

## 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

Expand Down
Loading