// Licensed under the MIT License
// https://github.com/craigahobbs/schema-markdown-doc/blob/main/LICENSE
/** @module lib/schemaMarkdownDoc */
import {getEnumValues, getReferencedTypes, getStructMembers} from 'schema-markdown/lib/schema.js';
import {markdownElements} from 'markdown-model/lib/elements.js';
import {parseMarkdown} from 'markdown-model/lib/parser.js';
// Non-breaking space character
const nbsp = String.fromCharCode(160);
/**
* The [schemaMarkdownDoc]{@link module:lib/schemaMarkdownDoc.schemaMarkdownDoc} options object
*
* @typedef {Object} SchemaMarkdownDocOptions
* @property {string} [params] - The page's hash param string with tag removed
* @property {Object[]} [actionURLs] - The
* [action URLs]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='ActionURL'} override
* @property {?Object} [markdownOptions] - The
* [markdownElements options]{@link https://craigahobbs.github.io/markdown-model/module-lib_elements.html#~MarkdownElementsOptions}
* object
*/
/**
* Generate the Schema Markdown user type documentation element model
*
* @param {Object} types - The [type model]{@link https://craigahobbs.github.io/schema-markdown-doc/doc/#var.vName='Types'}
* @param {string} typeName - The type name
* @param {?Object} [options = null] - The [options]{@link module:lib/schemaMarkdownDoc~SchemaMarkdownDocOptions} object
* @returns {Object[]}
*/
export function schemaMarkdownDoc(types, typeName, options = null) {
// Compute the referenced types
if (!(typeName in types)) {
throw new Error(`Unknown type '${typeName}'`);
}
const userType = types[typeName];
const {action = null} = userType;
const referencedTypes = getReferencedTypes(types, typeName);
let typesFilter;
if (action !== null) {
typesFilter = [typeName, action.path, action.query, action.input, action.output, action.errors];
} else {
typesFilter = [typeName];
}
const filteredTypes =
Object.entries(referencedTypes).sort().filter(([name]) => !typesFilter.includes(name)).map(([, type]) => type);
// Return the user type's element model
return [
// The user type
userTypeElements(types, typeName, 'h1', options),
// Referenced types
!filteredTypes.length ? null : [
{'html': 'hr'},
{'html': 'h2', 'elem': {'text': 'Referenced Types'}},
filteredTypes.map((refType) => userTypeElements(types, Object.values(refType)[0].name, 'h3', options))
]
];
}
function markdownElementsNull(text, options) {
const markdownText = text ?? null;
const markdownOptions = (options !== null ? (options.markdownOptions ?? null) : null);
return markdownText !== null ? markdownElements(parseMarkdown(markdownText), markdownOptions) : null;
}
function typeHref(typeName, options) {
const paramString = (options !== null && 'params' in options ? options.params : '');
return paramString !== '' ? `${paramString}&type_${typeName}` : `type_${typeName}`;
}
function typeElements(type, options) {
if ('array' in type) {
return [typeElements(type.array.type, options), {'text': `${nbsp}[]`}];
} else if ('dict' in type) {
return [
!('keyType' in type.dict) || 'builtin' in type.dict.keyType ? null
: [typeElements(type.dict.keyType, options), {'text': `${nbsp}:${nbsp}`}],
typeElements(type.dict.type, options),
{'text': `${nbsp}{}`}
];
} else if ('user' in type) {
return {'html': 'a', 'attr': {'href': `#${typeHref(type.user, options)}`}, 'elem': {'text': type.user}};
}
return {'text': type.builtin};
}
function attrElements({type, attr = null, optional = false}) {
// Create the array of attribute "parts" (lhs, op, rhs)
const parts = [];
const typeName = type.array ? 'array' : (type.dict ? 'dict' : 'value');
attrParts(parts, typeName, attr, optional);
// Array or dict key/value attributes?
if ('array' in type) {
if ('attr' in type.array) {
attrParts(parts, 'value', type.array.attr, false);
}
} else if ('dict' in type) {
if ('keyAttr' in type.dict) {
attrParts(parts, 'key', type.dict.keyAttr, false);
}
if ('attr' in type.dict) {
attrParts(parts, 'value', type.dict.attr, false);
}
}
// Return the attributes element model
return !parts.length ? null : parts.map(
(part, ixPart) => [
ixPart !== 0 ? {'html': 'br'} : null,
{'text': part.op ? `${part.lhs}${nbsp}${part.op}${nbsp}${part.rhs}` : part.lhs}
]
);
}
function attrParts(parts, noun, attr, optional) {
if (optional) {
parts.push({'lhs': 'optional'});
}
if (attr !== null && 'nullable' in attr) {
parts.push({'lhs': 'nullable'});
}
if (attr !== null && 'gt' in attr) {
parts.push({'lhs': noun, 'op': '>', 'rhs': attr.gt});
}
if (attr !== null && 'gte' in attr) {
parts.push({'lhs': noun, 'op': '>=', 'rhs': attr.gte});
}
if (attr !== null && 'lt' in attr) {
parts.push({'lhs': noun, 'op': '<', 'rhs': attr.lt});
}
if (attr !== null && 'lte' in attr) {
parts.push({'lhs': noun, 'op': '<=', 'rhs': attr.lte});
}
if (attr !== null && 'eq' in attr) {
parts.push({'lhs': noun, 'op': '==', 'rhs': attr.eq});
}
if (attr !== null && 'lenGT' in attr) {
parts.push({'lhs': `len(${noun})`, 'op': '>', 'rhs': attr.lenGT});
}
if (attr !== null && 'lenGTE' in attr) {
parts.push({'lhs': `len(${noun})`, 'op': '>=', 'rhs': attr.lenGTE});
}
if (attr !== null && 'lenLT' in attr) {
parts.push({'lhs': `len(${noun})`, 'op': '<', 'rhs': attr.lenLT});
}
if (attr !== null && 'lenLTE' in attr) {
parts.push({'lhs': `len(${noun})`, 'op': '<=', 'rhs': attr.lenLTE});
}
if (attr !== null && 'lenEq' in attr) {
parts.push({'lhs': `len(${noun})`, 'op': '==', 'rhs': attr.lenEq});
}
}
function userTypeElements(types, typeName, titleTag, options, title = null, introMarkdown = null) {
const userType = types[typeName];
// Generate the header element models
const titleElements = (defaultTitle) => ({
'html': titleTag,
'attr': {'id': typeHref(typeName, options)},
'elem': {'text': title !== null ? title : defaultTitle}
});
// Struct?
if ('struct' in userType) {
const {struct} = userType;
const members = getStructMembers(types, struct);
const memberAttrElem = Object.fromEntries(members.map((member) => [member.name, attrElements(member)]));
const hasAttr = Object.values(memberAttrElem).some((attrElem) => attrElem !== null);
const memberDocElem = Object.fromEntries(members.map(({name, doc}) => [name, markdownElementsNull(doc, options)]));
const hasDoc = Object.values(memberDocElem).some((docElem) => docElem !== null);
// Return the struct documentation element model
const isUnion = ('union' in struct && struct.union);
return [
titleElements(isUnion ? `union ${typeName}` : `struct ${typeName}`),
!('bases' in struct) ? null : {'html': 'p', 'elem': [
{'text': 'Bases: '},
struct.bases.map((base, ixBase) => [
ixBase === 0 ? null : {'text': ', '},
{'html': 'a', 'attr': {'href': `#${typeHref(base, options)}`}, 'elem': {'text': base}}
])
]},
markdownElementsNull(struct.doc, options),
// Struct members
!members.length ? markdownElementsNull('The struct is empty.', options) : {'html': 'table', 'elem': [
{'html': 'tr', 'elem': [
{'html': 'th', 'elem': {'text': 'Name'}},
{'html': 'th', 'elem': {'text': 'Type'}},
hasAttr ? {'html': 'th', 'elem': {'text': 'Attributes'}} : null,
hasDoc ? {'html': 'th', 'elem': {'text': 'Description'}} : null
]},
members.map((member) => ({'html': 'tr', 'elem': [
{'html': 'td', 'elem': {'text': member.name}},
{'html': 'td', 'elem': typeElements(member.type, options)},
hasAttr ? {'html': 'td', 'elem': memberAttrElem[member.name]} : null,
hasDoc ? {'html': 'td', 'elem': memberDocElem[member.name]} : null
]}))
]}
];
// Enumeration?
} else if ('enum' in userType) {
const enum_ = userType.enum;
const values = 'values' in enum_ ? getEnumValues(types, enum_) : null;
const valueDocElem = values !== null
? Object.fromEntries(values.map(({name, doc}) => [name, markdownElementsNull(doc, options)])) : null;
const hasDoc = values !== null && Object.values(valueDocElem).some((docElem) => docElem !== null);
// Return the enumeration documentation element model
return [
titleElements(`enum ${typeName}`),
!('bases' in enum_) ? null : {'html': 'p', 'elem': [
{'text': 'Bases: '},
enum_.bases.map((base) => ({'html': 'a', 'attr': {'href': `#${typeHref(base, options)}`}, 'elem': {'text': base}}))
]},
markdownElementsNull(enum_.doc, options),
markdownElementsNull(introMarkdown, options),
// Enumeration values
!values || !values.length ? markdownElementsNull('The enum is empty.', options) : {'html': 'table', 'elem': [
{'html': 'tr', 'elem': [
{'html': 'th', 'elem': {'text': 'Value'}},
hasDoc ? {'html': 'th', 'elem': {'text': 'Description'}} : null
]},
values.map((value) => ({'html': 'tr', 'elem': [
{'html': 'td', 'elem': {'text': value.name}},
hasDoc ? {'html': 'td', 'elem': valueDocElem[value.name]} : null
]}))
]}
];
// Typedef?
} else if ('typedef' in userType) {
const {typedef} = userType;
const attrElem = 'attr' in typedef ? attrElements(typedef) : null;
// Return the typedef documentation element model
return [
titleElements(`typedef ${typeName}`),
markdownElementsNull(typedef.doc, options),
// Typedef type description
{'html': 'table', 'elem': [
{'html': 'tr', 'elem': [
{'html': 'th', 'elem': {'text': 'Type'}},
attrElem !== null ? {'html': 'th', 'elem': {'text': 'Attributes'}} : null
]},
{'html': 'tr', 'elem': [
{'html': 'td', 'elem': typeElements(typedef.type, options)},
attrElem !== null ? {'html': 'td', 'elem': attrElem} : null
]}
]}
];
// Action?
} else if ('action' in userType) {
const {action} = userType;
// Add "UnexpectedError" to the action's errors
let actionErrorTypeName;
let actionErrorTypes;
let actionErrorEnum;
if ('errors' in action) {
actionErrorTypeName = action.errors;
actionErrorTypes = getReferencedTypes(types, actionErrorTypeName);
actionErrorEnum = {...types[actionErrorTypeName].enum};
if ('values' in actionErrorEnum) {
actionErrorEnum.values = [...actionErrorEnum.values];
} else {
actionErrorEnum.values = [];
}
} else {
actionErrorTypeName = `${action.name}_errors`;
actionErrorTypes = {};
actionErrorEnum = {'name': actionErrorTypeName, 'values': []};
}
if (!actionErrorEnum.values.some((value) => value.name === 'UnexpectedError')) {
actionErrorEnum.values.push({
'name': 'UnexpectedError',
'doc': ['An unexpected error occurred while processing the request']
});
}
actionErrorTypes[actionErrorTypeName] = {'enum': actionErrorEnum};
// If no URLs passed use the action's URLs
let actionURLs = null;
const rawActionURLs = (options !== null ? (options.actionURLs ?? null) : null) ?? action.urls ?? null;
if (rawActionURLs !== null) {
actionURLs = rawActionURLs.map(({method = null, path = null}) => {
const url = {
'path': path !== null ? path : `/${typeName}`
};
if (method !== null) {
url.method = method;
}
return url;
});
}
// Return the action documentation element model
return [
titleElements(`action ${typeName}`),
markdownElementsNull(action.doc, options),
actionURLs === null || !actionURLs.length ? null : [
{'html': 'p', 'elem': [
{'html': 'b', 'elem': {'text': 'Note: '}},
{'text': `The request is exposed at the following ${actionURLs.length === 1 ? 'URL:' : 'URLs:'}`}
]},
actionURLs.map((url) => ({'html': 'p', 'elem': [
{'text': `${nbsp}${nbsp}`},
{
'html': 'a',
'attr': {'href': url.path}, 'elem': {'text': url.method ? `${url.method} ${url.path}` : url.path}
}
]}))
],
// Action types
'path' in action ? userTypeElements(types, action.path, 'h2', options, 'Path Parameters') : null,
'query' in action ? userTypeElements(types, action.query, 'h2', options, 'Query Parameters') : null,
'input' in action ? userTypeElements(types, action.input, 'h2', options, 'Input Parameters') : null,
'output' in action ? userTypeElements(types, action.output, 'h2', options, 'Output Parameters') : null,
userTypeElements(
actionErrorTypes,
actionErrorTypeName,
'h2',
options,
'Error Codes',
`\
If an application error occurs, the response is of the form:
{
"error": "<code>",
"message": "<message>"
}
"message" is optional. "<code>" is one of the following values:`
)
];
}
// Unreachable for valid type models
return null;
}