From b520566151d949c6abda2c914afd499ae56a7015 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Mon, 19 Oct 2026 19:42:03 +0200 Subject: [PATCH] feat: replace config-to-docs with a config-sample-to-adoc Antora extension Port the owncloud/config-to-docs converter to Node and embed it here, so the server config parameter pages no longer depend on a separate PHP repo (PHP/Twig/Laminas dependency problem, see owncloud/config-to-docs#32). - antora-extensions/lib/config-sample-to-adoc.js: dependency-free converter; reproduces the PHP tool's output byte for byte, quirks included, so the published pages do not change - antora-extensions/config-sample-to-adoc.js: maintainer-only extension, disabled in site.yml; pairs of source_path/target_path, report_only, stop_after_processing. Fails loudly on bad paths, unknown keys, a missing header marker or a missing $CONFIG array - config-docs-in-sync: run on Node 22 with the new converter instead of cloning config-to-docs and installing PHP 7.4 + Composer - content/server/AGENTS.md: the sample-comment authoring rules that previously lived in the config-to-docs README Output was verified against the PHP tool for the 10.16 and master core samples, and a quirk fixture generated by the PHP tool is the golden test. Signed-off-by: Lukas Hirt --- .github/workflows/config-docs-in-sync.yml | 61 +++---- AGENTS.md | 3 +- antora-extensions/config-sample-to-adoc.js | 100 +++++++++++ .../lib/config-sample-to-adoc.js | 160 ++++++++++++++++++ content/server/AGENTS.md | 79 +++++++++ content/server/CLAUDE.md | 1 + extension-tests/config-sample-to-adoc.test.js | 100 +++++++++++ .../quirks.expected.adoc | 103 +++++++++++ .../config-sample-to-adoc/quirks.sample.php | 77 +++++++++ .../config-sample-to-adoc/quirks.target.adoc | 8 + scripts/config-sample-to-adoc.js | 19 +++ site.yml | 14 ++ 12 files changed, 682 insertions(+), 43 deletions(-) create mode 100644 antora-extensions/config-sample-to-adoc.js create mode 100644 antora-extensions/lib/config-sample-to-adoc.js create mode 100644 content/server/AGENTS.md create mode 120000 content/server/CLAUDE.md create mode 100644 extension-tests/config-sample-to-adoc.test.js create mode 100644 extension-tests/fixtures/config-sample-to-adoc/quirks.expected.adoc create mode 100644 extension-tests/fixtures/config-sample-to-adoc/quirks.sample.php create mode 100644 extension-tests/fixtures/config-sample-to-adoc/quirks.target.adoc create mode 100644 scripts/config-sample-to-adoc.js diff --git a/.github/workflows/config-docs-in-sync.yml b/.github/workflows/config-docs-in-sync.yml index 14a31b6..3f7b591 100644 --- a/.github/workflows/config-docs-in-sync.yml +++ b/.github/workflows/config-docs-in-sync.yml @@ -1,10 +1,10 @@ name: Config Docs In Sync # The two config parameter pages per server version are generated by -# owncloud/config-to-docs from owncloud/core's config/config.sample.php and -# config/config.apps.sample.php. Everything below the "header end" marker in -# those pages is machine output, and a hand edit there is silently destroyed the -# next time anyone regenerates. This job regenerates and fails on any +# antora-extensions/lib/config-sample-to-adoc.js from owncloud/core's +# config/config.sample.php and config/config.apps.sample.php. Everything below +# the "header end" marker in those pages is machine output, and a hand edit +# there is silently destroyed the next time anyone regenerates. This job regenerates and fails on any # difference, so the published pages are provably generator output and an edit # in the wrong place is caught here instead of years later. # @@ -32,12 +32,6 @@ env: jobs: config-docs-in-sync: runs-on: ubuntu-latest - # owncloud-owned image, so no third-party action is needed to provide PHP. - container: owncloudci/php:7.4 - # Inside a container the default shell is sh, not bash. - defaults: - run: - shell: bash concurrency: group: config-docs-in-sync-${{ github.ref }} cancel-in-progress: true @@ -45,35 +39,19 @@ jobs: - name: Checkout docs uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Checkout config-to-docs - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - repository: owncloud/config-to-docs - path: .config-to-docs - # Deliberately unpinned. This check asks "do the committed pages match - # what a maintainer running ctd.sh today would produce", so it has to - # track the current generator. The composer deps below are pinned - # because the lock file is that generator's own declared contract. - - - name: Install the converter - working-directory: .config-to-docs - # install, not update: the comment parsing lives in - # phpdocumentor/reflection-docblock, which composer.json accepts as - # ^4.3.0 while the lock pins a known-good version. Floating it can - # change the generated output. - run: composer install --no-interaction --no-progress --no-dev --no-scripts + node-version: '22' - name: Regenerate every version from its core branch run: | set -eu - # convert.php exits 0 on both of its own failure paths - unreadable - # input, unwritable output - so nothing here may infer success from an - # exit status. Every file is checked before use, and the pages actually - # regenerated are counted and asserted at the end. Without that, a - # renamed sample in core would leave the pages untouched, the diff - # below would find nothing, and this job would pass having checked - # nothing at all. + # Every file is checked before use, and the pages actually regenerated + # are counted and asserted at the end. Without that, a renamed sample + # in core would leave the pages untouched, the diff below would find + # nothing, and this job would pass having checked nothing at all. versions=0 generated=0 @@ -140,13 +118,11 @@ jobs: fi # Keep the committed copy to compare against. The comparison - # deliberately does not use git: the pages are just files, and - # inside this container git cannot see the workspace repository. + # deliberately does not use git: the pages are just files. mkdir -p "$RUNNER_TEMP/committed/$(dirname "$page")" cp "$page" "$RUNNER_TEMP/committed/$page" - php .config-to-docs/convert.php config:convert-adoc \ - --input-file="$sample" --output-file="$page" + node scripts/config-sample-to-adoc.js "$sample" "$page" generated=$((generated + 1)) done @@ -170,7 +146,7 @@ jobs: run: | set -eu - # diff, not git diff: see the note in the previous step. Each snapshot + # Plain diff against the snapshots taken above. Each snapshot # taken before regeneration is compared with the page as it now stands, # so the diff reads committed -> generated, i.e. what needs applying. : > "$RUNNER_TEMP/drift.diff" @@ -196,7 +172,7 @@ jobs: { echo "### Config parameter pages are out of sync" echo - echo "These pages are generated by [config-to-docs](https://github.com/owncloud/config-to-docs)" + echo "These pages are generated by \`antora-extensions/lib/config-sample-to-adoc.js\`" echo "from \`config/config.sample.php\` and \`config/config.apps.sample.php\` in" echo "[owncloud/core](https://github.com/owncloud/core). Everything below the" echo "\`header end\` marker is machine output." @@ -205,15 +181,16 @@ jobs: echo "* **To change a page's introduction**, edit *above* the marker - that part is kept." echo "* **If core changed and the page is merely stale**, commit the regenerated page." echo - echo "To regenerate locally: clone core and config-to-docs, check out the core branch" - echo "matching the version, and run \`./ctd.sh\` in config-to-docs." + echo "To regenerate locally: clone core next to this repo, check out the core branch" + echo "matching the version, and run the \`config-sample-to-adoc\` extension, see" + echo "\`content/server/AGENTS.md\`." echo echo '```diff' cat "$RUNNER_TEMP/drift.diff" echo '```' } >> "$GITHUB_STEP_SUMMARY" - echo "::error::Committed config parameter pages differ from config-to-docs output - see the job summary for the diff and how to fix it." + echo "::error::Committed config parameter pages differ from the generator output - see the job summary for the diff and how to fix it." grep -E '^(---|\+\+\+|Only in)' "$RUNNER_TEMP/drift.diff" || true exit 1 diff --git a/AGENTS.md b/AGENTS.md index 3caff9e..3adae2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,11 +76,12 @@ Subdirectory `AGENTS.md` files are loaded on demand when working in that subtree * `content/AGENTS.md` -- AsciiDoc authoring rules (tables, images, lists, heading capitalisation), xref fixes, `doc.owncloud.com` link conversion, external link maintenance, xref link-text checks * `content/ocis/AGENTS.md` -- oCIS developer service pages, Antora Collector and Swagger UI extensions +* `content/server/AGENTS.md` -- generated server config parameter pages (`config-sample-to-adoc`) and how to write the sample comments in core * `ui/AGENTS.md` -- `llms.txt` maintenance and supplemental UI JavaScript ## File Relationship: CLAUDE.md and AGENTS.md -Every `CLAUDE.md` is a symlink to the `AGENTS.md` beside it (root, `content/`, `content/ocis/`, `ui/`). Both names are provided so that different AI tools find their expected filename — Claude Code reads `CLAUDE.md`, OpenAI Codex-based tools read `AGENTS.md`. **Always edit `AGENTS.md` directly**; `CLAUDE.md` reflects all changes automatically via the symlink. When adding a new subdirectory `AGENTS.md`, add the sibling `CLAUDE.md` symlink and list it under "Where the Other Rules Live". +Every `CLAUDE.md` is a symlink to the `AGENTS.md` beside it (root, `content/`, `content/ocis/`, `content/server/`, `ui/`). Both names are provided so that different AI tools find their expected filename — Claude Code reads `CLAUDE.md`, OpenAI Codex-based tools read `AGENTS.md`. **Always edit `AGENTS.md` directly**; `CLAUDE.md` reflects all changes automatically via the symlink. When adding a new subdirectory `AGENTS.md`, add the sibling `CLAUDE.md` symlink and list it under "Where the Other Rules Live". Keep these files outside the Antora `start_paths` (never inside a `content///` directory, which is added and dropped over time) and outside `ui/supplemental/`, otherwise they would be published. `extension-tests/static-files.test.js` guards this. diff --git a/antora-extensions/config-sample-to-adoc.js b/antora-extensions/config-sample-to-adoc.js new file mode 100644 index 0000000..d4dd790 --- /dev/null +++ b/antora-extensions/config-sample-to-adoc.js @@ -0,0 +1,100 @@ +'use strict' + +/** + * config-sample-to-adoc — Antora extension + * + * Regenerates the server configuration parameter pages + * (config_sample_php_parameters.adoc, config_apps_sample_php_parameters.adoc) + * from the config.sample.php / config.apps.sample.php files in a local + * owncloud/core checkout. It replaces the retired owncloud/config-to-docs tool. + * + * This is a maintainer tool, not part of the site build: the extension is + * registered with `enabled: false` and is meant to be switched on locally, + * with a core clone on the branch that matches the docs version. It writes + * into content/, so the result is reviewed and committed like any other edit. + * + * Everything above the `// header end do not delete or edit this line` marker + * in a target page is hand-written and kept; everything below it is replaced. + * The target page must already exist and contain the marker. + * + * It listens to playbookBuilt, which fires before content is aggregated, so a + * regenerated page is already picked up by the same build. + * + * antora: + * extensions: + * - require: ./antora-extensions/config-sample-to-adoc.js + * enabled: true + * pairs: + * - source_path: ../core/config/config.sample.php + * target_path: content/server/11.0/modules/admin_manual/pages/configuration/server/config_sample_php_parameters.adoc + * - source_path: ../core/config/config.apps.sample.php + * target_path: content/server/11.0/modules/admin_manual/pages/configuration/server/config_apps_sample_php_parameters.adoc + * report_only: true + * stop_after_processing: true + * + * Config keys: + * pairs (array, required) — Source→target pairs to process. + * source_path (string, required) — Path to a config.sample.php or + * config.apps.sample.php file (a file, + * not a URL). Relative paths are + * resolved from the playbook directory. + * target_path (string, required) — Path to the existing .adoc page in this + * repo. Resolved like source_path. + * report_only (boolean, default: false) — When true, only log which pages would + * change; write nothing. + * stop_after_processing (boolean, default: false) — When true, stop the Antora build with + * exit code 0 once the pages are done. + * Combine with report_only to inspect + * without running a full build. + */ + +const fs = require('node:fs') +const path = require('node:path') +const { convert } = require('./lib/config-sample-to-adoc') + +module.exports.register = function ({ config }) { + this.once('playbookBuilt', ({ playbook }) => { + // Antora camelCases the keys of extension config, so the snake_case keys of + // the playbook arrive as sourcePath, reportOnly, etc. An unknown key is an + // error rather than ignored: a dry run that silently writes would be bad. + const { pairs, reportOnly = false, stopAfterProcessing = false, ...unknown } = config + if (Object.keys(unknown).length) { + throw new Error(`config-sample-to-adoc: unknown config key(s): ${Object.keys(unknown).join(', ')}`) + } + if (!Array.isArray(pairs) || !pairs.length) { + throw new Error('config-sample-to-adoc: `pairs` is required and must be a non-empty list') + } + const log = (message) => console.log(`[config-sample-to-adoc] ${message}`) + + for (const { sourcePath: source, targetPath: target, ...extra } of pairs) { + if (typeof source !== 'string' || typeof target !== 'string' || Object.keys(extra).length) { + throw new Error('config-sample-to-adoc: every pair needs exactly a `source_path` and a `target_path`') + } + const sourceFile = path.resolve(playbook.dir, source) + const targetFile = path.resolve(playbook.dir, target) + // Unlike the retired PHP tool, a bad path is an error, not a silent no-op. + const sample = readFile(sourceFile, 'source_path') + const current = readFile(targetFile, 'target_path') + const updated = convert(sample, current) + + if (updated === current) { + log(`unchanged: ${target}`) + } else if (reportOnly) { + log(`would update: ${target}`) + } else { + fs.writeFileSync(targetFile, updated) + log(`updated: ${target}`) + } + } + + if (stopAfterProcessing) this.stop(0) + }) +} + +function readFile (file, key) { + try { + return fs.readFileSync(file, 'utf8') + } catch (err) { + throw new Error(`config-sample-to-adoc: cannot read ${key} ${file}: ${err.message}`) + } +} diff --git a/antora-extensions/lib/config-sample-to-adoc.js b/antora-extensions/lib/config-sample-to-adoc.js new file mode 100644 index 0000000..11292e8 --- /dev/null +++ b/antora-extensions/lib/config-sample-to-adoc.js @@ -0,0 +1,160 @@ +'use strict' + +/** + * Convert ownCloud core's config.sample.php / config.apps.sample.php into the + * AsciiDoc parameter pages under admin_manual/pages/configuration/server. + * + * This is a port of the retired owncloud/config-to-docs PHP tool. The output has + * to stay byte-identical to what that tool produced, so its quirks are kept on + * purpose -- see content/server/AGENTS.md for the ones that + * matter to people editing the samples in core. The tool leaned on + * phpdocumentor/reflection-docblock for the docblock split; splitDocBlock() + * below is the same regular expression. + * + * Pure function, no I/O: the extension in ../config-sample-to-adoc.js does that. + * + * Derived from owncloud/config-to-docs, licensed under the MIT License: + * + * Copyright (c) 2018, ownCloud GmbH. Original author: Matthew Setter. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and + * to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of + * the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO + * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +const HEADER_END = '// header end do not delete or edit this line' + +// PHP's trim() default character list. +const PHP_WS = '[ \\t\\n\\r\\0\\x0B]' +const phpTrim = (s) => s.replace(new RegExp(`^${PHP_WS}+|${PHP_WS}+$`, 'g'), '') + +// Zend\Filter\StringTrim +const stringTrim = (s) => s.replace(/^\s+|\s+$/gu, '') + +// \s as PCRE reads it without the /u flag. +const WS = '[ \\t\\n\\r\\f\\v]' + +const DESCRIPTION_FILTERS = [ + [new RegExp(`^([^\\n]${WS}*)(?=-)`, 'gm'), ''], + [new RegExp(`[^\\n][^\\n] warning::${WS}*`, 'gm'), 'WARNING: '], + [/``/gm, '`'], + [/\t/gm, ' '], +] +const SUMMARY_FILTER = new RegExp(`^${WS}*\\*${WS}*`, 'gm') +const CODE_FILTER = /^ {4}/gm + +// Twig escapes the level-2 heading (autoescape) but not the rest (|raw). +const escapeHtml = (s) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + +// phpDocumentor\Reflection\DocBlockFactory::stripDocComment() +function stripDocComment (docBlock) { + let comment = phpTrim(docBlock.replace(/[ \t]*(?:\/\*\*|\*\/|\*)?[ \t]?([^\n]*)/g, '$1')) + if (comment.endsWith('*/')) comment = phpTrim(comment.slice(0, -2)) + return comment.replace(/\r\n|\r/g, '\n') +} + +// phpDocumentor\Reflection\DocBlockFactory::splitDocBlock(), summary and description only. +const SPLIT = new RegExp( + '^' + + '(?:(#@\\+|#@-)\\n?)?' + + '(?:(?!@\\p{L})(' + + '[^\\n.]+' + + '(?:(?!\\.\\n|\\n{2})[\\n.](?![ \\t]*@\\p{L})[^\\n.]+)*' + + '\\.?' + + ')?)' + + '(?:\\s*(?!@\\p{L})([^\\n]+(?:\\n+(?![ \\t]*@\\p{L})[^\\n]+)*))?' + + '(\\s+[\\s\\S]*)?', + 'u' +) + +function splitDocBlock (comment) { + // A docblock that starts with a tag has no summary and no description. + if (comment.startsWith('@')) return { summary: '', description: '' } + const match = SPLIT.exec(comment.replace(/[ \t]+$/gm, '')) + return { summary: match[2] || '', description: match[3] || '' } +} + +// phpDocumentor\Reflection\DocBlock\DescriptionFactory +function renderDescription (description) { + if (!description || description === '0') return '' + const lines = description.split('\n') + if (lines.length > 1) { + let strip = Infinity + for (const line of lines.slice(1)) { + if (phpTrim(line) === '') continue + strip = Math.min(strip, line.length - line.replace(new RegExp(`^${PHP_WS}+`), '').length) + } + if (strip > 0 && strip !== Infinity) description = [lines[0], ...lines.slice(1).map((l) => l.slice(strip))].join('\n') + } + if (/\{@(?!\})/.test(description)) { + throw new Error(`inline phpDoc tags ({@...}) are not supported in the config samples: ${description}`) + } + return description.replace(/\{@\}/g, '@').replace(/\{\}/g, '}') +} + +function buildRow (docBlock, codeBlock) { + const { summary, description } = splitDocBlock(stripDocComment(docBlock)) + let desc = stringTrim(renderDescription(description)) + for (const [pattern, replacement] of DESCRIPTION_FILTERS) desc = desc.replace(pattern, replacement) + return { + summary: stringTrim(summary).replace(SUMMARY_FILTER, ''), + description: desc, + sectionHeader: codeBlock === null, + code: codeBlock === null ? '' : stringTrim(codeBlock).replace(CODE_FILTER, ''), + } +} + +function extractBlocks (source) { + const match = /\$CONFIG = array\(([\s\S]*)\);/.exec(source) || /\$CONFIG = \[([\s\S]*)\];/.exec(source) + if (!match) throw new Error('no `$CONFIG = [` or `$CONFIG = array(` found in the source file') + return match[1].split('/**') +} + +function parseBlock (block) { + const parts = ('/**' + block).split(' */') + const codeBlock = parts.length === 2 && phpTrim(parts[1]) !== '' ? parts[1] : null + return buildRow(parts[0] + ' */', codeBlock) +} + +function render (rows) { + return rows + .map(({ summary, description, sectionHeader, code }) => { + let out = sectionHeader ? `== ${escapeHtml(phpTrim(summary))}\n` : `=== ${phpTrim(summary)}\n` + if (description) out += `\n${phpTrim(description)}\n` + if (code) out += `\n==== Code Sample\n\n[source,php]\n....\n${phpTrim(code)}\n....\n` + return out + '\n' + }) + .join('') +} + +/** + * @param {string} source - contents of config.sample.php / config.apps.sample.php + * @param {string} existingTarget - current contents of the target page; everything + * above the "header end" marker is hand-written and is kept + * @returns {string} the new contents of the target page + */ +function convert (source, existingTarget) { + const current = phpTrim(existingTarget) + const marker = current.lastIndexOf(HEADER_END) + if (marker < 0) { + throw new Error(`the target page has no "${HEADER_END}" line, so the hand-written header cannot be kept`) + } + const header = current.slice(0, marker) + HEADER_END + '\n\n' + const rows = extractBlocks(source) + .filter((block) => phpTrim(block) !== '') + .map(parseBlock) + return header + render(rows) +} + +module.exports = { convert, HEADER_END } diff --git a/content/server/AGENTS.md b/content/server/AGENTS.md new file mode 100644 index 0000000..83a1ddb --- /dev/null +++ b/content/server/AGENTS.md @@ -0,0 +1,79 @@ +# server + +Agent guidelines for the server component under `content/server/`. Loaded on demand; repo-wide rules live in the root `AGENTS.md`. + +## Config Parameter Pages + +The server configuration parameter pages are generated from `config/config.sample.php` and +`config/config.apps.sample.php` in [owncloud/core](https://github.com/owncloud/core/tree/master/config) +by the `config-sample-to-adoc` Antora extension (`antora-extensions/config-sample-to-adoc.js`), which +replaces the retired owncloud/config-to-docs tool. This file is the rulebook that core's sample files point to. + + +The pages are `config_sample_php_parameters.adoc` and `config_apps_sample_php_parameters.adoc` under +`content/server//modules/admin_manual/pages/configuration/server/`. The `config-docs-in-sync` CI check +regenerates them and fails on any difference. + +### Working with the pages + +* **Edit above the line `// header end do not delete or edit this line`** in the page for the introduction. + That part is kept. Everything below it is machine output; a hand edit there is lost on the next run. + If the marker is deleted, or the page is missing, the generator fails instead of guessing. +* **To document a config key, change its comment in core**, then regenerate. +* **To regenerate**, check out the core branch matching the docs version (`master` for the version in + development), point the `config-sample-to-adoc` entry in `site.yml` at it and at that version's pages, set + `enabled: true`, and run `npm run antora-local`. `report_only: true` shows what would change without writing. + Review and commit the diff. Revert `enabled` before committing. + +The generator (`antora-extensions/lib/config-sample-to-adoc.js`) is a port of the PHP tool and reproduces its +output byte for byte, quirks included, so the published pages do not change. The quirks below are why the samples +in core have to be written carefully. + +### How a docblock becomes AsciiDoc + +Everything between `$CONFIG = [` and `];` is split on `/**`. Each docblock becomes one section, and the PHP code +following it (if any) becomes that section's code sample. + +* **The first line is the heading.** It ends at the first `.` at end-of-line, or the first blank line, whichever + comes first. A heading that swallows its second line usually lacks a trailing period. Avoid `..` and `...` in + it: two consecutive dots end the heading on the spot, mid-line. +* **A docblock with no code after it** produces a level-2 heading (`== ...`); **with code after it**, a level-3 + heading plus a `==== Code Sample` block. +* **Everything after the heading is emitted as raw AsciiDoc**, so write valid AsciiDoc there (labeled lists + `keyname::`, backtick spans, `**bold**`, link macros). + +What survives: blank ` *` lines (so multiple paragraphs work), a lone ` * +` line (list continuations), link macros +including `?`, `&` and `#` in the URL, backticks and `%` anywhere, `**bold**` in the body and mid-heading, and +flush-left `-` and `*` lists. + +### What breaks + +* **Never start a line with `@` followed by a letter.** It is parsed as a phpDoc tag, and everything from there to + the end of the docblock is dropped without warning. Indenting does not help. As the first line you get an empty + heading. +* **Never put a ` */` inside the code that follows a docblock**, e.g. `'key' => 'value', /* note */`. The block is + split on ` */`, so the whole code sample is dropped and the heading demotes from level 3 to level 2. A literal + `/**` inside a sample value invents a bogus extra section. +* **Never begin a line with a single character followed by `-`.** A filter deletes that leading run, so a line + wrapped onto `e-mail to the admin` is published as `-mail to the admin`. Mid-line is fine. The filter exists to + pull indented `-` list items flush left. +* **Never follow a line consisting of one character with a line starting `-`.** The filter spans whitespace + *including newlines*, so the character and the line break are both lost; a blank line between does not protect you. +* **Do not start a heading with `*`.** The leading asterisk is stripped: `**bold**` publishes as `*bold**`. +* **Keep `'`, `"`, `<`, `>` and `&` out of the first line of a level-2 heading.** It is HTML-escaped (an + apostrophe becomes `'`). Level-3 headings are not escaped, which is why `ownCloud's` survives today; do not + rely on that. +* **Avoid double backticks and tabs in the body.** They collapse to one backtick and to a single space. In a + heading both survive, and tabs inside a code sample survive too. +* **Avoid `{}` and `{@}` in the body.** They come out as `}` and `@`. Inline phpDoc tags such as `{@link ...}` are + not supported: the generator stops with an error. +* **Indentation.** Do not rely on it. The first body line always loses its own indentation and is excluded from the + calculation that dedents the rest, so the lines after it keep indentation only when some later non-blank line is + flush left. An indented line following a blank line becomes an AsciiDoc literal block. + +### Two rewrites to know about + +* `.. warning::` (a reStructuredText leftover) becomes `WARNING: `. The pattern is not anchored: *any* two + characters followed by ` warning::` match anywhere in a line. Write `WARNING:` directly. +* In a code sample, four leading spaces are stripped from each line, except the first, which is left-trimmed + completely. Start every sample flush at four spaces, as the existing entries do. diff --git a/content/server/CLAUDE.md b/content/server/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/content/server/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/extension-tests/config-sample-to-adoc.test.js b/extension-tests/config-sample-to-adoc.test.js new file mode 100644 index 0000000..684427d --- /dev/null +++ b/extension-tests/config-sample-to-adoc.test.js @@ -0,0 +1,100 @@ +'use strict' + +// Guards for antora-extensions/config-sample-to-adoc.js and its converter in +// antora-extensions/lib/config-sample-to-adoc.js, the port of the retired +// owncloud/config-to-docs PHP tool. +// +// quirks.expected.adoc was produced by that PHP tool from quirks.sample.php, not +// by this code, so the golden test is a regression guard against the original +// behaviour -- including the quirks documented in content/server/AGENTS.md that look +// like bugs but are what the published pages were built with. + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { convert, HEADER_END } = require('../antora-extensions/lib/config-sample-to-adoc') +const { register } = require('../antora-extensions/config-sample-to-adoc') + +const fixture = (name) => fs.readFileSync(path.join(__dirname, 'fixtures', 'config-sample-to-adoc', name), 'utf8') + +test('converter reproduces the output of the PHP tool, quirks included', () => { + assert.equal(convert(fixture('quirks.sample.php'), fixture('quirks.target.adoc')), fixture('quirks.expected.adoc')) +}) + +test('converter keeps the hand-written header and replaces what is below the marker', () => { + const out = convert(fixture('quirks.sample.php'), fixture('quirks.target.adoc')) + assert.ok(out.startsWith('= Quirks\n:toc: right\n\nHand-written intro.\n\n' + HEADER_END + '\n\n')) + assert.ok(!out.includes('stale generated content')) +}) + +test('converter accepts the array() syntax', () => { + const out = convert(' 1,\n);\n', HEADER_END) + assert.ok(out.endsWith('=== Heading.\n\n==== Code Sample\n\n[source,php]\n....\n\'a\' => 1,\n....\n\n')) +}) + +test('converter fails loudly instead of wiping a page', () => { + assert.throws(() => convert(fixture('quirks.sample.php'), '= No marker\n'), /hand-written header/) + assert.throws(() => convert(' convert('$CONFIG = [\n/**\n * Head.\n *\n * See {@link x}.\n */\n];', HEADER_END), /inline phpDoc tags/) +}) + +// Run the extension against real files in a temp playbook directory. +function setup (t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-sample-to-adoc-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + fs.writeFileSync(path.join(dir, 'config.sample.php'), fixture('quirks.sample.php')) + fs.writeFileSync(path.join(dir, 'target.adoc'), fixture('quirks.target.adoc')) + const logs = [] + t.mock.method(console, 'log', (message) => logs.push(message)) + return { dir, logs, target: path.join(dir, 'target.adoc') } +} + +function run (dir, config) { + let stopped + const handlers = {} + register.call({ once: (event, fn) => (handlers[event] = fn), stop: (code) => (stopped = code) }, { config }) + handlers.playbookBuilt({ playbook: { dir } }) + return { stopped } +} + +// Antora camelCases the keys of extension config before the extension sees them. +const pairs = [{ sourcePath: 'config.sample.php', targetPath: 'target.adoc' }] + +test('extension writes the regenerated page, resolving paths from the playbook dir', (t) => { + const { dir, logs, target } = setup(t) + const { stopped } = run(dir, { pairs }) + assert.equal(fs.readFileSync(target, 'utf8'), fixture('quirks.expected.adoc')) + assert.match(logs[0], /updated: target\.adoc/) + assert.equal(stopped, undefined) +}) + +test('extension reports a page that is already up to date', (t) => { + const { dir, logs } = setup(t) + run(dir, { pairs }) + run(dir, { pairs }) + assert.match(logs[1], /unchanged: target\.adoc/) +}) + +test('report_only logs the change and writes nothing', (t) => { + const { dir, logs, target } = setup(t) + run(dir, { pairs, reportOnly: true }) + assert.equal(fs.readFileSync(target, 'utf8'), fixture('quirks.target.adoc')) + assert.match(logs[0], /would update: target\.adoc/) +}) + +test('stop_after_processing stops the build with exit code 0', (t) => { + const { dir } = setup(t) + assert.equal(run(dir, { pairs, stopAfterProcessing: true }).stopped, 0) +}) + +test('extension fails on bad configuration and bad paths', (t) => { + const { dir } = setup(t) + assert.throws(() => run(dir, {}), /`pairs` is required/) + assert.throws(() => run(dir, { pairs: [{ sourcePath: 'config.sample.php' }] }), /exactly a `source_path` and a `target_path`/) + assert.throws(() => run(dir, { pairs, report_only: true }), /unknown config key.*report_only/) + assert.throws(() => run(dir, { pairs: [{ sourcePath: 'nope.php', targetPath: 'target.adoc' }] }), /cannot read source_path/) + assert.throws(() => run(dir, { pairs: [{ sourcePath: 'config.sample.php', targetPath: 'nope.adoc' }] }), /cannot read target_path/) +}) diff --git a/extension-tests/fixtures/config-sample-to-adoc/quirks.expected.adoc b/extension-tests/fixtures/config-sample-to-adoc/quirks.expected.adoc new file mode 100644 index 0000000..12451fc --- /dev/null +++ b/extension-tests/fixtures/config-sample-to-adoc/quirks.expected.adoc @@ -0,0 +1,103 @@ += Quirks +:toc: right + +Hand-written intro. + +// header end do not delete or edit this line + +== Section & "heading" <with> 'chars' + +=== Heading one. + +Paragraph with `backticks`, `double ticks`, 50% done, } braces, a tab here +and a link https://example.com/a?b=1&c=2#d[text]. + +keyname:: +description + +- flush list +- second item + +NOTE: and a wrapped e-mail +-mail address and x-ray + +WARNING: careful now + +==== Code Sample + +[source,php] +.... +'one' => 'value', +.... + +=== Heading without period +second line swallowed + +Body text. + +==== Code Sample + +[source,php] +.... +'two' => [ +'a' => 1, // trailing +'b' => "multi +line", +], +.... + +== Inline comment in code drops the sample. + +=== Heading with ellipsis. + +.. mid line. Rest + +Indented body: + four spaces kept + two spaces + +==== Code Sample + +[source,php] +.... +'four' => 'y', +.... + +=== + +==== Code Sample + +[source,php] +.... +'five' => 'z', +.... + +=== Unicode — ümlaut “quotes” heading. + +Body with ünïcode. + +==== Code Sample + +[source,php] +.... +'six' => 'ü', +.... + +=== Code containing a docblock marker. + +==== Code Sample + +[source,php] +.... +'seven' => ' +.... + +=== not a section + +==== Code Sample + +[source,php] +.... +', +.... + diff --git a/extension-tests/fixtures/config-sample-to-adoc/quirks.sample.php b/extension-tests/fixtures/config-sample-to-adoc/quirks.sample.php new file mode 100644 index 0000000..e8fc547 --- /dev/null +++ b/extension-tests/fixtures/config-sample-to-adoc/quirks.sample.php @@ -0,0 +1,77 @@ + 'chars' + */ + +/** + * Heading one. + * + * Paragraph with `backticks`, ``double ticks``, 50% done, {} braces, a tab here + * and a link https://example.com/a?b=1&c=2#d[text]. + * + * keyname:: + * description + * + * - flush list + * - second item + * + * NOTE: and a wrapped e-mail + * e-mail address and x-ray + * + * .. warning:: + * careful now + * + * @param string $ignored + * dropped by the tag split + */ +'one' => 'value', + +/** + * Heading without period + * second line swallowed + * + * Body text. + */ +'two' => [ + 'a' => 1, // trailing + 'b' => "multi + line", +], + +/** + * Inline comment in code drops the sample. + */ +'three' => 'x', /* note */ + +/** + * Heading with ellipsis... mid line. Rest + * + * Indented body: + * four spaces kept + * two spaces + */ +'four' => 'y', + +/** + * @tag first line is a tag + */ +'five' => 'z', + +/** + * Unicode — ümlaut “quotes” heading. + * + * Body with ünïcode. + */ +'six' => 'ü', + +/** + * Code containing a docblock marker. + */ +'seven' => '/** not a section */', + +]; diff --git a/extension-tests/fixtures/config-sample-to-adoc/quirks.target.adoc b/extension-tests/fixtures/config-sample-to-adoc/quirks.target.adoc new file mode 100644 index 0000000..8e78404 --- /dev/null +++ b/extension-tests/fixtures/config-sample-to-adoc/quirks.target.adoc @@ -0,0 +1,8 @@ += Quirks +:toc: right + +Hand-written intro. + +// header end do not delete or edit this line + +stale generated content diff --git a/scripts/config-sample-to-adoc.js b/scripts/config-sample-to-adoc.js new file mode 100644 index 0000000..20ca967 --- /dev/null +++ b/scripts/config-sample-to-adoc.js @@ -0,0 +1,19 @@ +'use strict' + +// Regenerate one server config parameter page from a core config sample: +// +// node scripts/config-sample-to-adoc.js +// +// Used by the config-docs-in-sync workflow, which has no Antora build. Locally, +// prefer the config-sample-to-adoc Antora extension (see site.yml). Errors exit +// non-zero. + +const fs = require('node:fs') +const { convert } = require('../antora-extensions/lib/config-sample-to-adoc') + +const [source, target] = process.argv.slice(2) +if (!source || !target) { + console.error('usage: node scripts/config-sample-to-adoc.js ') + process.exit(2) +} +fs.writeFileSync(target, convert(fs.readFileSync(source, 'utf8'), fs.readFileSync(target, 'utf8'))) diff --git a/site.yml b/site.yml index 1658415..7c88220 100644 --- a/site.yml +++ b/site.yml @@ -91,6 +91,20 @@ antora: attributefile: ./global-attributes.yml enabled: true - ./antora-extensions/llms-txt.js + # Maintainer tool, off in every real build: regenerates the server config + # parameter pages from an owncloud/core checkout (replaces config-to-docs). + # To use it, check out the core branch matching the docs version, point the + # paths below at it and at that version's pages, and set enabled: true. See + # the header of the extension and content/server/AGENTS.md. + - require: ./antora-extensions/config-sample-to-adoc.js + enabled: false + pairs: + - source_path: ../core/config/config.sample.php + target_path: content/server/11.0/modules/admin_manual/pages/configuration/server/config_sample_php_parameters.adoc + - source_path: ../core/config/config.apps.sample.php + target_path: content/server/11.0/modules/admin_manual/pages/configuration/server/config_apps_sample_php_parameters.adoc + report_only: false + stop_after_processing: true - 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