const nunjucks = require("nunjucks") const fs = require('node:fs') const path = require('node:path') const { resolveRefs } = require('./specLoader') const META_DIR = path.join(__dirname, 'meta') const TEMPLATES_DIR = path.join(__dirname, 'templates') const planeConfig = JSON.parse(fs.readFileSync(path.join(META_DIR, 'plane-config.json'), 'utf-8')) const CONFIG = { controlPlaneKeywords: planeConfig.controlPlaneKeywords, betaDefaults: { v1: 'DEPRECATED', v2: 'FALSE' }, betaOverrides: { extract: 'PRIVATE', merge: 'PRIVATE' }, maxRefDepth: 20, } class refGen { constructor(options) { this.options = options this.options.parents = [] this.validateSpec(options.specifications) for (const x of Object.keys(this.options.specifications.tags)) { this.options.parents.push(this.options.specifications.tags[x].name) } this.descriptions = JSON.parse(fs.readFileSync(path.join(META_DIR, 'descriptions.json'), 'utf-8')) this.positions = { tags: {}, endpoints: {} } try { this.positions = JSON.parse(fs.readFileSync(path.join(META_DIR, 'positions.json'), 'utf-8')) } catch (err) { // Optional file — leave defaults empty } this.admonitions = {} try { this.admonitions = JSON.parse(fs.readFileSync(path.join(META_DIR, 'admonitions.json'), 'utf-8')) } catch (err) { // Optional file — leave defaults empty } } validateSpec(spec) { if (!spec.tags || !Array.isArray(spec.tags) || spec.tags.length === 0) { throw new Error('OpenAPI spec must have a non-empty "tags" array') } for (let i = 0; i < spec.tags.length; i++) { if (!spec.tags[i].name || typeof spec.tags[i].name !== 'string') { throw new Error(`Tag at index ${i} is missing a "name" string`) } } if (!spec.paths || typeof spec.paths !== 'object' || Object.keys(spec.paths).length === 0) { throw new Error('OpenAPI spec must have a non-empty "paths" object') } for (const [path, methods] of Object.entries(spec.paths)) { const httpMethods = Object.keys(methods).filter(m => ['get', 'post', 'put', 'delete', 'patch'].includes(m)) if (httpMethods.length === 0) { console.warn(`Warning: Path "${path}" has no HTTP methods`) } for (const method of httpMethods) { if (!methods[method].summary) { console.warn(`Warning: ${method.toUpperCase()} ${path} is missing a "summary"`) } } } } getPlane(slug, target) { const keywords = CONFIG.controlPlaneKeywords[target] || CONFIG.controlPlaneKeywords.zilliz return keywords.some(k => slug.includes(k)) ? 'control-plane' : 'data-plane' } getBetaTag(slug, version) { for (const [keyword, tag] of Object.entries(CONFIG.betaOverrides)) { if (slug.includes(keyword)) return tag } return CONFIG.betaDefaults[version] || 'FALSE' } lookupDescription(slug, specDescription) { const entry = this.descriptions.find(x => x.name === slug) if (!entry) { console.warn(`Warning: No description entry for slug "${slug}", falling back to spec description`) return specDescription || '' } return entry.description } lookupMilvusName(slug) { const entry = this.descriptions.find(x => x.name === slug) return entry?.milvus?.name || null } toSlug(name) { return name.replace("&", "and").split(' ').join('-').replace(/\(|\)|,/g, '').toLowerCase() } async write_refs() { const { lang, target, parents, specifications } = this.options const env = new nunjucks.Environment( new nunjucks.FileSystemLoader(TEMPLATES_DIR), { autoescape: false, } ) const template = env.getTemplate("reference.mdx") const autoPositions = {} for (const page_url of Object.keys(specifications.paths)) { for (const method of Object.keys(specifications.paths[page_url])) { const specification = resolveRefs(specifications.paths[page_url][method], specifications) if (specification?.["x-include-target"] && !specification["x-include-target"].includes(target)) { continue } const tagObj = specifications.tags.find(t => t.name === specification.tags?.[0]) if (tagObj?.["x-include-target"] && !tagObj["x-include-target"].includes(target)) { continue } const page_title = lang === "zh-CN" ? specification["x-i18n"][lang].summary : specification.summary const page_excerpt = this.__filter_content(lang === "zh-CN" ? specification["x-i18n"][lang].description : specification.description, target).split('<')[0] var page_parent = parents.filter(x => x === specification.tags[0])[0] if (!page_parent) { console.warn(`Warning: No matching parent tag for "${specification.tags?.[0]}" in ${method.toUpperCase()} ${page_url}, skipping`) continue } const tag = page_parent page_parent = this.toSlug(page_parent) if (target === 'milvus') { const name = this.lookupMilvusName(page_parent) if (name) { page_parent = name } } const version = page_parent.includes('v2') ? 'v2' : 'v1' var slug_suffix = version === 'v2' ? '-v2' : '' if (target === 'milvus') { slug_suffix = '' } var upper_folder = this.getPlane(page_parent, target) var page_slug = (this.get_slug(page_title, target)) + slug_suffix // Check x-beta on operation, then tag, then fall back to defaults let beta_tag = specification['x-beta'] if (!beta_tag && tagObj?.['x-beta']) { beta_tag = tagObj['x-beta'] } if (!beta_tag) { beta_tag = this.getBetaTag(page_slug, version) } // Auto-deprecate all v1 endpoints if (version === 'v1') { specification.deprecated = true } let sidebar_position const metaPos = this.positions.endpoints?.[page_slug] if (metaPos !== undefined) { sidebar_position = metaPos } else { if (!autoPositions[tag]) { autoPositions[tag] = 0 } sidebar_position = autoPositions[tag] autoPositions[tag]++ } const page_method = method.toLowerCase() const meta = this.admonitions[page_slug] || {} // 1. Page-level admonitions const specAdmonitions = specification['x-admonition'] || [] const pageMetaAdmonitions = meta.admonitions || [] const mergedPage = [...specAdmonitions, ...pageMetaAdmonitions] if (mergedPage.length > 0) { specification['x-admonition'] = mergedPage } // 2. Parameter-level admonitions if (specification.parameters && meta.parameters) { for (const param of specification.parameters) { const paramMeta = meta.parameters[param.name] if (paramMeta && paramMeta.length > 0) { param['x-admonition'] = [...(param['x-admonition'] || []), ...paramMeta] } } } // 3. Property-level admonitions const injectPropertyAdmonitions = (schema, propertyMap) => { if (!schema || !propertyMap) return for (const [path, admonitions] of Object.entries(propertyMap)) { const parts = path.split('.') let current = schema for (let i = 0; i < parts.length; i++) { if (!current) break if (i === parts.length - 1) { if (current.properties && current.properties[parts[i]]) { const prop = current.properties[parts[i]] prop['x-admonition'] = [...(prop['x-admonition'] || []), ...admonitions] } } else { if (current.properties && current.properties[parts[i]]) { current = current.properties[parts[i]] } else if (current.items) { current = current.items } else { break } } } } } if (meta.properties) { if (specification.requestBody?.content?.['application/json']?.schema) { injectPropertyAdmonitions( specification.requestBody.content['application/json'].schema, meta.properties.requestBody ) } if (specification.responses?.['200']?.content?.['application/json']?.schema) { injectPropertyAdmonitions( specification.responses['200'].content['application/json'].schema, meta.properties.responses ) } } const specs = JSON.stringify(specification) const t = template.render({ page_title: page_title + (version === 'v2' ? ' (V2)' : ' (V1)'), page_excerpt, page_slug, beta_tag, page_url, page_method, specs, sidebar_position, target, lang, }).replaceAll(/
/g, '
') fs.writeFileSync(`${this.options.target_path}/${version}/${upper_folder}/${page_parent}/${page_slug}.mdx`, t) } } } make_groups() { const { specifications, target, target_path } = this.options const env = new nunjucks.Environment( new nunjucks.FileSystemLoader(TEMPLATES_DIR), { autoescape: false } ) const template = env.getTemplate("group.mdx") for (const group of Object.keys(specifications.tags)) { if (specifications.tags[group]['x-include-target'] && !(specifications.tags[group]['x-include-target']?.includes(target))) continue; const slug = this.toSlug(specifications.tags[group].name) const version = slug.includes('v2') ? 'v2' : 'v1' var upper_folder = this.getPlane(slug, target) console.log(slug) const group_name = version === 'v2' ? specifications.tags[group].name.slice(0, -5) : specifications.tags[group].name const description = this.lookupDescription(slug, specifications.tags[group].description) const tagName = specifications.tags[group].name let position const metaPos = this.positions.tags?.[slug] if (metaPos !== undefined) { position = metaPos } else { position = specifications.tags.map(x => x.name).indexOf(tagName) } // Check x-beta on tag, then fall back to defaults let beta_tag = specifications.tags[group]?.['x-beta'] if (!beta_tag) { beta_tag = this.getBetaTag(slug, version) } const t = template.render({ group_name: group_name + (version === 'v2' ? ' (V2)' : ' (V1)'), position, slug, beta_tag, description }) var folder_path = `${target_path}/${version}/${upper_folder}/${slug}` if (target === 'milvus') { const name = this.lookupMilvusName(slug) if (name) { folder_path = `${target_path}/${version}/${upper_folder}/${name}` } } if (!fs.existsSync(folder_path)) { fs.mkdirSync(folder_path, { recursive: true }) } if (target === 'zilliz') { if (!fs.existsSync(`${target_path}/${version}/${version}.mdx`)) { fs.writeFileSync(`${target_path}/${version}/${version}.mdx`, template.render({ group_name: version === 'v2' ? 'V2' : 'V1', position: version === 'v2' ? 1 : 2, slug: version, beta_tag: CONFIG.betaDefaults[version], description: '' })) } if (!fs.existsSync(`${target_path}/${version}/${upper_folder}/${upper_folder}.mdx`)) { const title = upper_folder.startsWith('control') ? 'Control Plane' : 'Data Plane' const pos = upper_folder.startsWith('control') ? 1 : 2 const desc = upper_folder.startsWith('control') ? 'This provide API endpoints for managing Zilliz Cloud clusters and resources.' : 'This provide API endpoints for managing data stored in Zilliz Cloud clusters.' fs.writeFileSync(`${target_path}/${version}/${upper_folder}/${upper_folder}.mdx`, template.render({ group_name: title + (version === 'v2' ? ' (V2)' : ' (V1)'), position: pos, slug: `${upper_folder}-${version}`, beta_tag: CONFIG.betaDefaults[version], description: desc })) } fs.writeFileSync(`${folder_path}/${slug}.mdx`, t) } } } get_slug(page_title, target) { console.log(page_title) var page_slug = 0 const { lang } = this.options if (lang == 'zh-CN') { const titles = JSON.parse(fs.readFileSync(path.join(META_DIR, 'titles.json'), 'utf-8')) page_slug = titles[page_title] if (!page_slug) { throw new Error(`Missing Chinese title mapping for: "${page_title}" in titles.json`) } } else { page_slug = page_title.replace("&", "and").split(' ').join('-').replace(/\(|\)/g, '').toLowerCase() } if (target === 'milvus') { const ruleSet = JSON.parse(fs.readFileSync(path.join(META_DIR, 'fileNameRuleSet.json'), 'utf-8')) var slug = undefined for (let rule of ruleSet) { switch (rule.match) { case 'endsWith': if (page_slug.endsWith(rule.path)) { slug = rule.name } break; case 'contains': if (page_slug.includes(rule.path)) { slug = rule.name } break; case 'removeAndCapitalize': if (page_slug.includes(rule.path)) { slug = page_slug.replace(rule.path, '').replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) } break; } if (slug) { page_slug = slug; break; } } } return page_slug } __filter_content (markdown, targets) { const matches = this.__match_filter_tags(markdown) if (matches.length > 0) { var preText = markdown.slice(0, matches[0].startIndex) var matchText = markdown.slice(matches[0].startIndex, matches[0].endIndex) var postText = markdown.slice(matches[0].endIndex) var isTargetValid = targets.split('.').includes(matches[0].target.trim()) var startTagLength = `<${matches[0].tag} target="${matches[0].target}">`.length var endTagLength = ``.length if (matches[0].tag == 'include' && isTargetValid || matches[0].tag == 'exclude' && !isTargetValid) { matchText = matchText.slice(startTagLength, -endTagLength) } if (matches[0].tag == 'include' && !isTargetValid || matches[0].tag == 'exclude' && isTargetValid) { matchText = "" } markdown = this.__filter_content(preText + matchText + postText, targets) } return markdown.replace(/(\s*\n){3,}/g, '\n\n') .replace(/
/g, '
') .replace(/(){2,}/, "
") .replace("

", "

") .replace(/\n\s*\n(\s*

<\/p><\/td>\n)*\s*<\/tr>/g, ''); } __match_filter_tags(markdown) { const startTagRegex = /<(include|exclude) target="(.+?)"/gm const endTagRegex = /<\/(include|exclude)>/gm const matches = [... markdown.matchAll(startTagRegex)] var returns = [] matches.forEach(match => { var tag = match[1] var rest = markdown.slice(match.index) var closeTagRegex = new RegExp(``, 'gm') var closeTagMatch = [... rest.matchAll(closeTagRegex)] var startIndex = match.index var endIndex = 0 for (let i = 0; i < closeTagMatch.length; i++) { var t = markdown.slice(startIndex, startIndex+closeTagMatch[i].index+closeTagMatch[i][0].length) var startCount = t.match(startTagRegex) ? t.match(startTagRegex).length : 0 var endCount = t.match(endTagRegex) ? t.match(endTagRegex).length : 0 if (startCount === endCount) { endIndex = startIndex + closeTagMatch[i].index + closeTagMatch[i][0].length break } } returns.push({ tag: tag, target: match[2], startIndex: startIndex, endIndex: endIndex }) }) return returns } } module.exports = refGen