verify-wrapper-options.mjs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import { constants } from 'node:fs'
  2. import { access, readFile } from 'node:fs/promises'
  3. import { dirname, join, resolve } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { loadEcosystemReleaseContext } from './lib/ecosystem-packages.mjs'
  6. const scriptDir = dirname(fileURLToPath(import.meta.url))
  7. const sourceRoot = resolve(scriptDir, '..')
  8. const { wrapperManifest } = await loadEcosystemReleaseContext(sourceRoot)
  9. const requiredViewerTypeExports = [
  10. 'FileRef',
  11. 'ViewerAiOptions',
  12. 'ViewerArchiveOptions',
  13. 'ViewerCadOptions',
  14. 'ViewerController',
  15. 'ViewerControllerAccessor',
  16. 'ViewerControllerHandle',
  17. 'ViewerDocxOptions',
  18. 'ViewerEvent',
  19. 'ViewerEventHandler',
  20. 'ViewerEventType',
  21. 'ViewerFetchFile',
  22. 'ViewerFetchInput',
  23. 'ViewerMountOptions',
  24. 'ViewerOptions',
  25. 'ViewerPdfOptions',
  26. 'ViewerSpreadsheetOptions',
  27. 'ViewerCoreOptions',
  28. 'ViewerSearchOptions',
  29. 'ViewerSourceInput',
  30. 'ViewerThemeMode',
  31. 'ViewerToolbarOptions',
  32. 'ViewerToolbarPosition',
  33. 'ViewerTypstOptions',
  34. 'ViewerWatermarkOptions'
  35. ]
  36. const requiredVue3TypeExports = [
  37. 'FileViewerVue3PluginOptions',
  38. 'FileViewerVue3Handle'
  39. ]
  40. const forbiddenLegacyOptionTypes = [
  41. 'CreateViewerFrameOptions',
  42. 'ViewerDirectFrameHandle',
  43. 'ViewerFrameComponentBridgeOptions',
  44. 'ViewerFrameComponentProps',
  45. 'ViewerFrameContainerComponentProps',
  46. 'ViewerFrameController',
  47. 'ViewerFrameControllerAccessor',
  48. 'ViewerFrameControllerHandle',
  49. 'ViewerFrameEventHandler',
  50. 'ViewerFrameEventPayload',
  51. 'ViewerFrameEventType',
  52. 'ViewerFrameFilePostController',
  53. 'ViewerFrameFilePostControllerOptions',
  54. 'ViewerFrameHostComponentProps',
  55. 'ViewerFrameIframeComponentProps',
  56. 'ViewerFrameOptions',
  57. 'ViewerFrameParamValue',
  58. 'ViewerMountedFrameHandle'
  59. ]
  60. const forbiddenDirectCoreOptionTypes = [
  61. 'FileViewerAiOptions',
  62. 'FileViewerArchiveOptions',
  63. 'FileViewerCadOptions',
  64. 'FileViewerDocxOptions',
  65. 'FileViewerOptions',
  66. 'FileViewerPdfOptions',
  67. 'FileViewerSpreadsheetOptions',
  68. 'FileViewerSearchOptions',
  69. 'FileViewerSerializableCadOptions',
  70. 'FileViewerSerializableOptions',
  71. 'FileViewerSerializableToolbarOptions',
  72. 'FileViewerThemeMode',
  73. 'FileViewerToolbarOptions',
  74. 'FileViewerToolbarPosition',
  75. 'FileViewerTypstOptions',
  76. 'FileViewerWatermarkOptions'
  77. ]
  78. const forbiddenLocalOptionFields = [
  79. 'ai',
  80. 'archive',
  81. 'beforeOperation',
  82. 'cad',
  83. 'docx',
  84. 'pdf',
  85. 'spreadsheet',
  86. 'search',
  87. 'theme',
  88. 'toolbar',
  89. 'typst',
  90. 'watermark'
  91. ]
  92. function assert(condition, message) {
  93. if (!condition) {
  94. throw new Error(message)
  95. }
  96. }
  97. async function exists(path) {
  98. try {
  99. await access(path, constants.F_OK)
  100. return true
  101. } catch {
  102. return false
  103. }
  104. }
  105. async function readWrapperSource(packageDir) {
  106. const candidates = [
  107. join(sourceRoot, packageDir, 'src', 'index.ts'),
  108. join(sourceRoot, packageDir, 'src', 'index.tsx'),
  109. join(sourceRoot, packageDir, 'src', 'package', 'index.ts')
  110. ]
  111. for (const candidate of candidates) {
  112. if (await exists(candidate)) {
  113. return {
  114. path: candidate,
  115. content: await readFile(candidate, 'utf8')
  116. }
  117. }
  118. }
  119. throw new Error(`${packageDir} must provide src/index.ts or src/index.tsx`)
  120. }
  121. async function readWrapperControllerSource(packageDir) {
  122. const candidates = [
  123. join(sourceRoot, packageDir, 'src', 'controller.ts'),
  124. join(sourceRoot, packageDir, 'src', 'package', 'controller.ts')
  125. ]
  126. for (const candidate of candidates) {
  127. if (await exists(candidate)) {
  128. return {
  129. path: candidate,
  130. content: await readFile(candidate, 'utf8')
  131. }
  132. }
  133. }
  134. throw new Error(`${packageDir} must provide a local src/controller.ts wrapper controller`)
  135. }
  136. function escapeRegExp(value) {
  137. return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  138. }
  139. function hasTypeToken(source, typeName) {
  140. return new RegExp(`\\b${escapeRegExp(typeName)}\\b`).test(source)
  141. }
  142. function hasExportedType(source, typeName, specifier) {
  143. const directAlias = new RegExp(`export\\s+(?:interface|type)\\s+${escapeRegExp(typeName)}\\b`)
  144. const reexportBlock = new RegExp(
  145. `export\\s+type\\s*{[\\s\\S]*\\b${escapeRegExp(typeName)}\\b[\\s\\S]*}\\s+from\\s+['"]${escapeRegExp(specifier)}['"]`
  146. )
  147. return directAlias.test(source) || reexportBlock.test(source)
  148. }
  149. function hasForbiddenCoreOptionImport(source, typeName) {
  150. const coreTypeImport = new RegExp(
  151. `import\\s+type\\s*{[\\s\\S]*\\b${escapeRegExp(typeName)}\\b[\\s\\S]*}\\s+from\\s+['"]@file-viewer/core['"]`
  152. )
  153. const mixedCoreImport = new RegExp(
  154. `import\\s*{[\\s\\S]*\\btype\\s+${escapeRegExp(typeName)}\\b[\\s\\S]*}\\s+from\\s+['"]@file-viewer/core['"]`
  155. )
  156. return coreTypeImport.test(source) || mixedCoreImport.test(source)
  157. }
  158. function hasForbiddenLocalOptionField(source, fieldName) {
  159. const fieldDeclaration = new RegExp(`(^|\\n)\\s*${escapeRegExp(fieldName)}\\??\\s*:\\s*[^,\\n]+;`, 'm')
  160. return fieldDeclaration.test(source)
  161. }
  162. let checked = 0
  163. for (const wrapper of wrapperManifest.wrappers) {
  164. const { content, path } = await readWrapperSource(wrapper.packageDir)
  165. if (wrapper.flavor === 'full') {
  166. assert(wrapper.basePackage, `${wrapper.packageName} full package must declare basePackage`)
  167. assert(
  168. content.includes('\'@file-viewer/preset-all\'') || content.includes('"@file-viewer/preset-all"'),
  169. `${path} must import @file-viewer/preset-all to enable the complete format matrix`
  170. )
  171. assert(
  172. content.includes(`'${wrapper.basePackage}'`) || content.includes(`"${wrapper.basePackage}"`),
  173. `${path} must re-export its standard package ${wrapper.basePackage}`
  174. )
  175. for (const legacyType of forbiddenLegacyOptionTypes) {
  176. assert(!hasTypeToken(content, legacyType), `${path} must not expose legacy iframe option type ${legacyType}`)
  177. }
  178. checked += 1
  179. continue
  180. }
  181. const { content: controllerContent, path: controllerPath } = await readWrapperControllerSource(wrapper.packageDir)
  182. for (const legacyType of forbiddenLegacyOptionTypes) {
  183. assert(!hasTypeToken(content, legacyType), `${path} must not expose legacy iframe option type ${legacyType}`)
  184. assert(!hasTypeToken(controllerContent, legacyType), `${controllerPath} must not expose legacy iframe option type ${legacyType}`)
  185. }
  186. assert(
  187. controllerContent.includes('from \'@file-viewer/core\'') || controllerContent.includes('from "@file-viewer/core"'),
  188. `${controllerPath} must consume the shared @file-viewer/core low-level contract`
  189. )
  190. for (const forbiddenToken of [
  191. 'createFileViewerNativeController',
  192. 'resolveFileViewerNativeLoadSource',
  193. 'FileViewerNativeController',
  194. 'FileViewerNativeFetchFile',
  195. 'FileViewerNativeFetchInput',
  196. 'FileViewerNativeSource'
  197. ]) {
  198. assert(
  199. !controllerContent.includes(forbiddenToken),
  200. `${controllerPath} must keep browser mount/source orchestration inside the component package instead of importing ${forbiddenToken} from core`
  201. )
  202. }
  203. assert(
  204. !content.includes('from \'@file-viewer/web\'') && !content.includes('from "@file-viewer/web"'),
  205. `${path} must not consume another component package`
  206. )
  207. assert(
  208. !controllerContent.includes('from \'@file-viewer/web\'') && !controllerContent.includes('from "@file-viewer/web"'),
  209. `${controllerPath} must not consume another component package`
  210. )
  211. assert(
  212. content.includes('from \'./controller\'') ||
  213. content.includes('from "./controller"') ||
  214. content.includes('from \'./controller.js\'') ||
  215. content.includes('from "./controller.js"'),
  216. `${path} must consume its local component controller instead of core mount helpers`
  217. )
  218. if (wrapper.packageName !== '@file-viewer/vue3') {
  219. assert(
  220. content.includes('from \'@file-viewer/core\'') || content.includes('from "@file-viewer/core"'),
  221. `${path} must inject the shared core renderer registry explicitly`
  222. )
  223. }
  224. for (const typeName of requiredViewerTypeExports) {
  225. assert(
  226. hasExportedType(content, typeName, './controller') ||
  227. hasExportedType(content, typeName, './controller.js') ||
  228. hasTypeToken(content, typeName),
  229. `${path} must re-export ${typeName} from the local component controller`
  230. )
  231. }
  232. if (wrapper.packageName === '@file-viewer/vue3') {
  233. for (const typeName of requiredVue3TypeExports) {
  234. assert(hasTypeToken(content, typeName), `${path} must expose Vue 3 type ${typeName}`)
  235. }
  236. checked += 1
  237. continue
  238. }
  239. for (const typeName of forbiddenDirectCoreOptionTypes) {
  240. assert(
  241. !hasForbiddenCoreOptionImport(content, typeName),
  242. `${path} must not import ${typeName} directly from @file-viewer/core; use shared Viewer* aliases`
  243. )
  244. }
  245. for (const fieldName of forbiddenLocalOptionFields) {
  246. assert(
  247. !hasForbiddenLocalOptionField(content, fieldName),
  248. `${path} must not redeclare option field "${fieldName}"; pass it through ViewerMountOptions`
  249. )
  250. }
  251. checked += 1
  252. }
  253. console.log(`Verified ${checked} standard component package option surfaces against the shared core contracts.`)