@redpanda-data/docs-extensions-and-macros 5.55.0 → 5.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/doc-tools.js +315 -7
- package/cli-utils/docs-links-to-xrefs.js +174 -0
- package/cli-utils/generated-marker.js +43 -0
- package/cli-utils/helm-commented-values.js +37 -3
- package/extension-utils/llms-utils.js +6 -2
- package/extension-utils/metadata-candidates.js +304 -0
- package/extension-utils/metadata-check.js +174 -0
- package/extension-utils/metadata-suggest.js +573 -0
- package/extension-utils/taxonomy-categories.js +106 -0
- package/extension-utils/taxonomy.js +566 -0
- package/extensions/REFERENCE.adoc +90 -4
- package/extensions/algolia-indexer/generate-index.js +13 -1
- package/extensions/algolia-indexer/index.js +4 -2
- package/extensions/convert-sitemap-to-markdown.js +47 -12
- package/extensions/convert-to-markdown.js +176 -0
- package/extensions/generate-fields-only-pages.js +14 -0
- package/extensions/modify-connect-tag-playbook.js +2 -2
- package/extensions/url-to-xref.js +92 -22
- package/extensions/validate-attributes.js +10 -1
- package/package.json +2 -1
- package/tools/check-metadata/index.js +523 -0
- package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
- package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
- package/tools/cloud-regions/render-cloud-regions.js +3 -0
- package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
- package/tools/dispatch-graph/classify-runs.js +128 -0
- package/tools/dispatch-graph/parse-workflows.js +499 -0
- package/tools/enterprise-features/detect-gating-changes.js +393 -0
- package/tools/enterprise-features/verify.js +2 -1
- package/tools/gen-rpk-ascii.py +7 -1
- package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
- package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
- package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
- package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
- package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
- package/tools/lint-strings/index.js +133 -3
- package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
- package/tools/lint-strings/surfaces/connect.js +72 -12
- package/tools/lint-strings/surfaces/crd.js +10 -6
- package/tools/metrics/metrics.py +11 -0
- package/tools/property-extractor/generate-handlebars-docs.js +9 -1
- package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +29 -8
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -24
- package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
- package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
- package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
- package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
- package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
- package/tools/rpk-docs/generate-rpk-docs.js +2 -0
- package/tools/rpk-docs/generate-x-env-partial.js +7 -4
- package/tools/rpk-docs/helpers/index.js +4 -1
- package/tools/rpk-docs/templates/command.hbs +1 -1
|
@@ -100,6 +100,29 @@ This extension generates an Algolia index for each version of each component. Th
|
|
|
100
100
|
|
|
101
101
|
NOTE: Only pages that include an `<article>` element with the `doc` class are indexed.
|
|
102
102
|
|
|
103
|
+
When the build has the docs taxonomy (see <<Docs taxonomy>>), every Product Docs record also carries these attributes, next to `categories`:
|
|
104
|
+
|
|
105
|
+
[cols="1,3"]
|
|
106
|
+
|===
|
|
107
|
+
|Attribute |Value
|
|
108
|
+
|
|
109
|
+
|`topics`, `topicIds`
|
|
110
|
+
|The topic concepts of the page's authored `page-categories`, with the broader concepts they imply (Replication implies High Availability). Labels and IDs.
|
|
111
|
+
|
|
112
|
+
|`useCases`, `useCaseIds`
|
|
113
|
+
|The use-case concepts of the page's categories.
|
|
114
|
+
|
|
115
|
+
|`technologies`, `technologyIds`
|
|
116
|
+
|The technology concepts of the page's categories, those a topic implies (Iceberg Topics implies Apache Iceberg), and the technology whose `component` is the page's component (a page in `connect` is about Redpanda Connect).
|
|
117
|
+
|
|
118
|
+
|`deployment`, `deploymentIds`
|
|
119
|
+
|The deployment concept of the page's `env-*` attributes, and its broader concept.
|
|
120
|
+
|===
|
|
121
|
+
|
|
122
|
+
`categories` is unchanged: it is still the legacy list that validate-attributes writes, because labs-home filters on it. Labs records get none of the new attributes, and keep their string `deployment`.
|
|
123
|
+
|
|
124
|
+
The Algolia index settings are not in any repository. To filter or facet on the new attributes, add them to `attributesForFaceting` in the index settings.
|
|
125
|
+
|
|
103
126
|
=== Environment variables
|
|
104
127
|
|
|
105
128
|
- `ALGOLIA_ADMIN_API_KEY` (required)
|
|
@@ -730,6 +753,17 @@ This extension does not require any environment variables.
|
|
|
730
753
|
|
|
731
754
|
There are no configurable options for this extension. It operates based on site attributes defined in `add-global-attributes.js` to determine valid categories and subcategories.
|
|
732
755
|
|
|
756
|
+
=== With the docs taxonomy
|
|
757
|
+
|
|
758
|
+
When the build has the docs taxonomy (see <<Docs taxonomy>>), each `page-categories` value is also resolved through it:
|
|
759
|
+
|
|
760
|
+
* A value from `valid-categories.yml` passes through unchanged, so `page-categories` is exactly what it was without the taxonomy. When the value is a deprecated concept, the build logs one warning per value, with the number of pages, a sample, and the replacement. Labs and solutions pages are left to the legacy check: labs is retiring, and solutions-catalog resolves solution categories itself.
|
|
761
|
+
* A value only the taxonomy knows, such as a new label like `Iceberg Topics`, is replaced by the legacy category of the concept it resolves to, or dropped when there is none. It is not reported as invalid.
|
|
762
|
+
* A value neither knows warns per page and is dropped, as before.
|
|
763
|
+
* A legacy value the taxonomy does not resolve warns as a gap in `taxonomy.yml`.
|
|
764
|
+
|
|
765
|
+
Nothing here fails the build. The authored list is kept in `page-categories-authored` for the extensions that resolve concepts from it.
|
|
766
|
+
|
|
733
767
|
=== Registration
|
|
734
768
|
|
|
735
769
|
Register the `validate-attributes` extension in the Antora playbook under the `antora.extensions` key like so:
|
|
@@ -787,7 +821,26 @@ antora:
|
|
|
787
821
|
- require: '@redpanda-data/docs-extensions-and-macros/extensions/related-labs-extension.js'
|
|
788
822
|
----
|
|
789
823
|
|
|
790
|
-
==
|
|
824
|
+
== Docs taxonomy
|
|
825
|
+
|
|
826
|
+
`extension-utils/taxonomy.js` loads the docs taxonomy, the controlled vocabulary in the docs repository at `shared/modules/ROOT/attachments/taxonomy.yml`, and validates it against `taxonomy.schema.json` next to it. It is not an extension to register: validate-attributes and the Algolia indexer load it on first use and share it on `siteCatalog.taxonomy`.
|
|
827
|
+
|
|
828
|
+
The file is read from the content catalog as an attachment of the `shared` component, so it is never merged into page attributes. <<Global attributes>> only reads `modules/ROOT/partials/`.
|
|
829
|
+
|
|
830
|
+
The file has seven concept schemes (topic, task, content-type, use-case, industry, technology, deployment). Every concept has an `id`, a `prefLabel`, `altLabels`, a `definition`, a `broader` concept, `related` concepts, and `deprecated` with `replacedBy`. Every old category is either the label of a live concept or a deprecated concept whose `replacedBy` names its replacement.
|
|
831
|
+
|
|
832
|
+
At load, the build fails when the file does not match the schema, when an ID repeats, when a label or altLabel repeats in any scheme (case-insensitive), or when a `broader`, `related`, `implies`, or `replacedBy` ID does not exist. When the file is absent, the build logs one warning and every consumer keeps its behavior without the taxonomy, so the docs repository and this package can deploy in either order. When the schema is absent, only the structural checks run.
|
|
833
|
+
|
|
834
|
+
`Taxonomy#resolve(value)` returns the concept a label, altLabel, or ID names, and for a deprecated concept its `replacedBy` IDs. `Taxonomy#expand(ids)` adds the broader concepts and `implies` targets as implied. `pageFacets` turns a page's authored categories, deployment type, and component into the search facets.
|
|
835
|
+
|
|
836
|
+
Outside Antora, `loadTaxonomyFrom({ source, root })` loads the same file from a path or URL, or else from the first that exists of the docs checkout at `root`, the docs checkout at `$REDPANDA_DOCS_PATH`, and the published copy at https://docs.redpanda.com/shared/_attachments/taxonomy.yml. It validates against the schema beside whichever it used.
|
|
837
|
+
|
|
838
|
+
The metadata rules live in `extension-utils/metadata-check.js` (`checkPageMetadata`). validate-attributes runs them on Product Docs pages, solutions-catalog on solution overviews, and `doc-tools check-metadata` on page headers in a pull request, so the build and the PR check cannot disagree. See `check-metadata` in the CLI reference for the rules and their severities, and `.github/workflows/check-metadata.yml` for the reusable PR check.
|
|
839
|
+
|
|
840
|
+
Suggestions live in `extension-utils/metadata-suggest.js` (`suggestForPage`), which `doc-tools check-metadata --suggest` runs. It matches every live concept's labels against a page's title, description, path, headings, and body, deterministically and with no model, and returns up to a few concepts per facet that the page does not carry yet, each with a score and the evidence for it. It honors the facet cardinalities in the taxonomy, `implies`, and `broader`, and never suggests a deprecated concept. Another tool that tags content against the taxonomy should call it rather than match labels itself, so every tagger agrees.
|
|
841
|
+
|
|
842
|
+
`extension-utils/metadata-candidates.js` finds the other side: frequent, distinctive terms on a page that no concept's label matches (`pageCandidates`), and the same across a repository with the pages each is on (`aggregateCandidates`). `doc-tools check-metadata --suggest` lists them per page and `--candidates-only` aggregates them. They are evidence for proposing a new concept or altLabel, deterministic like the suggestions.
|
|
843
|
+
|
|
791
844
|
|
|
792
845
|
This extension collects Asciidoc attributes from the https://github.com/redpanda-data/docs-site[`shared` component] or a local YAML file and makes them available to all component versions. Having global attributes is useful for consistent configuration of local and production builds.
|
|
793
846
|
|
|
@@ -1300,8 +1353,38 @@ The extension exports these attributes to YAML frontmatter:
|
|
|
1300
1353
|
- `page-categories`: Categories
|
|
1301
1354
|
- `page-topic-type`: Topic type (tutorial, how-to, concept, reference)
|
|
1302
1355
|
- `page-personas`: Target audience
|
|
1356
|
+
- `page-generated-content`: Always present: `none`, `partial` (the page includes generated files) or `all` (the page itself was generated)
|
|
1357
|
+
- `page-autogenerated`, `page-generated-by`, `page-generated-from`: Set when the page itself was written by a generator: the tool that wrote it, and the source it names (`page-generated-from` only when the marker names one)
|
|
1358
|
+
- `page-generated-sources`: One entry per include whose file was written by a generator: the include target, the generator, its version when the marker carries one, and the source it names
|
|
1359
|
+
- `page-introduced-in`, `page-introduced-in-product`: Release that introduced the page's feature, as a bare `X.Y.Z`, and the product it belongs to (generated Connect reference pages, self-managed only)
|
|
1303
1360
|
- Custom attributes from allowlist
|
|
1304
1361
|
|
|
1362
|
+
=== Generated content provenance
|
|
1363
|
+
|
|
1364
|
+
Every doc-tools generator writes one comment line near the top of each file it produces, built by `cli-utils/generated-marker.js`:
|
|
1365
|
+
|
|
1366
|
+
----
|
|
1367
|
+
// This content is autogenerated. Do not edit manually. Generated by doc-tools (<generator>) from <source>. <how to change it>
|
|
1368
|
+
----
|
|
1369
|
+
|
|
1370
|
+
The marker names no doc-tools version. Generated files are committed, so a version would rewrite the first line of every generated file on each release even when nothing else changed. A marker that does carry a token after `doc-tools` (`doc-tools 5.55.0 (rpk-docs)`) is still read, and the token is reported as the version when it is one. Pages generated during the site build, which are never committed, record the version of the package that built them.
|
|
1371
|
+
|
|
1372
|
+
While the catalog still holds AsciiDoc (`contentClassified`), the extension reads that marker on every page and on every file the page includes, then records what it found as page attributes (`documentsConverted`) so both the Markdown twin and UI templates can use it. Every twin states `page-generated-content`, so `none` is a statement and not an absence. A page that is generated end to end gets `all`, `page-autogenerated: true`, `page-generated-by` and `page-generated-from`; a hand-written page that includes generated partials gets `partial` and `page-generated-sources`, for example:
|
|
1373
|
+
|
|
1374
|
+
[,yaml]
|
|
1375
|
+
----
|
|
1376
|
+
page-generated-content: partial
|
|
1377
|
+
page-generated-sources:
|
|
1378
|
+
- include: connect:components:partial$descriptions/inputs/postgres_cdc.adoc
|
|
1379
|
+
generator: rpcn-connector-docs
|
|
1380
|
+
version: null
|
|
1381
|
+
source: the component spec in redpanda-data/connect
|
|
1382
|
+
----
|
|
1383
|
+
|
|
1384
|
+
Older files that carry only `// This content is autogenerated. Do not edit manually.` are still recognized; they report a null generator and source, and the twin has no `page-generated-from`. A source is read only right after `doc-tools (<generator>)` or after `autogenerated`, so a later "from" in a legacy banner's prose is not taken for one. A comment counts as a marker only when it opens with `This content is autogenerated`, `Autogenerated`, `AUTO-GENERATED`, or `Generated by doc-tools`, so a hand-written comment such as `// Generated by running rpk cluster info` does not. The partials that ship in the Connect release asset carry connect's own banner, which names the file that registers the component as `<repository>[@<version>]:<path>` (for example `// This content is autogenerated from the component spec in redpanda-data/benthos@v4.84.0:internal/impl/pure/processor_mapping.go. Do not edit manually.`), or the repository when the generator could not read the name; either reports that source and a null generator. An extension that creates pages at build time sets `page.generatedProvenance` on the file itself, which this scan honors.
|
|
1385
|
+
|
|
1386
|
+
Include detection is best effort. Each `include::` target at the start of a line is resolved as a partial in the page's context (falling back to pages and examples), so a page-relative include such as `include::shared.adoc[]` is looked up as `partial$shared.adoc`. Includes inside comment blocks or inactive `ifdef` branches are counted, targets with an attribute reference are skipped, and nested includes (a hand-written partial that includes a generated one) are not followed.
|
|
1387
|
+
|
|
1305
1388
|
=== Example output
|
|
1306
1389
|
|
|
1307
1390
|
[,yaml]
|
|
@@ -1396,7 +1479,8 @@ The `convert-sitemap-to-markdown` extension generates human-readable markdown ve
|
|
|
1396
1479
|
- Generates individual `.md` files for each sitemap
|
|
1397
1480
|
- Creates master `sitemap-all.md` combining all pages
|
|
1398
1481
|
- Organizes URLs by component/version
|
|
1399
|
-
-
|
|
1482
|
+
- Uses each page's document title and `:description:`, falling back to a title from the URL for files that aren't Antora pages
|
|
1483
|
+
- Includes page metadata (modified dates, priority) on a nested line under each entry, so the entry line keeps the `- [Title](url): description` form
|
|
1400
1484
|
- Uses Antora content catalog (no filesystem operations)
|
|
1401
1485
|
|
|
1402
1486
|
=== Configuration
|
|
@@ -1440,8 +1524,10 @@ Total pages: 4,126
|
|
|
1440
1524
|
|
|
1441
1525
|
### Current
|
|
1442
1526
|
|
|
1443
|
-
- [
|
|
1444
|
-
-
|
|
1527
|
+
- [Introduction to Redpanda](https://docs.redpanda.com/current/get-started/): Learn about Redpanda and its features.
|
|
1528
|
+
- modified: 2024-03-29
|
|
1529
|
+
- [Deploy](https://docs.redpanda.com/current/deploy/)
|
|
1530
|
+
- modified: 2024-03-28
|
|
1445
1531
|
----
|
|
1446
1532
|
|
|
1447
1533
|
=== Use cases
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { parse } = require('node-html-parser')
|
|
4
4
|
const { decode } = require('html-entities')
|
|
5
5
|
const path = require('path')
|
|
6
|
+
const { pageFacets, authoredCategories } = require('../../extension-utils/taxonomy')
|
|
6
7
|
|
|
7
8
|
// Create encoder once at module scope for efficiency
|
|
8
9
|
const textEncoder = new TextEncoder()
|
|
@@ -20,10 +21,12 @@ const textEncoder = new TextEncoder()
|
|
|
20
21
|
* @param {Boolean} config.indexLatestOnly - If true, only index the latest version of any given page.
|
|
21
22
|
* @param {Array} config.excludes - CSS selectors for elements to exclude from indexing.
|
|
22
23
|
* @param {Object} config.logger - Logger to use
|
|
24
|
+
* @param {Object} [config.taxonomy] - The site taxonomy (extension-utils/taxonomy.js). When
|
|
25
|
+
* set, Product Docs records also carry topics, useCases, technologies, and deployment.
|
|
23
26
|
* @typedef {Object} SearchIndexData
|
|
24
27
|
* @returns {SearchIndexData} A data object that contains the Algolia index
|
|
25
28
|
*/
|
|
26
|
-
function generateIndex (playbook, contentCatalog, { indexLatestOnly = false, excludes = [], logger } = {}) {
|
|
29
|
+
function generateIndex (playbook, contentCatalog, { indexLatestOnly = false, excludes = [], logger, taxonomy } = {}) {
|
|
27
30
|
// Use provided logger or create a no-op logger for tests
|
|
28
31
|
if (!logger) {
|
|
29
32
|
logger = process.env.NODE_ENV === 'test'
|
|
@@ -288,6 +291,10 @@ function generateIndex (playbook, contentCatalog, { indexLatestOnly = false, exc
|
|
|
288
291
|
? page.asciidoc.attributes['page-categories'].split(',').map(category => category.trim())
|
|
289
292
|
: []
|
|
290
293
|
|
|
294
|
+
const facets = taxonomy && component.name !== 'labs'
|
|
295
|
+
? pageFacets(taxonomy, { categories: authoredCategories(page.asciidoc?.attributes), deployment, component: component.name })
|
|
296
|
+
: null
|
|
297
|
+
|
|
291
298
|
const commercialNames = page.asciidoc?.attributes['page-commercial-names']
|
|
292
299
|
? page.asciidoc.attributes['page-commercial-names'].split(',').map(name => name.trim())
|
|
293
300
|
: []
|
|
@@ -332,6 +339,11 @@ function generateIndex (playbook, contentCatalog, { indexLatestOnly = false, exc
|
|
|
332
339
|
indexItem.breadcrumbs = breadcrumbs
|
|
333
340
|
indexItem.type = 'Doc'
|
|
334
341
|
indexItem._tags = Array.isArray(tag) ? tag : [tag]
|
|
342
|
+
// Taxonomy facets, next to `categories` rather than instead of it:
|
|
343
|
+
// labs-home filters on `categories`, so it stays the legacy list until
|
|
344
|
+
// every consumer has moved. Arrays of prefLabels for display and
|
|
345
|
+
// faceting, plus the concept IDs, which do not change when a label does.
|
|
346
|
+
if (taxonomy) Object.assign(indexItem, facets)
|
|
335
347
|
} else {
|
|
336
348
|
indexItem.deployment = deployment
|
|
337
349
|
indexItem.type = 'Lab'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const generateIndex = require('./generate-index')
|
|
4
|
+
const { getTaxonomy } = require('../../extension-utils/taxonomy')
|
|
4
5
|
const { raiseListenerLimit } = require('../util/raise-listener-limit')
|
|
5
6
|
const algoliasearch = require('algoliasearch')
|
|
6
7
|
const http = require('http')
|
|
@@ -51,8 +52,9 @@ function register ({
|
|
|
51
52
|
})
|
|
52
53
|
const index = client.initIndex(process.env.ALGOLIA_INDEX_NAME)
|
|
53
54
|
|
|
54
|
-
this.on('beforePublish', async ({ playbook, contentCatalog }) => {
|
|
55
|
-
const
|
|
55
|
+
this.on('beforePublish', async ({ playbook, contentCatalog, siteCatalog }) => {
|
|
56
|
+
const taxonomy = getTaxonomy({ contentCatalog, siteCatalog, logger })
|
|
57
|
+
const algolia = generateIndex(playbook, contentCatalog, { indexLatestOnly, excludes, logger, taxonomy })
|
|
56
58
|
|
|
57
59
|
if (!algolia || Object.keys(algolia).length === 0) {
|
|
58
60
|
logger.warn('No content to index for Algolia')
|
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
const path = require('path')
|
|
16
16
|
const { parseStringPromise } = require('xml2js')
|
|
17
|
+
const { decode } = require('html-entities')
|
|
18
|
+
const { toMarkdownUrl } = require('../extension-utils/url-utils')
|
|
17
19
|
|
|
18
20
|
module.exports.register = function ({ config }) {
|
|
19
21
|
const logger = this.getLogger('convert-sitemap-to-markdown')
|
|
20
22
|
|
|
21
23
|
this
|
|
22
|
-
.on('beforePublish', async ({ playbook, siteCatalog }) => {
|
|
24
|
+
.on('beforePublish', async ({ playbook, siteCatalog, contentCatalog }) => {
|
|
23
25
|
const startTime = Date.now()
|
|
24
26
|
|
|
25
27
|
logger.info('Sitemap to markdown converter starting...')
|
|
@@ -37,10 +39,12 @@ module.exports.register = function ({ config }) {
|
|
|
37
39
|
|
|
38
40
|
logger.info(`Found ${sitemapFiles.length} sitemap file(s)`)
|
|
39
41
|
|
|
42
|
+
const pageInfo = collectPageInfo(contentCatalog)
|
|
43
|
+
|
|
40
44
|
// Convert each sitemap and collect all URLs
|
|
41
45
|
const allUrls = []
|
|
42
46
|
for (const sitemapFile of sitemapFiles) {
|
|
43
|
-
const urls = await convertSitemapToMarkdown(sitemapFile, siteCatalog, logger)
|
|
47
|
+
const urls = await convertSitemapToMarkdown(sitemapFile, siteCatalog, logger, pageInfo)
|
|
44
48
|
if (urls) {
|
|
45
49
|
allUrls.push(...urls)
|
|
46
50
|
}
|
|
@@ -48,7 +52,7 @@ module.exports.register = function ({ config }) {
|
|
|
48
52
|
|
|
49
53
|
// Create combined master sitemap if we have multiple sitemaps
|
|
50
54
|
if (sitemapFiles.length > 1 && allUrls.length > 0) {
|
|
51
|
-
await createMasterSitemap(sitemapFiles, allUrls, siteCatalog, logger)
|
|
55
|
+
await createMasterSitemap(sitemapFiles, allUrls, siteCatalog, logger, pageInfo)
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
|
|
@@ -63,7 +67,7 @@ module.exports.register = function ({ config }) {
|
|
|
63
67
|
/**
|
|
64
68
|
* Create a master combined sitemap from all individual sitemaps
|
|
65
69
|
*/
|
|
66
|
-
async function createMasterSitemap(sitemapFiles, allUrls, siteCatalog, logger) {
|
|
70
|
+
async function createMasterSitemap(sitemapFiles, allUrls, siteCatalog, logger, pageInfo) {
|
|
67
71
|
const sitemapCount = sitemapFiles.length
|
|
68
72
|
const sitemapWord = sitemapCount === 1 ? 'sitemap' : 'sitemaps'
|
|
69
73
|
|
|
@@ -81,7 +85,7 @@ async function createMasterSitemap(sitemapFiles, allUrls, siteCatalog, logger) {
|
|
|
81
85
|
|
|
82
86
|
// Add all URLs grouped by component
|
|
83
87
|
if (allUrls.length > 0) {
|
|
84
|
-
markdown += convertUrlsetToMarkdown(allUrls)
|
|
88
|
+
markdown += convertUrlsetToMarkdown(allUrls, pageInfo)
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
// Add to site catalog
|
|
@@ -97,7 +101,7 @@ async function createMasterSitemap(sitemapFiles, allUrls, siteCatalog, logger) {
|
|
|
97
101
|
* Convert a sitemap file to markdown
|
|
98
102
|
* Returns the URLs found in this sitemap for aggregation
|
|
99
103
|
*/
|
|
100
|
-
async function convertSitemapToMarkdown(sitemapFile, siteCatalog, logger) {
|
|
104
|
+
async function convertSitemapToMarkdown(sitemapFile, siteCatalog, logger, pageInfo) {
|
|
101
105
|
const xmlContent = sitemapFile.contents.toString('utf8')
|
|
102
106
|
const basename = path.basename(sitemapFile.out.path)
|
|
103
107
|
const outputPath = basename.replace(/\.xml$/, '.md')
|
|
@@ -113,7 +117,7 @@ async function convertSitemapToMarkdown(sitemapFile, siteCatalog, logger) {
|
|
|
113
117
|
// Handle standard sitemap
|
|
114
118
|
if (parsed.urlset && parsed.urlset.url) {
|
|
115
119
|
urls = parsed.urlset.url
|
|
116
|
-
markdown += convertUrlsetToMarkdown(urls)
|
|
120
|
+
markdown += convertUrlsetToMarkdown(urls, pageInfo)
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
// Handle sitemap index
|
|
@@ -139,7 +143,7 @@ async function convertSitemapToMarkdown(sitemapFile, siteCatalog, logger) {
|
|
|
139
143
|
/**
|
|
140
144
|
* Convert URL entries to markdown
|
|
141
145
|
*/
|
|
142
|
-
function convertUrlsetToMarkdown(urls) {
|
|
146
|
+
function convertUrlsetToMarkdown(urls, pageInfo = new Map()) {
|
|
143
147
|
let markdown = '## Pages\n\n'
|
|
144
148
|
markdown += `Total pages: ${urls.length.toLocaleString()}\n\n`
|
|
145
149
|
|
|
@@ -156,18 +160,23 @@ function convertUrlsetToMarkdown(urls) {
|
|
|
156
160
|
const priority = url.priority ? url.priority[0] : ''
|
|
157
161
|
|
|
158
162
|
if (loc) {
|
|
159
|
-
|
|
163
|
+
const page = pageInfo.get(new URL(loc).pathname)
|
|
164
|
+
const title = (page && page.title) || extractPageTitle(loc)
|
|
165
|
+
markdown += `- [${title}](${loc})`
|
|
166
|
+
if (page && page.description) markdown += `: ${page.description}`
|
|
167
|
+
markdown += '\n'
|
|
160
168
|
|
|
169
|
+
// Metadata goes on a nested line. Readers of the llms.txt link form
|
|
170
|
+
// take everything after `: ` as the description, so metadata on the
|
|
171
|
+
// entry line would become part of it.
|
|
161
172
|
const metadata = []
|
|
162
173
|
if (lastmod) metadata.push(`modified: ${lastmod}`)
|
|
163
174
|
if (changefreq) metadata.push(`frequency: ${changefreq}`)
|
|
164
175
|
if (priority) metadata.push(`priority: ${priority}`)
|
|
165
176
|
|
|
166
177
|
if (metadata.length > 0) {
|
|
167
|
-
markdown += `
|
|
178
|
+
markdown += ` - ${metadata.join(', ')}\n`
|
|
168
179
|
}
|
|
169
|
-
|
|
170
|
-
markdown += '\n'
|
|
171
180
|
}
|
|
172
181
|
}
|
|
173
182
|
|
|
@@ -177,6 +186,32 @@ function convertUrlsetToMarkdown(urls) {
|
|
|
177
186
|
return markdown
|
|
178
187
|
}
|
|
179
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Map each published page's HTML and Markdown paths to its document title and
|
|
191
|
+
* description, so sitemap entries carry the same text as the page itself
|
|
192
|
+
* rather than a title guessed from the URL.
|
|
193
|
+
*/
|
|
194
|
+
function collectPageInfo(contentCatalog) {
|
|
195
|
+
const info = new Map()
|
|
196
|
+
if (!contentCatalog) return info
|
|
197
|
+
for (const page of contentCatalog.getPages((p) => p.pub && p.pub.url && p.out)) {
|
|
198
|
+
const asciidoc = page.asciidoc || {}
|
|
199
|
+
const title = plainText(asciidoc.doctitle || asciidoc.navtitle)
|
|
200
|
+
const description = plainText(asciidoc.attributes && asciidoc.attributes.description)
|
|
201
|
+
if (!title && !description) continue
|
|
202
|
+
const entry = { title: title.replace(/([[\]])/g, '\\$1'), description }
|
|
203
|
+
info.set(page.pub.url, entry)
|
|
204
|
+
info.set(toMarkdownUrl(page.pub.url), entry)
|
|
205
|
+
}
|
|
206
|
+
return info
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Converted titles and descriptions are HTML. Strip tags before decoding so an
|
|
210
|
+
// escaped placeholder such as `<name>` survives as `<name>`.
|
|
211
|
+
function plainText(html) {
|
|
212
|
+
return decode(String(html || '').replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim()
|
|
213
|
+
}
|
|
214
|
+
|
|
180
215
|
/**
|
|
181
216
|
* Convert sitemap index to markdown
|
|
182
217
|
*/
|
|
@@ -8,6 +8,98 @@ const TurndownService = require('turndown')
|
|
|
8
8
|
const turndownPluginGfm = require('turndown-plugin-gfm')
|
|
9
9
|
const { gfm } = turndownPluginGfm
|
|
10
10
|
|
|
11
|
+
// Every doc-tools generator writes one comment line near the top of each file
|
|
12
|
+
// it produces, in this shape:
|
|
13
|
+
//
|
|
14
|
+
// // This content is autogenerated. Do not edit manually. Generated by doc-tools (<generator>) from <source>. <how to change it>
|
|
15
|
+
//
|
|
16
|
+
// (cli-utils/generated-marker.js builds it.) Some files written before the
|
|
17
|
+
// version was dropped carry one after "doc-tools". Older files carry only the
|
|
18
|
+
// first two sentences. The twin's front matter reports what the marker says, so a
|
|
19
|
+
// reader (or an agent) can tell generated text from hand-written text on the
|
|
20
|
+
// same page and knows where the fix goes. The comment must open with the
|
|
21
|
+
// generated phrase, which every marker in the docs repos does ("This content
|
|
22
|
+
// is autogenerated", "AUTO-GENERATED FILE", "Generated by `doc-tools ...`"),
|
|
23
|
+
// so a prose comment such as "// Table generated by the build" is not one.
|
|
24
|
+
// "Generated by" counts only when doc-tools follows, so a hand-written
|
|
25
|
+
// "// Generated by running rpk cluster info, then edited" is not one either.
|
|
26
|
+
const GENERATED_MARKER_SCAN_LINES = 15
|
|
27
|
+
const GENERATED_MARKER_RE = /^\/\/\s*((?:this content is auto-?generated\b|auto-?generated\b|generated by\s+`?doc-tools\b).*)$/i
|
|
28
|
+
// "doc-tools (property-docs)", or with a version token: "doc-tools 5.55.0
|
|
29
|
+
// (rpk-docs)". A token that is not a version (the Python helpers once wrote
|
|
30
|
+
// "unknown") still yields the generator.
|
|
31
|
+
const MARKER_TOOL_RE = /doc-tools(?:\s+([^\s()]+))?\s*\(([^)]+)\)/i
|
|
32
|
+
// The source is named only right after the tool, or after "autogenerated"
|
|
33
|
+
// (connect's own banner), so a "from" later in a legacy banner's prose is not
|
|
34
|
+
// read as one.
|
|
35
|
+
const MARKER_SOURCE_RE = /(?:doc-tools(?:\s+[^\s()]+)?\s*\([^)]+\)|auto-?generated)\s+from\s+(.+?)(?:\.(?=\s|$)|$)/i
|
|
36
|
+
const INCLUDE_TARGET_RE = /^include::([^[\s{]+)\[/gm
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the generated-content marker from the first lines of an AsciiDoc file.
|
|
40
|
+
* @param {Buffer|string} contents - The file's AsciiDoc source
|
|
41
|
+
* @returns {{note: string, generator: (string|null), version: (string|null), source: (string|null)}|null}
|
|
42
|
+
*/
|
|
43
|
+
function readGeneratedMarker (contents) {
|
|
44
|
+
if (!contents) return null
|
|
45
|
+
const text = Buffer.isBuffer(contents) ? contents.toString('utf8') : String(contents)
|
|
46
|
+
const lines = text.split(/\r?\n/, GENERATED_MARKER_SCAN_LINES)
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const m = line.match(GENERATED_MARKER_RE)
|
|
49
|
+
if (!m) continue
|
|
50
|
+
const note = m[1].trim()
|
|
51
|
+
const tool = note.match(MARKER_TOOL_RE)
|
|
52
|
+
const generator = tool ? tool[2].trim() : null
|
|
53
|
+
const token = tool && tool[1] ? tool[1].replace(/^v(?=\d)/i, '') : null
|
|
54
|
+
const version = token && /^\d/.test(token) ? token : null
|
|
55
|
+
const source = (note.match(MARKER_SOURCE_RE) || [])[1] || null
|
|
56
|
+
return { note, generator, version, source: source ? source.trim() : null }
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* List the include targets in an AsciiDoc source. Targets that carry an
|
|
63
|
+
* attribute reference cannot be resolved here and are skipped.
|
|
64
|
+
* @param {Buffer|string} contents - The file's AsciiDoc source
|
|
65
|
+
* @returns {string[]} Unique include targets, in order of first appearance
|
|
66
|
+
*/
|
|
67
|
+
function findIncludeTargets (contents) {
|
|
68
|
+
if (!contents) return []
|
|
69
|
+
const text = Buffer.isBuffer(contents) ? contents.toString('utf8') : String(contents)
|
|
70
|
+
const targets = []
|
|
71
|
+
for (const m of text.matchAll(INCLUDE_TARGET_RE)) {
|
|
72
|
+
if (!targets.includes(m[1])) targets.push(m[1])
|
|
73
|
+
}
|
|
74
|
+
return targets
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Work out which text on a page is generated: the page itself, and any
|
|
79
|
+
* include whose file carries the marker. Runs while the catalog still holds
|
|
80
|
+
* AsciiDoc, before conversion.
|
|
81
|
+
* @param {Object} page - Antora page file (src, contents)
|
|
82
|
+
* @param {Object} contentCatalog - Antora content catalog
|
|
83
|
+
* @returns {{page: (Object|null), includes: Object[]}}
|
|
84
|
+
*/
|
|
85
|
+
function collectGeneratedProvenance (page, contentCatalog) {
|
|
86
|
+
const own = readGeneratedMarker(page.contents)
|
|
87
|
+
const includes = []
|
|
88
|
+
for (const target of findIncludeTargets(page.contents)) {
|
|
89
|
+
let resource
|
|
90
|
+
try {
|
|
91
|
+
resource = contentCatalog.resolveResource(target, page.src, 'partial', ['partial', 'page', 'example'])
|
|
92
|
+
} catch (err) {
|
|
93
|
+
resource = null
|
|
94
|
+
}
|
|
95
|
+
if (!resource || !resource.contents) continue
|
|
96
|
+
const marker = readGeneratedMarker(resource.contents)
|
|
97
|
+
if (!marker) continue
|
|
98
|
+
includes.push({ include: target, generator: marker.generator, version: marker.version, source: marker.source, note: marker.note })
|
|
99
|
+
}
|
|
100
|
+
return { page: own, includes }
|
|
101
|
+
}
|
|
102
|
+
|
|
11
103
|
/**
|
|
12
104
|
* Decode HTML entities in a string
|
|
13
105
|
* @param {string} str - String with potential HTML entities
|
|
@@ -70,6 +162,17 @@ function generateFrontmatter(page) {
|
|
|
70
162
|
'page-eol-date',
|
|
71
163
|
'page-git-created-date',
|
|
72
164
|
'page-git-modified-date',
|
|
165
|
+
// Generated-content provenance, from the markers generators write
|
|
166
|
+
'page-generated-content',
|
|
167
|
+
'page-autogenerated',
|
|
168
|
+
'page-generated-by',
|
|
169
|
+
'page-generated-from',
|
|
170
|
+
'page-generated-sources',
|
|
171
|
+
// Release that introduced the page's feature, and the product that
|
|
172
|
+
// release belongs to. Generated Connect reference pages set them on
|
|
173
|
+
// self-managed pages; the UI shows them as a "Since" badge.
|
|
174
|
+
'page-introduced-in',
|
|
175
|
+
'page-introduced-in-product',
|
|
73
176
|
// Component-specific version attributes (from antora.yml)
|
|
74
177
|
'latest-redpanda-tag', // Redpanda docker tag (e.g., v25.3.5)
|
|
75
178
|
'latest-console-tag',
|
|
@@ -107,6 +210,29 @@ function generateFrontmatter(page) {
|
|
|
107
210
|
return
|
|
108
211
|
}
|
|
109
212
|
|
|
213
|
+
// Provenance travels as a JSON string in the attribute; the twin wants a list
|
|
214
|
+
if (key === 'page-generated-sources') {
|
|
215
|
+
try {
|
|
216
|
+
frontmatter[key] = JSON.parse(value)
|
|
217
|
+
} catch (err) {
|
|
218
|
+
frontmatter[key] = value
|
|
219
|
+
}
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (key === 'page-autogenerated') {
|
|
223
|
+
frontmatter[key] = value === '' || value === true || value === 'true'
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (key === 'page-introduced-in' || key === 'page-introduced-in-product') {
|
|
227
|
+
// Not boolean flags: an empty value (an unset attribute in a template)
|
|
228
|
+
// means there is nothing to record.
|
|
229
|
+
const text = String(value).trim()
|
|
230
|
+
if (!text) return
|
|
231
|
+
// A bare X.Y.Z, as the UI badge shows it.
|
|
232
|
+
frontmatter[key] = key === 'page-introduced-in' ? text.replace(/^v/i, '').replace(/\.+$/, '') : text
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
110
236
|
// Skip empty attributes (AsciiDoc boolean flags)
|
|
111
237
|
if (value === '') {
|
|
112
238
|
// Special handling for version fields - use actual version from page source
|
|
@@ -294,8 +420,12 @@ function createEnterpriseFeatureRules () {
|
|
|
294
420
|
}
|
|
295
421
|
|
|
296
422
|
module.exports.createEnterpriseFeatureRules = createEnterpriseFeatureRules
|
|
423
|
+
module.exports.generateFrontmatter = generateFrontmatter
|
|
297
424
|
module.exports.formatStatusMarker = formatStatusMarker
|
|
298
425
|
module.exports.ENTERPRISE_MARKER = ENTERPRISE_MARKER
|
|
426
|
+
module.exports.readGeneratedMarker = readGeneratedMarker
|
|
427
|
+
module.exports.findIncludeTargets = findIncludeTargets
|
|
428
|
+
module.exports.collectGeneratedProvenance = collectGeneratedProvenance
|
|
299
429
|
|
|
300
430
|
module.exports.register = function () {
|
|
301
431
|
raiseListenerLimit(this)
|
|
@@ -556,6 +686,27 @@ module.exports.register = function () {
|
|
|
556
686
|
return outerTurndown
|
|
557
687
|
}
|
|
558
688
|
|
|
689
|
+
// Record which text on each page is generated, while the catalog still
|
|
690
|
+
// holds AsciiDoc: the page's own marker, and the marker on every include it
|
|
691
|
+
// resolves to. An extension that generates pages at build time sets
|
|
692
|
+
// page.generatedProvenance itself, and that wins.
|
|
693
|
+
this.on('contentClassified', ({ contentCatalog }) => {
|
|
694
|
+
const pages = contentCatalog.findBy({ family: 'page' })
|
|
695
|
+
let marked = 0
|
|
696
|
+
for (const page of pages) {
|
|
697
|
+
if (page.generatedProvenance) {
|
|
698
|
+
marked++
|
|
699
|
+
continue
|
|
700
|
+
}
|
|
701
|
+
const provenance = collectGeneratedProvenance(page, contentCatalog)
|
|
702
|
+
if (provenance.page || provenance.includes.length) {
|
|
703
|
+
page.generatedProvenance = provenance
|
|
704
|
+
marked++
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
logger.info(`Recorded generated-content provenance on ${marked} of ${pages.length} pages`)
|
|
708
|
+
})
|
|
709
|
+
|
|
559
710
|
// Add marker attribute before UI rendering so templates can detect markdown availability
|
|
560
711
|
this.on('documentsConverted', ({ contentCatalog }) => {
|
|
561
712
|
const pages = contentCatalog.findBy({ family: 'page' })
|
|
@@ -568,6 +719,31 @@ module.exports.register = function () {
|
|
|
568
719
|
|
|
569
720
|
// Add marker that UI templates can check
|
|
570
721
|
page.asciidoc.attributes['page-has-markdown'] = ''
|
|
722
|
+
|
|
723
|
+
// Provenance as page attributes, so the twin's front matter and the UI
|
|
724
|
+
// templates can both read it. page-generated-content is set on every
|
|
725
|
+
// page, so "none" is a statement and not an absence.
|
|
726
|
+
const provenance = page.generatedProvenance
|
|
727
|
+
let generatedContent = 'none'
|
|
728
|
+
if (provenance?.page) {
|
|
729
|
+
generatedContent = 'all'
|
|
730
|
+
page.asciidoc.attributes['page-autogenerated'] = 'true'
|
|
731
|
+
const { generator, version, source } = provenance.page
|
|
732
|
+
if (generator) {
|
|
733
|
+
page.asciidoc.attributes['page-generated-by'] = `doc-tools${version ? ` ${version}` : ''} (${generator})`
|
|
734
|
+
}
|
|
735
|
+
// Only a named source: a legacy banner has none, and its prose is
|
|
736
|
+
// not one.
|
|
737
|
+
if (source) page.asciidoc.attributes['page-generated-from'] = source
|
|
738
|
+
} else if (provenance?.includes?.length) {
|
|
739
|
+
generatedContent = 'partial'
|
|
740
|
+
}
|
|
741
|
+
if (provenance?.includes?.length) {
|
|
742
|
+
page.asciidoc.attributes['page-generated-sources'] = JSON.stringify(
|
|
743
|
+
provenance.includes.map(({ include, generator, version, source }) => ({ include, generator, version, source }))
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
page.asciidoc.attributes['page-generated-content'] = generatedContent
|
|
571
747
|
})
|
|
572
748
|
})
|
|
573
749
|
|
|
@@ -8,6 +8,7 @@ const os = require('os')
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
const handlebars = require('handlebars')
|
|
11
|
+
const { generatedMarker, DOC_TOOLS_VERSION } = require('../cli-utils/generated-marker')
|
|
11
12
|
|
|
12
13
|
// Import and register Redpanda Connect helpers
|
|
13
14
|
const helpers = require('../tools/redpanda-connect/helpers')
|
|
@@ -269,6 +270,19 @@ module.exports.register = function ({ config }) {
|
|
|
269
270
|
// Mark as field-only page (used by convert-to-markdown and add-llms-directive to skip directive)
|
|
270
271
|
file.isFieldOnlyPage = true
|
|
271
272
|
|
|
273
|
+
// The whole page is generated here at build time, so say so the way
|
|
274
|
+
// a file marker would; convert-to-markdown exports it as
|
|
275
|
+
// page-autogenerated and page-generated-from in the twin.
|
|
276
|
+
file.generatedProvenance = {
|
|
277
|
+
page: {
|
|
278
|
+
generator: 'fields-only-pages',
|
|
279
|
+
version: DOC_TOOLS_VERSION,
|
|
280
|
+
source: 'the component spec in redpanda-data/connect',
|
|
281
|
+
note: generatedMarker('fields-only-pages', 'the component spec in redpanda-data/connect').replace(/^\/\/\s*/, ''),
|
|
282
|
+
},
|
|
283
|
+
includes: [],
|
|
284
|
+
}
|
|
285
|
+
|
|
272
286
|
pagesGenerated++
|
|
273
287
|
} catch (err) {
|
|
274
288
|
logger.error(`Failed to generate field-only page for ${type}/${item.name}: ${err.message}`)
|
|
@@ -526,7 +526,7 @@ module.exports.register = function ({ config }) {
|
|
|
526
526
|
if (r.kept + r.outsideGenerated + r.providedElsewhere + r.otherComponents === 0) return
|
|
527
527
|
logger.info(
|
|
528
528
|
`Redpanda Connect content: kept ${r.kept} generated files; skipped ${r.providedElsewhere} already provided by another source, ` +
|
|
529
|
-
`${r.outsideGenerated} outside the generated partials
|
|
529
|
+
`${r.outsideGenerated} outside the generated partials and examples, and ${r.otherComponents} from other components`
|
|
530
530
|
)
|
|
531
531
|
})
|
|
532
532
|
|
|
@@ -592,7 +592,7 @@ module.exports.register = function ({ config }) {
|
|
|
592
592
|
from = `the ${tag} ${asset.ASSET_NAME} release asset`
|
|
593
593
|
}
|
|
594
594
|
if (!files.some((f) => asset.toResource(f.path))) {
|
|
595
|
-
throw new Error(`${from} has no files under modules/components/partials or modules/components/
|
|
595
|
+
throw new Error(`${from} has no files under modules/components/partials, modules/components/examples, or modules/components/attachments`)
|
|
596
596
|
}
|
|
597
597
|
const r = addConnectDocs(contentCatalog, files, origin, { replace: !!localDir })
|
|
598
598
|
const versions = r.versions.filter(Boolean)
|