verify-renderer-contracts.mjs 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. import { existsSync, statSync } from 'node:fs'
  2. import { readFile } from 'node:fs/promises'
  3. import { dirname, join, resolve } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import {
  6. collectPackageEntrypoints,
  7. loadEcosystemReleaseContext
  8. } from './lib/ecosystem-packages.mjs'
  9. const scriptDir = dirname(fileURLToPath(import.meta.url))
  10. const sourceRoot = resolve(scriptDir, '..')
  11. const { entries, rootPackage } = await loadEcosystemReleaseContext(sourceRoot)
  12. const expectedWorkspaceRange = `workspace:^${rootPackage.version}`
  13. const packageNamesByKind = entries.reduce((result, entry) => {
  14. result[entry.kind] ||= new Set()
  15. result[entry.kind].add(entry.packageName)
  16. return result
  17. }, {})
  18. const wrapperPackageNames = new Set([
  19. ...(packageNamesByKind['standard-wrapper'] || []),
  20. ...(packageNamesByKind.compatibility || [])
  21. ])
  22. const rendererEntries = entries.filter(entry => entry.kind === 'renderer')
  23. const pluginEnginePackages = new Set([
  24. '@file-viewer/pptx',
  25. '@file-viewer/eda-layout',
  26. '@file-viewer/eda-orcad',
  27. '@file-viewer/geometry-engine'
  28. ])
  29. function assert(condition, message) {
  30. if (!condition) {
  31. throw new Error(message)
  32. }
  33. }
  34. function assertFile(path, label = path) {
  35. assert(existsSync(path), `Missing ${label}`)
  36. assert(statSync(path).isFile(), `${label} must be a file`)
  37. }
  38. function assertDirectory(path, label = path) {
  39. assert(existsSync(path), `Missing ${label}`)
  40. assert(statSync(path).isDirectory(), `${label} must be a directory`)
  41. }
  42. function normalizeEntrypoint(value) {
  43. return String(value || '').replace(/^\.\//, '')
  44. }
  45. function dependencyFields(packageJson) {
  46. return {
  47. dependencies: packageJson.dependencies || {},
  48. optionalDependencies: packageJson.optionalDependencies || {},
  49. peerDependencies: packageJson.peerDependencies || {}
  50. }
  51. }
  52. function assertNoWrapperDependencies(entry) {
  53. for (const [field, dependencies] of Object.entries(dependencyFields(entry.packageJson))) {
  54. for (const packageName of Object.keys(dependencies)) {
  55. assert(
  56. !wrapperPackageNames.has(packageName),
  57. `${entry.packageName} ${field}.${packageName} must not depend on a component or compatibility wrapper`
  58. )
  59. }
  60. }
  61. }
  62. function assertCommonPackageContract(entry) {
  63. const packageJson = entry.packageJson
  64. assert(packageJson.name === entry.renderer.packageName, `${entry.id} package name drifted from wrappers.json`)
  65. assert(packageJson.private !== true, `${entry.packageName} must be publishable`)
  66. assert(packageJson.type === 'module', `${entry.packageName} must be an ESM package`)
  67. assert(packageJson.publishConfig?.access === 'public', `${entry.packageName} publishConfig.access must be public`)
  68. assert(packageJson.main === './dist/index.js', `${entry.packageName} main must be ./dist/index.js`)
  69. assert(packageJson.module === './dist/index.js', `${entry.packageName} module must be ./dist/index.js`)
  70. assert(packageJson.types === './dist/index.d.ts', `${entry.packageName} types must be ./dist/index.d.ts`)
  71. assert(packageJson.exports?.['.']?.import === './dist/index.js', `${entry.packageName} exports["."].import must be ./dist/index.js`)
  72. assert(packageJson.exports?.['.']?.types === './dist/index.d.ts', `${entry.packageName} exports["."].types must be ./dist/index.d.ts`)
  73. assert(packageJson.exports?.['./package.json'] === './package.json', `${entry.packageName} must export ./package.json`)
  74. assert(packageJson.scripts?.build, `${entry.packageName} must provide a build script`)
  75. assert(packageJson.scripts?.['type-check'], `${entry.packageName} must provide a type-check script`)
  76. const files = new Set(packageJson.files || [])
  77. for (const requiredFile of ['dist', 'README.md', 'README.en.md', 'LICENSE']) {
  78. assert(files.has(requiredFile), `${entry.packageName} package files must include ${requiredFile}`)
  79. }
  80. assertFile(join(entry.absoluteDir, 'README.md'), `${entry.packageName} README.md`)
  81. assertFile(join(entry.absoluteDir, 'README.en.md'), `${entry.packageName} README.en.md`)
  82. assertFile(join(entry.absoluteDir, 'LICENSE'), `${entry.packageName} LICENSE`)
  83. assertDirectory(join(entry.absoluteDir, 'dist'), `${entry.packageName} dist/`)
  84. collectPackageEntrypoints(packageJson).forEach(entrypoint => {
  85. assertFile(
  86. join(entry.absoluteDir, normalizeEntrypoint(entrypoint)),
  87. `${entry.packageName} ${entrypoint}`
  88. )
  89. })
  90. assertNoWrapperDependencies(entry)
  91. }
  92. async function assertPluginRendererContract(entry) {
  93. const sourceIndex = join(entry.absoluteDir, 'src', 'index.ts')
  94. assertFile(sourceIndex, `${entry.packageName} src/index.ts`)
  95. const source = await readFile(sourceIndex, 'utf8')
  96. const exportBaseName = entry.renderer.id.replace(/(^|-)([a-z0-9])/g, (_match, _dash, char) => char.toUpperCase())
  97. const camelBaseName = exportBaseName.charAt(0).toLowerCase() + exportBaseName.slice(1)
  98. assert(
  99. entry.packageJson.dependencies?.['@file-viewer/core'] === expectedWorkspaceRange,
  100. `${entry.packageName} must depend on @file-viewer/core@${expectedWorkspaceRange}`
  101. )
  102. assert(
  103. /export\s+default\s+\w+Renderer/.test(source),
  104. `${entry.packageName} src/index.ts must default-export a renderer plugin`
  105. )
  106. assert(
  107. source.includes(`export const ${camelBaseName}Renderer`) ||
  108. source.includes(`export const ${camelBaseName}RendererDefinitions`) ||
  109. source.includes(`export const ${camelBaseName}RendererDefinition`),
  110. `${entry.packageName} src/index.ts must expose a named renderer export`
  111. )
  112. assert(
  113. source.includes('renderFileViewer'),
  114. `${entry.packageName} src/index.ts must expose lazy renderFileViewer* handlers`
  115. )
  116. assert(
  117. source.includes('import('),
  118. `${entry.packageName} src/index.ts must keep heavy renderer implementation behind dynamic import()`
  119. )
  120. }
  121. for (const entry of rendererEntries) {
  122. assertCommonPackageContract(entry)
  123. if (!pluginEnginePackages.has(entry.packageName)) {
  124. await assertPluginRendererContract(entry)
  125. }
  126. }
  127. console.log(
  128. `[renderer-contracts] Verified ${rendererEntries.length} renderer packages, including ${rendererEntries.length - pluginEnginePackages.size} plugin renderer packages and ${pluginEnginePackages.size} engine package.`
  129. )