verify-github-growth-assets.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import { existsSync } from 'node:fs'
  2. import { readFile } from 'node:fs/promises'
  3. import { dirname, join, resolve } from 'node:path'
  4. import { spawnSync } from 'node:child_process'
  5. import { fileURLToPath } from 'node:url'
  6. const scriptDir = dirname(fileURLToPath(import.meta.url))
  7. const sourceRoot = resolve(scriptDir, '..')
  8. const args = process.argv.slice(2)
  9. const offline = args.includes('--offline')
  10. const strictSocialPreview = args.includes('--strict-social-preview')
  11. const requiredFiles = [
  12. 'README.md',
  13. 'README.en.md',
  14. 'CONTRIBUTING.md',
  15. 'SECURITY.md',
  16. 'SUPPORT.md',
  17. 'CODE_OF_CONDUCT.md',
  18. 'ROADMAP.md',
  19. 'CHANGELOG.md',
  20. 'RELEASE_TEMPLATE.md',
  21. 'GITHUB_GROWTH_PLAYBOOK.md',
  22. 'GITHUB_GROWTH_AUDIT.md',
  23. 'GITHUB_SETTINGS_CHECKLIST.md',
  24. 'TRAFFIC_TRACKING.md',
  25. 'CONTENT_DISTRIBUTION_PLAN.md',
  26. '.github/FUNDING.yml',
  27. '.github/PULL_REQUEST_TEMPLATE.md',
  28. '.github/ISSUE_TEMPLATE/bug_report.yml',
  29. '.github/ISSUE_TEMPLATE/compatibility.yml',
  30. '.github/ISSUE_TEMPLATE/feature_request.yml',
  31. '.github/social-preview.svg',
  32. '.github/social-preview.png',
  33. 'docs/guide/compare.md',
  34. 'docs/en/guide/compare.md'
  35. ]
  36. const desiredTopics = [
  37. 'file-viewer',
  38. 'document-viewer',
  39. 'document-preview',
  40. 'file-preview',
  41. 'office-viewer',
  42. 'pdf-viewer',
  43. 'docx',
  44. 'pptx',
  45. 'xlsx',
  46. 'cad-viewer',
  47. 'dwg',
  48. 'dxf',
  49. 'vue',
  50. 'react',
  51. 'typescript',
  52. 'web-components',
  53. 'wasm',
  54. 'offline-first',
  55. 'self-hosted',
  56. 'private-deployment'
  57. ]
  58. const expectedDescription =
  59. 'Browser-native Office / PDF / CAD / archive viewer for internal web apps, with Vue, React, Svelte, jQuery, Web Components, and no server-side conversion.'
  60. const failures = []
  61. const pass = message => console.log(`ok - ${message}`)
  62. const fail = message => failures.push(message)
  63. const assertFile = file => {
  64. if (existsSync(resolve(sourceRoot, file))) {
  65. pass(`${file} exists`)
  66. } else {
  67. fail(`${file} is missing`)
  68. }
  69. }
  70. const readText = async file => readFile(resolve(sourceRoot, file), 'utf8')
  71. for (const file of requiredFiles) {
  72. assertFile(file)
  73. }
  74. const readme = await readText('README.md')
  75. const readmeEn = await readText('README.en.md')
  76. const demo = await readText('apps/viewer-demo/src/components/HelloWorld.vue')
  77. const playbook = await readText('GITHUB_GROWTH_PLAYBOOK.md')
  78. const requiredTextChecks = [
  79. [readme, '面向企业后台、内网和私有化系统的纯前端文件预览组件', 'Chinese README first-screen positioning'],
  80. [readme, '无需服务端转码', 'Chinese README no server-side conversion'],
  81. [readme, '当前内置 206+ 扩展名映射和 24 条预览链路', 'Chinese README format evidence'],
  82. [readme, '按场景选择入口', 'Chinese README scenario entrances'],
  83. [readme, '尤其需要真实业务文件来验证兼容性', 'Chinese README compatibility feedback'],
  84. [readmeEn, 'browser-native file viewer for private and internal web apps', 'English README first-screen positioning'],
  85. [readmeEn, 'without server-side conversion', 'English README no server-side conversion'],
  86. [readmeEn, '206+ extensions across 24 preview pipelines', 'English README format evidence'],
  87. [readmeEn, 'Choose By Scenario', 'English README scenario entrances'],
  88. [demo, '试试 Word 合同', 'Demo Word scenario shortcut'],
  89. [demo, '试试 DWG 图纸', 'Demo CAD scenario shortcut'],
  90. [demo, 'copySnippet', 'Demo copyable integration snippet'],
  91. [playbook, 'pnpm github:topics:update', 'Playbook has topics execution command'],
  92. [playbook, 'CONTENT_DISTRIBUTION_PLAN.md', 'Playbook links content distribution plan']
  93. ]
  94. for (const [source, needle, label] of requiredTextChecks) {
  95. if (source.includes(needle)) {
  96. pass(label)
  97. } else {
  98. fail(`${label} missing text: ${needle}`)
  99. }
  100. }
  101. const socialPreview = await readFile(resolve(sourceRoot, '.github/social-preview.png'))
  102. if (socialPreview.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') {
  103. fail('Social preview is not a PNG')
  104. } else {
  105. const width = socialPreview.readUInt32BE(16)
  106. const height = socialPreview.readUInt32BE(20)
  107. if (width === 1280 && height === 640) {
  108. pass('Social preview PNG is 1280x640')
  109. } else {
  110. fail(`Social preview PNG expected 1280x640, got ${width}x${height}`)
  111. }
  112. }
  113. const runGhJson = commandArgs => {
  114. const result = spawnSync('gh', commandArgs, {
  115. cwd: sourceRoot,
  116. encoding: 'utf8',
  117. stdio: 'pipe'
  118. })
  119. if (result.status !== 0) {
  120. const details = [result.stderr, result.stdout].filter(Boolean).join('\n')
  121. throw new Error(`Command failed: gh ${commandArgs.join(' ')}${details ? `\n${details}` : ''}`)
  122. }
  123. return JSON.parse(result.stdout || '{}')
  124. }
  125. if (!offline) {
  126. const remote = runGhJson([
  127. 'repo',
  128. 'view',
  129. 'flyfish-dev/file-viewer',
  130. '--json',
  131. 'description,homepageUrl,repositoryTopics'
  132. ])
  133. if (remote.description === expectedDescription) {
  134. pass('GitHub remote description matches growth positioning')
  135. } else {
  136. fail(`GitHub remote description mismatch: ${remote.description}`)
  137. }
  138. const remoteTopics = (remote.repositoryTopics || []).map(topic => topic.name).sort()
  139. const expectedTopics = [...desiredTopics].sort()
  140. if (JSON.stringify(remoteTopics) === JSON.stringify(expectedTopics)) {
  141. pass('GitHub remote topics match desired discovery set')
  142. } else {
  143. fail(`GitHub remote topics mismatch: ${remoteTopics.join(', ')}`)
  144. }
  145. if (remote.homepageUrl === 'https://file-viewer.app') {
  146. pass('GitHub remote homepage is file-viewer.app')
  147. } else {
  148. fail(`GitHub remote homepage mismatch: ${remote.homepageUrl}`)
  149. }
  150. const openGraph = runGhJson([
  151. 'api',
  152. 'graphql',
  153. '-F',
  154. 'owner=flyfish-dev',
  155. '-F',
  156. 'name=file-viewer',
  157. '-f',
  158. 'query=query($owner:String!, $name:String!) { repository(owner:$owner, name:$name) { usesCustomOpenGraphImage } }'
  159. ])
  160. const usesCustomOpenGraphImage = Boolean(openGraph.data?.repository?.usesCustomOpenGraphImage)
  161. if (usesCustomOpenGraphImage) {
  162. pass('GitHub remote custom social preview is configured')
  163. } else if (strictSocialPreview) {
  164. fail('GitHub remote custom social preview is not configured')
  165. } else {
  166. console.warn('warn - GitHub remote custom social preview is not configured; run with --strict-social-preview after uploading the image.')
  167. }
  168. }
  169. if (failures.length) {
  170. console.error('\nGitHub growth asset verification failed:')
  171. for (const failure of failures) {
  172. console.error(`- ${failure}`)
  173. }
  174. process.exit(1)
  175. }
  176. console.log('\nGitHub growth assets verified.')