check-ui-build.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. #!/usr/bin/env node
  2. /**
  3. * Assert that the browser viewer actually built.
  4. *
  5. * `codegraph ui` serves dist/viewer/ as static files. If that tree is missing
  6. * or half-written, the CLI still starts and the browser gets a 404 — a failure
  7. * that would otherwise surface after the release is published. So the build
  8. * fails here instead: index.html must exist, be non-trivial, and every local
  9. * asset it references must be on disk next to it.
  10. *
  11. * It also re-asserts that the compiled engine is still there. The viewer build
  12. * empties its own output directory, and `dist/ui/` — the obvious name — is
  13. * where tsc puts the TERMINAL ui, so a mis-pointed outDir silently deletes
  14. * modules the CLI requires at startup.
  15. *
  16. * The pruned TextMate grammars in dist/textmate/ are checked the same way and
  17. * for the same reason: without them every file the viewer shows falls back to
  18. * unhighlighted text, which looks like a styling bug rather than a missing
  19. * build step.
  20. *
  21. * Usage: node scripts/check-ui-build.mjs [--root <dir>]
  22. * --root directory holding dist/ (default: the repo root). The release
  23. * bundler points this at its staging dir to verify the copy.
  24. */
  25. import { existsSync, readFileSync, statSync } from 'node:fs';
  26. import { dirname, join, resolve, sep } from 'node:path';
  27. import { fileURLToPath } from 'node:url';
  28. const argv = process.argv.slice(2);
  29. const rootFlag = argv.indexOf('--root');
  30. const staged = rootFlag >= 0 && Boolean(argv[rootFlag + 1]);
  31. const root = staged
  32. ? resolve(argv[rootFlag + 1])
  33. : resolve(dirname(fileURLToPath(import.meta.url)), '..');
  34. const viewerDir = join(root, 'dist', 'viewer');
  35. const indexHtml = join(viewerDir, 'index.html');
  36. function fail(message, hint) {
  37. console.error(`[check-ui-build] ${message}`);
  38. if (hint) console.error(`[check-ui-build] ${hint}`);
  39. process.exit(1);
  40. }
  41. if (!existsSync(indexHtml)) {
  42. fail(
  43. `missing ${indexHtml}`,
  44. staged
  45. ? 'this bundle predates the UI or was assembled from a stale archive — rebuild it with scripts/build-bundle.sh'
  46. : 'the UI workspace did not build — run `npm run build:ui` (or `npm ci` if ui/ has no node_modules)'
  47. );
  48. }
  49. const html = readFileSync(indexHtml, 'utf8');
  50. if (html.length < 200 || !/<div id="app">/.test(html)) {
  51. fail(`${indexHtml} does not look like the built viewer (${html.length} bytes)`);
  52. }
  53. // Every local src=/href= in the document must resolve inside dist/ui. This is
  54. // what catches a partial write: index.html naming a hashed bundle that the
  55. // build never emitted.
  56. const referenced = [...html.matchAll(/\s(?:src|href)="([^"]+)"/g)].map((m) => m[1]);
  57. const local = referenced.filter(
  58. (url) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url) && !url.startsWith('#')
  59. );
  60. const missing = [];
  61. let assets = 0;
  62. for (const url of local) {
  63. const rel = url.replace(/^\.\//, '').replace(/[?#].*$/, '');
  64. if (!rel) continue;
  65. const onDisk = join(viewerDir, ...rel.split('/'));
  66. if (!existsSync(onDisk) || !statSync(onDisk).isFile()) missing.push(rel);
  67. else assets += 1;
  68. }
  69. if (missing.length > 0) {
  70. fail(
  71. `index.html references ${missing.length} file(s) that are not in dist/viewer: ${missing.join(', ')}`,
  72. 'the UI build was interrupted or dist/viewer was copied incompletely'
  73. );
  74. }
  75. if (assets === 0) {
  76. fail('index.html references no bundled assets — the UI build produced no JS/CSS');
  77. }
  78. // The viewer build must never have eaten the tsc output next door.
  79. for (const compiled of [join('bin', 'codegraph.js'), 'index.js', join('ui', 'shimmer-progress.js')]) {
  80. if (!existsSync(join(root, 'dist', compiled))) {
  81. fail(
  82. `dist/${compiled.split(sep).join('/')} is missing — the compiled engine is incomplete`,
  83. "if this appeared with a UI change, check ui/vite.config.ts: build.outDir must stay dist/viewer, and emptyOutDir must never point at a directory tsc writes (dist/ui is the TERMINAL ui)"
  84. );
  85. }
  86. }
  87. // The pruned syntax grammars (scripts/prune-grammars.mjs). Their absence is
  88. // survivable at runtime — source is served unhighlighted — which is exactly why
  89. // it has to fail here: nothing downstream would ever complain.
  90. const textmateDir = join(root, 'dist', 'textmate');
  91. const manifestPath = join(textmateDir, 'manifest.json');
  92. if (!existsSync(manifestPath)) {
  93. fail(
  94. `missing ${manifestPath}`,
  95. staged
  96. ? 'this bundle was assembled before the syntax grammars were added, or dist/textmate was not copied'
  97. : 'run `npm run build:textmate` (it needs @shikijs/langs from devDependencies)'
  98. );
  99. }
  100. const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
  101. const languages = Object.keys(manifest.languages ?? {});
  102. if (languages.length === 0) fail('dist/textmate/manifest.json lists no languages');
  103. const grammarFiles = new Set(Object.values(manifest.languages).flat());
  104. const missingGrammars = [...grammarFiles].filter(
  105. (name) => !existsSync(join(textmateDir, `${name}.json`))
  106. );
  107. if (missingGrammars.length > 0) {
  108. fail(
  109. `dist/textmate is missing ${missingGrammars.length} grammar file(s): ${missingGrammars.join(', ')}`,
  110. 'the prune step was interrupted or dist/textmate was copied incompletely'
  111. );
  112. }
  113. console.log(
  114. `[check-ui-build] dist/viewer ok (index.html + ${assets} referenced asset(s)); ` +
  115. `dist/textmate ok (${languages.length} languages, ${grammarFiles.size} grammars); dist/ engine intact`
  116. );