render-persistence-schema.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. /** Readable, linked persistence schemas rendered from the fingerprint inventory. */
  2. import { githubSlug } from './verify-md-links.ts'
  3. import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
  4. import {
  5. canonicalizeSchema,
  6. schemaChildren,
  7. schemaDigest,
  8. type CanonicalSchema,
  9. type PersistenceSchemaInventory,
  10. type PersistenceType,
  11. type SchemaNode,
  12. } from './persistence-schema-model.ts'
  13. interface TypeDisplay {
  14. readonly type: PersistenceType
  15. readonly label: string
  16. readonly anchor: string
  17. }
  18. function code(text: string): string {
  19. return '`' + text.replaceAll('`', '\\`').replaceAll('|', '\\|') + '`'
  20. }
  21. function sourcePath(source: string): string {
  22. return source.replace(/:\d+(?::\d+)?$/u, '')
  23. }
  24. function nodeAt(schema: CanonicalSchema, index: number): SchemaNode {
  25. const node = schema.nodes[index]
  26. if (!node) throw new Error(`persistence catalog: missing schema node ${String(index)}`)
  27. return node
  28. }
  29. function childPath(node: SchemaNode, position: number): string {
  30. switch (node.kind) {
  31. case 'object': return node.properties[position]?.name ?? `index-${String(position - node.properties.length)}`
  32. case 'array': return 'item'
  33. case 'tuple': return `item-${String(position)}`
  34. case 'union': return `variant-${String(position)}`
  35. case 'primitive':
  36. case 'literal':
  37. case 'opaque': return String(position)
  38. default: return assertNever(node)
  39. }
  40. }
  41. function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
  42. const paths = new Map<string, string>()
  43. for (const root of inventory.roots) {
  44. const seen = new Set<number>()
  45. const visit = (index: number, path: string): void => {
  46. if (seen.has(index)) return
  47. seen.add(index)
  48. const digest = schemaDigest(canonicalizeSchema(root.schema.nodes, index))
  49. if (!paths.has(digest)) paths.set(digest, path)
  50. const node = nodeAt(root.schema, index)
  51. schemaChildren(node).forEach((child, position) => { visit(child, `${path}.${childPath(node, position)}`) })
  52. }
  53. visit(0, root.key)
  54. }
  55. const labels = inventory.types.map((type) => {
  56. const node = nodeAt(type.schema, 0)
  57. const label = node.kind === 'primitive' ? node.type
  58. : node.kind === 'literal' ? JSON.stringify(node.value)
  59. : node.kind === 'opaque' ? node.reason
  60. : type.names[0] ?? paths.get(type.digest) ?? node.kind
  61. return { type, label }
  62. })
  63. const counts = new Map<string, number>()
  64. for (const { label } of labels) counts.set(label, (counts.get(label) ?? 0) + 1)
  65. const used = new Set<string>()
  66. return new Map(labels.map(({ type, label: original }) => {
  67. const label = (counts.get(original) ?? 0) > 1
  68. ? `${original} (${type.sources[0] ? sourcePath(type.sources[0]) : paths.get(type.digest) ?? 'anonymous'})`
  69. : original
  70. const base = `persistence-type-${githubSlug(label)}`
  71. let anchor = base
  72. for (let suffix = 2; used.has(anchor); suffix += 1) anchor = `${base}-${String(suffix)}`
  73. used.add(anchor)
  74. return [type.digest, { type, label, anchor }]
  75. }))
  76. }
  77. function reference(digest: string, entries: ReadonlyMap<string, TypeDisplay>): string {
  78. const entry = entries.get(digest)
  79. if (!entry) throw new Error(`persistence catalog: reachable type ${digest} is absent from the inventory`)
  80. return `[${code(entry.label)}](#${entry.anchor})`
  81. }
  82. function typeExpression(
  83. schema: CanonicalSchema,
  84. index: number,
  85. entries: ReadonlyMap<string, TypeDisplay>,
  86. locale: PersistenceCatalogLocale,
  87. ): string {
  88. const node = nodeAt(schema, index)
  89. if (node.kind === 'primitive') return code(node.type)
  90. if (node.kind === 'literal') return code(JSON.stringify(node.value))
  91. if (node.kind === 'opaque') return `${code(node.reason)}${persistenceCatalogText[locale].opaque}`
  92. return reference(schemaDigest(canonicalizeSchema(schema.nodes, index)), entries)
  93. }
  94. function definition(entry: TypeDisplay, entries: ReadonlyMap<string, TypeDisplay>, locale: PersistenceCatalogLocale): string[] {
  95. const text = persistenceCatalogText[locale]
  96. const schema = entry.type.schema
  97. const node = nodeAt(schema, 0)
  98. const lines = [`<a id="${entry.anchor}"></a>`, '', `### ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
  99. if (entry.type.sources.length > 0) {
  100. lines.push(`${text.sources}${entry.type.sources.map(source => `[${code(source)}](../${sourcePath(source)})`).join(' · ')}`, '')
  101. }
  102. const expression = (index: number): string => typeExpression(schema, index, entries, locale)
  103. switch (node.kind) {
  104. case 'object':
  105. if (node.properties.length === 0 && node.indices.length === 0) lines.push(text.emptyObject, '')
  106. else {
  107. lines.push(text.propertyColumns, '|---|---|---|')
  108. for (const property of node.properties) {
  109. lines.push(`| ${code(property.name)} | ${property.optional ? text.optional : text.required} | ${expression(property.type)} |`)
  110. }
  111. for (const index of node.indices) lines.push(`| [${expression(index.key)}] | ${text.index} | ${expression(index.value)} |`)
  112. lines.push('')
  113. }
  114. break
  115. case 'array': lines.push(`${text.arrayPrefix}${expression(node.element)}${text.arraySuffix}`, ''); break
  116. case 'tuple':
  117. lines.push(text.positionColumns, '|---|---|---|')
  118. node.elements.forEach((element, index) => {
  119. const presence = element.rest ? text.rest : element.optional ? text.optional : text.required
  120. lines.push(`| ${String(index)} | ${presence} | ${expression(element.type)} |`)
  121. })
  122. lines.push('')
  123. break
  124. case 'union':
  125. lines.push(text.oneOf, '', ...node.types.map(index => `- ${expression(index)}`), '')
  126. break
  127. case 'primitive': lines.push(code(node.type), ''); break
  128. case 'literal': lines.push(code(JSON.stringify(node.value)), ''); break
  129. case 'opaque': lines.push(`${code(node.reason)}${text.opaqueExplanation}`, ''); break
  130. default: assertNever(node)
  131. }
  132. return lines
  133. }
  134. /**
  135. * Render every tracked root with its exact digest and resolved type reference.
  136. * @param inventory - complete current-source schemas and declaration metadata.
  137. * @param locale - generated document language.
  138. * @returns Markdown index including the history and contributor workflow links.
  139. */
  140. export function renderPersistenceSchemaIndex(inventory: PersistenceSchemaInventory, locale: PersistenceCatalogLocale = 'en'): string {
  141. const entries = displays(inventory)
  142. const text = persistenceCatalogText[locale]
  143. return [
  144. `## ${text.fingerprints}`, '', text.fingerprintsIntro, '', text.historyIntro, '',
  145. text.rootColumns, '|---|---|---|---|',
  146. ...inventory.roots.map(root => `| ${code(root.key)} | ${root.kind} | ${code(root.digest)} | ${reference(root.digest, entries)} |`), '',
  147. ].join('\n')
  148. }
  149. /**
  150. * Render every reachable type once, with links for shared and recursive definitions.
  151. * @param inventory - complete current-source schemas and declaration metadata.
  152. * @param locale - generated document language.
  153. * @returns Markdown definitions whose anchors use names or owning paths instead of hashes.
  154. */
  155. export function renderPersistenceSchemaDefinitions(inventory: PersistenceSchemaInventory, locale: PersistenceCatalogLocale = 'en'): string {
  156. const entries = displays(inventory)
  157. const text = persistenceCatalogText[locale]
  158. const sorted = [...entries.values()].sort((left, right) => left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0)
  159. return [
  160. `## ${text.definitions}`, '', text.definitionsIntro, '',
  161. ...sorted.flatMap(entry => definition(entry, entries, locale)),
  162. ].join('\n')
  163. }
  164. function assertNever(value: never): never {
  165. throw new Error(`persistence catalog: unsupported type ${JSON.stringify(value)}`)
  166. }