prune-grammars.mjs 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. #!/usr/bin/env node
  2. /**
  3. * Write the TextMate grammars the viewer needs into `dist/textmate/`.
  4. *
  5. * Shiki carries about 700 grammars, 11 MB of JSON. The engine indexes about 40
  6. * languages. Shipping the other 660 to every user of a code-intelligence CLI is
  7. * not a trade worth making, so `@shikijs/langs` stays a devDependency and this
  8. * step copies out exactly the closure the viewer can reach: every grammar named
  9. * in `src/ui-server/highlight/languages.ts`, plus every grammar those embed
  10. * (`vue` needs html, css, typescript, json and four Vue-specific ones before it
  11. * will highlight a single-file component).
  12. *
  13. * Run from `npm run build`, after `tsc`, because the language table is read
  14. * from the compiled `dist/ui-server/highlight/languages.js` rather than being
  15. * duplicated here — one source of truth for what ships.
  16. *
  17. * Output:
  18. * dist/textmate/manifest.json grammar id -> files to load, deps first
  19. * dist/textmate/<name>.json one TextMate grammar, verbatim
  20. */
  21. import { createRequire } from 'node:module';
  22. import * as fs from 'node:fs';
  23. import * as path from 'node:path';
  24. import { fileURLToPath } from 'node:url';
  25. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  26. const OUT = path.join(ROOT, 'dist', 'textmate');
  27. const require = createRequire(import.meta.url);
  28. function fail(message) {
  29. process.stderr.write(`[prune-grammars] ${message}\n`);
  30. process.exit(1);
  31. }
  32. const languagesModule = path.join(ROOT, 'dist', 'ui-server', 'highlight', 'languages.js');
  33. if (!fs.existsSync(languagesModule)) {
  34. fail(`${path.relative(ROOT, languagesModule)} is missing — run tsc before this script.`);
  35. }
  36. const { REQUIRED_GRAMMARS } = require(languagesModule);
  37. if (!Array.isArray(REQUIRED_GRAMMARS) || REQUIRED_GRAMMARS.length === 0) {
  38. fail('REQUIRED_GRAMMARS is empty — the language table did not compile as expected.');
  39. }
  40. const shikiVersion = JSON.parse(
  41. fs.readFileSync(path.join(ROOT, 'node_modules', '@shikijs', 'langs', 'package.json'), 'utf-8')
  42. ).version;
  43. /**
  44. * Load one Shiki language module and return its registrations.
  45. *
  46. * The default export is already the flattened chain — embedded grammars first,
  47. * the language itself last — which is exactly the order Shiki's registry needs
  48. * to resolve `embeddedLangs`. Keeping that order is the whole reason the
  49. * manifest stores a list rather than a single filename.
  50. */
  51. async function loadChain(id) {
  52. const mod = await import(`@shikijs/langs/${id}`);
  53. const chain = mod.default;
  54. if (!Array.isArray(chain) || chain.length === 0) {
  55. fail(`@shikijs/langs/${id} did not export a grammar array.`);
  56. }
  57. return chain;
  58. }
  59. fs.rmSync(OUT, { recursive: true, force: true });
  60. fs.mkdirSync(OUT, { recursive: true });
  61. const manifest = { shikiVersion, languages: {} };
  62. const written = new Map();
  63. let bytes = 0;
  64. for (const id of REQUIRED_GRAMMARS) {
  65. let chain;
  66. try {
  67. chain = await loadChain(id);
  68. } catch (err) {
  69. fail(`could not load the ${id} grammar: ${err?.message ?? err}`);
  70. }
  71. const files = [];
  72. for (const grammar of chain) {
  73. // A chain can name the same dependency more than once (Vue reaches
  74. // JavaScript four different ways). Registering it twice is wasted work and
  75. // a confusing manifest; the FIRST occurrence is the one that keeps the
  76. // dependencies-before-dependents ordering intact.
  77. // `name` is the grammar's own id and is unique across the bundle, so two
  78. // languages that embed html write (and share) exactly one html.json.
  79. const file = grammar.name;
  80. if (typeof file !== 'string' || !/^[\w.+-]+$/.test(file)) {
  81. fail(`the ${id} chain contains a grammar with an unusable name: ${JSON.stringify(file)}`);
  82. }
  83. if (files.includes(file)) continue;
  84. if (!written.has(file)) {
  85. const json = JSON.stringify(grammar);
  86. fs.writeFileSync(path.join(OUT, `${file}.json`), json);
  87. written.set(file, json.length);
  88. bytes += json.length;
  89. }
  90. files.push(file);
  91. }
  92. manifest.languages[id] = files;
  93. }
  94. fs.writeFileSync(path.join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2));
  95. process.stdout.write(
  96. `[prune-grammars] ${REQUIRED_GRAMMARS.length} languages -> ${written.size} grammars, ` +
  97. `${(bytes / 1024 / 1024).toFixed(1)} MB in dist/textmate (shiki ${shikiVersion})\n`
  98. );