Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/sast.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
# Exclude existing .github/workflows/*.yml — they use mutable action tags.
# Pinning those files is tracked separately.
EXCLUDE_PATHS: '.github/workflows'
EXCLUDE_RULES: ''
EXCLUDE_RULES: 'javascript.express.security.audit.xss.mustache.explicit-unescape.template-explicit-unescape'
run: |
EXCLUDED_PATHS=()
if [[ -n "$EXCLUDE_PATHS" ]]; then
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/validate-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ jobs:
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 20
node-version: 22
registry-url: https://registry.npmjs.org/

- name: Install redoc
run: |
npm install -g @redocly/cli
redocly build-docs index.yml
./scripts/build-docs.sh /tmp/document-reader-static-doc.html

- name: Revert changes
if: ${{ false }}
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli validate -
## Building Redoc single page html documentation

```bash
npx @redocly/cli build-docs index.yml -o=document-reader-static-doc.html
./scripts/build-docs.sh
```

The build script normalizes multiline quoted YAML strings in a temporary directory. Source YAML files are not modified during the build.

## Bundle scheme to single .json file

```bash
Expand Down
171 changes: 171 additions & 0 deletions plugins/enum-tables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
const ENUM_TABLE_MARKER = '| Value | Name | Description |';
const ENUM_TAG = 'Enums';
const SCHEMA_REF_PREFIX = '#/components/schemas/';

function escapeTableCell(value) {
return String(value)
.replaceAll('\\', '\\\\')
.replaceAll('|', '\\|')
.replaceAll(/\r?\n/g, '<br>');
}

function enumReference(name, schema) {
const reference = {
type: schema.type,
title: name,
description: enumLink(name),
};

if (schema.format) {
reference.format = schema.format;
}

if (schema.nullable !== undefined) {
reference.nullable = schema.nullable;
}

return reference;
}

function enumLink(name) {
return `See [${name}](#schema/${encodeURIComponent(name)}) enumeration values.`;
}

function addEnumTable(schema) {
const values = schema.enum;

if (!Array.isArray(values) || schema.description?.includes(ENUM_TABLE_MARKER)) {
return;
}

const descriptions = schema['x-enum-descriptions'];
const variableNames = schema['x-enum-varnames'];
const rows = values.map((value, index) => {
const variableName = variableNames?.[index] ?? '';
const description = descriptions?.[index] ?? '';

return `| ${escapeTableCell(value)} | ${escapeTableCell(variableName)} | ${escapeTableCell(description)} |`;
});
const table = [
ENUM_TABLE_MARKER,
'| --- | --- | --- |',
...rows,
].join('\n');

schema.description = schema.description
? `${schema.description}\n\n${table}`
: table;

// The table replaces Redoc's compact, single-line enum value list.
delete schema.enum;
schema['x-tags'] = [...new Set([...(schema['x-tags'] ?? []), ENUM_TAG])];
}

function replaceEnumReferences(node, enumSchemas, visited = new WeakSet()) {
if (!node || typeof node !== 'object' || visited.has(node)) {
return;
}

visited.add(node);

if (node.type === 'array' && typeof node.items?.$ref === 'string') {
const name = node.items.$ref.startsWith(SCHEMA_REF_PREFIX)
? decodeURIComponent(node.items.$ref.slice(SCHEMA_REF_PREFIX.length))
: null;

if (name && enumSchemas.has(name)) {
const link = enumLink(name);

if (!node.description?.includes(link)) {
node.description = node.description
? `${node.description}\n\n${link}`
: link;
}
}
}

if (typeof node.$ref === 'string') {
if (node.$ref.startsWith(SCHEMA_REF_PREFIX)) {
const name = decodeURIComponent(node.$ref.slice(SCHEMA_REF_PREFIX.length));
const enumSchema = enumSchemas.get(name);

if (enumSchema) {
const existingDescription = node.description;

for (const key of Object.keys(node)) {
delete node[key];
}

Object.assign(node, enumReference(name, enumSchema));

if (existingDescription) {
node.description = `${existingDescription}\n\n${node.description}`;
}

return;
}
}
}

for (const value of Object.values(node)) {
replaceEnumReferences(value, enumSchemas, visited);
}
}

function addEnumNavigation(root) {
const schemas = root.components?.schemas ?? {};
const schemaEntries = Object.entries(schemas);
const enumEntries = schemaEntries
.filter(([, schema]) => Array.isArray(schema.enum))
.sort(([left], [right]) => {
const normalizedOrder = left
.toLocaleLowerCase('en')
.localeCompare(right.toLocaleLowerCase('en'));

return normalizedOrder || left.localeCompare(right);
});
const enumSchemas = new Map(enumEntries);

root.components.schemas = Object.fromEntries([
...schemaEntries.filter(([, schema]) => !Array.isArray(schema.enum)),
...enumEntries,
]);

for (const schema of enumSchemas.values()) {
addEnumTable(schema);
}

replaceEnumReferences(root.paths, enumSchemas);
replaceEnumReferences(root.components, enumSchemas);

root.tags ??= [];
if (!root.tags.some((tag) => tag.name === ENUM_TAG)) {
root.tags.push({
name: ENUM_TAG,
description: 'Enumeration definitions.',
});
}

root['x-tagGroups'] ??= [];
if (!root['x-tagGroups'].some((group) => group.tags?.includes(ENUM_TAG))) {
root['x-tagGroups'].push({
name: 'Definitions',
tags: [ENUM_TAG],
});
}
}

export default function enumTablesPlugin() {
return {
id: 'enum-tables',
decorators: {
oas3: {
'for-redoc': () => ({
Root: {
enter: addEnumNavigation,
},
}),
},
},
};
}
5 changes: 5 additions & 0 deletions redocly.docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
plugins:
- ./plugins/enum-tables.js

decorators:
enum-tables/for-redoc: on
67 changes: 67 additions & 0 deletions scripts/build-docs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env sh

set -eu

output=${1:-doc.html}
bundle_dir=$(mktemp -d "${TMPDIR:-/tmp}/document-reader-docs.XXXXXX")
normalized_dir="$bundle_dir/normalized"
source_bundle="$bundle_dir/source.yml"
docs_bundle="$bundle_dir/docs.yml"

cleanup() {
rm -rf "$normalized_dir"
rm -f "$source_bundle" "$docs_bundle"
rmdir "$bundle_dir"
}

trap cleanup EXIT HUP INT TERM

run_redocly() {
if command -v redocly >/dev/null 2>&1; then
redocly "$@"
else
npx @redocly/cli "$@"
fi
}

normalize_yaml() {
awk '
BEGIN { quote = "" }
quote != "" {
print " " $0
if ((quote == "\"" && $0 ~ /"[[:space:]]*$/) ||
(quote == "\047" && $0 ~ /\047[[:space:]]*$/)) {
quote = ""
}
next
}
{
print
if ($0 ~ /^[[:space:]]*(description|example|title|summary):[[:space:]]*"/ &&
$0 !~ /"[[:space:]]*$/) {
quote = "\""
} else if ($0 ~ /^[[:space:]]*(description|example|title|summary):[[:space:]]*\047/ &&
$0 !~ /\047[[:space:]]*$/) {
quote = "\047"
}
}
' "$1" > "$2"
}

mkdir "$normalized_dir"
find . \
-path './.git' -prune -o \
-path './node_modules' -prune -o \
-type f \( -name '*.yml' -o -name '*.yaml' \) -print |
while IFS= read -r yaml_file; do
normalized_file="$normalized_dir/${yaml_file#./}"
mkdir -p "$(dirname "$normalized_file")"
normalize_yaml "$yaml_file" "$normalized_file"
done

run_redocly bundle "$normalized_dir/index.yml" --config redocly.yml --output "$source_bundle"
run_redocly bundle "$source_bundle" --config redocly.docs.yml --output "$docs_bundle"
run_redocly build-docs "$docs_bundle" \
--config redocly.yml \
--template templates/redoc.hbs \
--output "$output"
37 changes: 37 additions & 0 deletions templates/redoc.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>

<head>
<meta charset="utf8" />
<title>{{title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
padding: 0;
margin: 0;
}

/* Enum definitions use their Markdown table as the complete presentation. */
[data-section-id^="schema/"] > div:nth-child(2) > div > div:nth-child(2),
[data-section-id^="schema/"] > div:nth-child(2) > div > div:first-child > div > div > div:first-child,
.operation-type.schema {
display: none !important;
}

.operation-type.schema + span {
width: 100% !important;
}

[data-section-id^="schema/"] > div:nth-child(2) {
padding-top: 0 !important;
}
</style>
{{{redocHead}}}
{{#unless disableGoogleFont}}<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">{{/unless}}
</head>

<body>
{{{redocHTML}}}
</body>

</html>