codex-terminal-themes 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/LICENSE +21 -0
  4. package/LICENSE.txt +21 -0
  5. package/README.md +169 -0
  6. package/SECURITY.md +5 -0
  7. package/SUPPORT.md +5 -0
  8. package/bin/codex-terminal-themes.mjs +12 -0
  9. package/docs/app.js +1393 -0
  10. package/docs/assets/theme-gallery-header.png +0 -0
  11. package/docs/favicon.svg +10 -0
  12. package/docs/index.html +240 -0
  13. package/docs/preview.svg +24 -0
  14. package/docs/site-data.json +51078 -0
  15. package/docs/styles.css +780 -0
  16. package/metadata/README.md +71 -0
  17. package/metadata/themes.json +16185 -0
  18. package/metadata/themes.schema.json +218 -0
  19. package/package.json +109 -0
  20. package/src/cli.mjs +1763 -0
  21. package/stylelint.config.mjs +12 -0
  22. package/themes/80s.tmTheme +229 -0
  23. package/themes/Active4D.tmTheme +407 -0
  24. package/themes/All Hallow's Eve Custom.tmTheme +275 -0
  25. package/themes/All Hallow's Eve.tmTheme +277 -0
  26. package/themes/All Hallows Eve.tmTheme +277 -0
  27. package/themes/Amy.tmTheme +559 -0
  28. package/themes/Artic Fall.tmTheme +275 -0
  29. package/themes/BBEdit.tmTheme +439 -0
  30. package/themes/Bespin.tmTheme +516 -0
  31. package/themes/Black Pearl II.tmTheme +496 -0
  32. package/themes/Black Pearl.tmTheme +400 -0
  33. package/themes/Blackboard 2.tmTheme +348 -0
  34. package/themes/Blackboard Black.tmTheme +350 -0
  35. package/themes/Blackboard.tmTheme +348 -0
  36. package/themes/Blueberry Jelly.tmTheme +242 -0
  37. package/themes/Bluesy.tmTheme +242 -0
  38. package/themes/Blurb 2.tmTheme +229 -0
  39. package/themes/Blurb.tmTheme +229 -0
  40. package/themes/Bongzilla 2.tmTheme +223 -0
  41. package/themes/Bongzilla.tmTheme +223 -0
  42. package/themes/Brightly Dark.tmTheme +242 -0
  43. package/themes/Brilliance Black.tmTheme +2619 -0
  44. package/themes/Brilliance Dull.tmTheme +2243 -0
  45. package/themes/Bromozoid.tmTheme +227 -0
  46. package/themes/CSSEdit.tmTheme +203 -0
  47. package/themes/Classic Modified.tmTheme +469 -0
  48. package/themes/Clouds Midnight.tmTheme +361 -0
  49. package/themes/Clouds of Ruby.tmTheme +649 -0
  50. package/themes/Clouds.tmTheme +348 -0
  51. package/themes/Cloudy Fields.tmTheme +240 -0
  52. package/themes/Coal Graal.tmTheme +282 -0
  53. package/themes/Cobalt.tmTheme +561 -0
  54. package/themes/Coda.tmTheme +317 -0
  55. package/themes/Colorful.tmTheme +307 -0
  56. package/themes/Cool Glow.tmTheme +234 -0
  57. package/themes/Corona.tmTheme +290 -0
  58. package/themes/Cowabunga.tmTheme +242 -0
  59. package/themes/DanBurst.tmTheme +665 -0
  60. package/themes/Daniel Fischer.tmTheme +627 -0
  61. package/themes/Dark Ocean.tmTheme +242 -0
  62. package/themes/DarkNeon.tmTheme +818 -0
  63. package/themes/Dawn.tmTheme +437 -0
  64. package/themes/DawnCustom.tmTheme +443 -0
  65. package/themes/Django (Smoothy).tmTheme +453 -0
  66. package/themes/Django Blues.tmTheme +182 -0
  67. package/themes/Django Extended.tmTheme +495 -0
  68. package/themes/Django.tmTheme +436 -0
  69. package/themes/Dobdark.tmTheme +615 -0
  70. package/themes/Dominion Day.tmTheme +562 -0
  71. package/themes/Doo-Daa.tmTheme +242 -0
  72. package/themes/Drankin Purp.tmTheme +227 -0
  73. package/themes/Dreamweaver_Blackbam_Aptana303.tmTheme +980 -0
  74. package/themes/Easy on my Eyes There Buddy.tmTheme +227 -0
  75. package/themes/Eiffel.tmTheme +435 -0
  76. package/themes/Emacs Strict.tmTheme +241 -0
  77. package/themes/Emacs.tmTheme +241 -0
  78. package/themes/Epic Blue.tmTheme +320 -0
  79. package/themes/Erebus.tmTheme +467 -0
  80. package/themes/Espresso Libre.tmTheme +402 -0
  81. package/themes/Espresso Tutti.tmTheme +392 -0
  82. package/themes/Espresso.tmTheme +329 -0
  83. package/themes/Fade to Grey.tmTheme +308 -0
  84. package/themes/Fluidvision.tmTheme +443 -0
  85. package/themes/ForLaTeX.tmTheme +214 -0
  86. package/themes/Freckle.tmTheme +279 -0
  87. package/themes/Friendship Bracelet.tmTheme +303 -0
  88. package/themes/GaGaGaGroovy.tmTheme +227 -0
  89. package/themes/Gangrene.tmTheme +242 -0
  90. package/themes/GitHub.tmTheme +653 -0
  91. package/themes/GlitterBomb.tmTheme +387 -0
  92. package/themes/Halloween Night.tmTheme +303 -0
  93. package/themes/Happy happy joy joy.tmTheme +841 -0
  94. package/themes/Happydeluxe.tmTheme +184 -0
  95. package/themes/HelvectorLight.tmTheme +557 -0
  96. package/themes/Humane.tmTheme +220 -0
  97. package/themes/IDLE.tmTheme +235 -0
  98. package/themes/IR_Black.tmTheme +810 -0
  99. package/themes/IR_White.tmTheme +792 -0
  100. package/themes/Jane Fonda, Baby Redux.tmTheme +264 -0
  101. package/themes/Johnny.tmTheme +798 -0
  102. package/themes/Juicy.tmTheme +250 -0
  103. package/themes/Kuroir Theme.tmTheme +707 -0
  104. package/themes/LAZY.tmTheme +291 -0
  105. package/themes/Lowlight.tmTheme +605 -0
  106. package/themes/Mac Classic.tmTheme +476 -0
  107. package/themes/Made of Code.tmTheme +695 -0
  108. package/themes/MagicWB (Amiga).tmTheme +376 -0
  109. package/themes/Malibu Nights.tmTheme +257 -0
  110. package/themes/Menage A Trois.tmTheme +881 -0
  111. package/themes/Merbivore Soft.tmTheme +285 -0
  112. package/themes/Merbivore.tmTheme +285 -0
  113. package/themes/Midnight.tmTheme +321 -0
  114. package/themes/Mmm Sandy.tmTheme +227 -0
  115. package/themes/Monokai.tmTheme +289 -0
  116. package/themes/MultiMarkdown.tmTheme +183 -0
  117. package/themes/Mustang.tmTheme +339 -0
  118. package/themes/Neopro Inverted.tmTheme +328 -0
  119. package/themes/Neopro.tmTheme +330 -0
  120. package/themes/Nice One.tmTheme +222 -0
  121. package/themes/No Way.tmTheme +255 -0
  122. package/themes/Overcast.tmTheme +659 -0
  123. package/themes/Pastels on Dark.tmTheme +703 -0
  124. package/themes/Pastie.tmTheme +321 -0
  125. package/themes/Peridinkle.tmTheme +240 -0
  126. package/themes/Play!.tmTheme +736 -0
  127. package/themes/Puss.tmTheme +227 -0
  128. package/themes/Putty.tmTheme +275 -0
  129. package/themes/Quail.tmTheme +257 -0
  130. package/themes/RDark.tmTheme +235 -0
  131. package/themes/Rails Envy.tmTheme +299 -0
  132. package/themes/Railscasts 2.tmTheme +368 -0
  133. package/themes/Railscasts.tmTheme +278 -0
  134. package/themes/Resesif.tmTheme +298 -0
  135. package/themes/Ringo.tmTheme +240 -0
  136. package/themes/Ruby Blue.tmTheme +366 -0
  137. package/themes/RubyRobot.tmTheme +250 -0
  138. package/themes/Ryan Light.tmTheme +232 -0
  139. package/themes/Seafoam.tmTheme +242 -0
  140. package/themes/Sidewalk Chalk.tmTheme +276 -0
  141. package/themes/Sin City (that yellow bastard).tmTheme +585 -0
  142. package/themes/Slate.tmTheme +436 -0
  143. package/themes/Slush & Poppies.tmTheme +336 -0
  144. package/themes/Slush and Poppies.tmTheme +336 -0
  145. package/themes/Smokey Morning.tmTheme +229 -0
  146. package/themes/Smoothy original.tmTheme +623 -0
  147. package/themes/Smoothy.tmTheme +623 -0
  148. package/themes/Solarized (dark).tmTheme +2051 -0
  149. package/themes/Solarized-dark.tmTheme +312 -0
  150. package/themes/Solarized-light.tmTheme +305 -0
  151. package/themes/Sometheme.tmTheme +240 -0
  152. package/themes/SoylentTheme.tmTheme +353 -0
  153. package/themes/SpaceCadet.tmTheme +212 -0
  154. package/themes/Spectacular.tmTheme +436 -0
  155. package/themes/Starlight.tmTheme +857 -0
  156. package/themes/Stoneship Bright.tmTheme +348 -0
  157. package/themes/Stoneship.tmTheme +361 -0
  158. package/themes/Summer Camp Mod.tmTheme +229 -0
  159. package/themes/Summer Camp.tmTheme +229 -0
  160. package/themes/Summery Drink.tmTheme +242 -0
  161. package/themes/Sunburst.tmTheme +665 -0
  162. package/themes/Swyphs II.tmTheme +306 -0
  163. package/themes/Tango Bright.tmTheme +1 -0
  164. package/themes/Tango.tmTheme +450 -0
  165. package/themes/Teenage Dream.tmTheme +242 -0
  166. package/themes/Texari.tmTheme +727 -0
  167. package/themes/Text Ex Machina.tmTheme +295 -0
  168. package/themes/Tomorrow-Night-Blue.tmTheme +175 -0
  169. package/themes/Tomorrow-Night-Eighties.tmTheme +175 -0
  170. package/themes/Tomorrow-Night.tmTheme +175 -0
  171. package/themes/Tomorrow.tmTheme +349 -0
  172. package/themes/ToyChest.tmTheme +503 -0
  173. package/themes/TravisJeffery.tmTheme +1261 -0
  174. package/themes/Tubster.tmTheme +280 -0
  175. package/themes/Twilight BG FG.tmTheme +1000 -0
  176. package/themes/Twilight.tmTheme +516 -0
  177. package/themes/TwilightMod.tmTheme +529 -0
  178. package/themes/Two Days Ago.tmTheme +242 -0
  179. package/themes/Vibrant Fin.tmTheme +447 -0
  180. package/themes/Vibrant Ink.tmTheme +447 -0
  181. package/themes/Vibrant Scala.tmTheme +292 -0
  182. package/themes/Vibrant Tango.tmTheme +438 -0
  183. package/themes/Wandering.tmTheme +681 -0
  184. package/themes/Whimsy in Blue.tmTheme +240 -0
  185. package/themes/Why/342/200/231s Poignant.tmTheme" +191 -0
  186. package/themes/Windows XP.tmTheme +350 -0
  187. package/themes/Witch.tmTheme +282 -0
  188. package/themes/Yurple.tmTheme +227 -0
  189. package/themes/ZZZ.tmTheme +131 -0
  190. package/themes/Zachstronaut.tmTheme +381 -0
  191. package/themes/Zenburnesque.tmTheme +343 -0
  192. package/themes/[ Argonaut ].tmTheme +387 -0
  193. package/themes/barf.tmTheme +254 -0
  194. package/themes/converted-vscode-AmoledShinyBlack.tmTheme +528 -0
  195. package/themes/converted-vscode-AmoledShinyBlack2.tmTheme +609 -0
  196. package/themes/converted-vscode-AmoledShinyBlack3.tmTheme +683 -0
  197. package/themes/converted-vscode-AmoledShinyBlack4.tmTheme +896 -0
  198. package/themes/converted-vscode-AmoledShinyBlack5.tmTheme +1023 -0
  199. package/themes/converted-vscode-AmoledShinyBlack6.tmTheme +2092 -0
  200. package/themes/eclips3.media (ECLM).tmTheme +294 -0
  201. package/themes/evin.tmTheme +253 -0
  202. package/themes/fake.tmTheme +669 -0
  203. package/themes/fapfap.tmTheme +508 -0
  204. package/themes/helloKitty.tmTheme +293 -0
  205. package/themes/iLife 05.tmTheme +619 -0
  206. package/themes/iPlastic.tmTheme +397 -0
  207. package/themes/idleFingers.tmTheme +380 -0
  208. package/themes/krTheme.tmTheme +551 -0
  209. package/themes/mark.james.name.tmTheme +1117 -0
  210. package/themes/minimal Theme.tmTheme +551 -0
  211. package/themes/mint.tmTheme +617 -0
  212. package/themes/mintBlue Dark.tmTheme +653 -0
  213. package/themes/mintBlue.tmTheme +655 -0
  214. package/themes/modifiedPastels.tmTheme +745 -0
  215. package/themes/monoindustrial.tmTheme +451 -0
  216. package/themes/my-theme-blackboard.tmTheme +363 -0
  217. package/themes/my-theme-classic.tmTheme +465 -0
  218. package/themes/nppGLua.tmTheme +293 -0
  219. package/themes/reST testing theme.tmTheme +554 -0
  220. package/themes/rose-pine-dawn.tmTheme +329 -0
  221. package/themes/rose-pine-moon.tmTheme +329 -0
  222. package/themes/rose-pine.tmTheme +329 -0
  223. package/themes/ryan-light.tmTheme +232 -0
  224. package/themes/wut.tmTheme +255 -0
  225. package/tools/build-pages-site.mjs +465 -0
  226. package/tools/generate-theme-metadata.mjs +680 -0
  227. package/tools/serve-pages-site.mjs +196 -0
  228. package/tools/validate-themes.mjs +272 -0
  229. package/types/index.d.ts +49 -0
@@ -0,0 +1,680 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import { SyntaxValidator } from "fast-xml-validator";
3
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+
6
+ const rootDirectory = process.cwd();
7
+ const metadataDirectory = path.join(rootDirectory, "metadata");
8
+ const manifestPath = path.join(metadataDirectory, "themes.json");
9
+ const schemaPath = path.join(metadataDirectory, "themes.schema.json");
10
+ const themeDirectory = path.join(rootDirectory, "themes");
11
+ const shouldCheck = process.argv.includes("--check");
12
+ const shouldWrite = process.argv.includes("--write");
13
+
14
+ const parser = new XMLParser({
15
+ ignoreAttributes: false,
16
+ preserveOrder: true,
17
+ trimValues: false,
18
+ });
19
+
20
+ /**
21
+ * @typedef {{
22
+ * readonly background: string | null;
23
+ * readonly caret: string | null;
24
+ * readonly foreground: string | null;
25
+ * readonly invisibles: string | null;
26
+ * readonly lineHighlight: string | null;
27
+ * readonly selection: string | null;
28
+ * }} ThemeColors
29
+ *
30
+ * @typedef {{
31
+ * readonly colorReferences: number;
32
+ * readonly scopedSettings: number;
33
+ * readonly settings: number;
34
+ * readonly uniqueScopes: number;
35
+ * }} ThemeStatistics
36
+ *
37
+ * @typedef {{
38
+ * readonly appearance: "dark" | "light" | "unknown";
39
+ * readonly author: string | null;
40
+ * readonly colorSpace: string | null;
41
+ * readonly colors: ThemeColors;
42
+ * readonly fileName: string;
43
+ * readonly id: string;
44
+ * readonly name: string;
45
+ * readonly path: string;
46
+ * readonly scopes: readonly string[];
47
+ * readonly semanticClass: string | null;
48
+ * readonly statistics: ThemeStatistics;
49
+ * readonly uuid: string;
50
+ * }} ThemeMetadata
51
+ */
52
+
53
+ /**
54
+ * @param {readonly ThemeMetadata[]} themes
55
+ *
56
+ * @returns {Record<string, readonly string[]>}
57
+ */
58
+ function buildDuplicateUuidGroups(themes) {
59
+ /** @type {Map<string, string[]>} */
60
+ const groupedPaths = new Map();
61
+
62
+ for (const theme of themes) {
63
+ const existingPaths = groupedPaths.get(theme.uuid) ?? [];
64
+ existingPaths.push(theme.path);
65
+ groupedPaths.set(theme.uuid, existingPaths);
66
+ }
67
+
68
+ /** @type {Record<string, readonly string[]>} */
69
+ const duplicateGroups = {};
70
+
71
+ for (const [uuid, paths] of groupedPaths) {
72
+ if (paths.length > 1) {
73
+ duplicateGroups[uuid] = paths.toSorted((left, right) =>
74
+ left.localeCompare(right)
75
+ );
76
+ }
77
+ }
78
+
79
+ return Object.fromEntries(
80
+ Object.entries(duplicateGroups).toSorted(([left], [right]) =>
81
+ left.localeCompare(right)
82
+ )
83
+ );
84
+ }
85
+
86
+ /**
87
+ * @returns {Promise<Record<string, unknown>>}
88
+ */
89
+ async function buildManifest() {
90
+ const filePaths = await getThemeFiles(themeDirectory);
91
+ const themes = await parseThemes(filePaths);
92
+ const duplicateUuidGroups = buildDuplicateUuidGroups(themes);
93
+
94
+ return {
95
+ $schema: "./themes.schema.json",
96
+ consumers: [
97
+ "bat",
98
+ "Codex terminal",
99
+ "TextMate-compatible syntax highlighters",
100
+ ],
101
+ description:
102
+ "Generated metadata for the TextMate themes in this repository.",
103
+ duplicateUuidGroups,
104
+ generatedBy: "npm run metadata:write",
105
+ name: "codex-terminal-themes",
106
+ schemaVersion: 1,
107
+ themeCount: themes.length,
108
+ themeDirectory: "themes",
109
+ themes,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * @param {string} color
115
+ *
116
+ * @returns {number | null}
117
+ */
118
+ function calculateLuminance(color) {
119
+ const parsedColor = parseColor(color);
120
+
121
+ if (parsedColor === null) {
122
+ return null;
123
+ }
124
+
125
+ const [
126
+ red,
127
+ green,
128
+ blue,
129
+ ] = parsedColor.map((channel) => {
130
+ const normalizedChannel = channel / 255;
131
+ return normalizedChannel <= 0.039_28
132
+ ? normalizedChannel / 12.92
133
+ : ((normalizedChannel + 0.055) / 1.055) ** 2.4;
134
+ });
135
+
136
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
137
+ }
138
+
139
+ /**
140
+ * @param {string} fileName
141
+ *
142
+ * @returns {string}
143
+ */
144
+ function createThemeId(fileName) {
145
+ const baseName = fileName.replace(/\.tmTheme$/v, "");
146
+ const id = baseName
147
+ .normalize("NFKD")
148
+ .replaceAll(/[^\dA-Za-z]+/gv, "-")
149
+ .replaceAll(/^-|-$/gv, "")
150
+ .toLowerCase();
151
+
152
+ return id.length > 0 ? id : "theme";
153
+ }
154
+
155
+ /**
156
+ * @param {string | null} background
157
+ *
158
+ * @returns {"dark" | "light" | "unknown"}
159
+ */
160
+ function detectAppearance(background) {
161
+ if (background === null) {
162
+ return "unknown";
163
+ }
164
+
165
+ const luminance = calculateLuminance(background);
166
+
167
+ if (luminance === null) {
168
+ return "unknown";
169
+ }
170
+
171
+ return luminance < 0.5 ? "dark" : "light";
172
+ }
173
+
174
+ /**
175
+ * @param {Record<string, unknown>} record
176
+ * @param {string} key
177
+ *
178
+ * @returns {readonly unknown[] | undefined}
179
+ */
180
+ function getArrayProperty(record, key) {
181
+ const value = record[key];
182
+ return isUnknownArray(value) ? value : undefined;
183
+ }
184
+
185
+ /**
186
+ * @param {string} manifestJson
187
+ *
188
+ * @returns {Promise<number>}
189
+ */
190
+ async function getCheckExitCode(manifestJson) {
191
+ const existingManifest = await readExistingManifest();
192
+
193
+ if (existingManifest === manifestJson) {
194
+ process.stdout.write("Theme metadata manifest is up to date.\n");
195
+ return 0;
196
+ }
197
+
198
+ process.stderr.write(
199
+ "Theme metadata manifest is stale. Run `npm run metadata:write`.\n"
200
+ );
201
+ return 1;
202
+ }
203
+
204
+ /**
205
+ * @param {ReadonlyMap<string, unknown>} settings
206
+ * @param {string} key
207
+ *
208
+ * @returns {string | null}
209
+ */
210
+ function getColorValue(settings, key) {
211
+ const value = settings.get(key);
212
+ const color = getStringValue(value);
213
+ return color ?? null;
214
+ }
215
+
216
+ /**
217
+ * @param {readonly unknown[]} dictChildren
218
+ *
219
+ * @returns {ReadonlyMap<string, unknown>}
220
+ */
221
+ function getDictionaryEntries(dictChildren) {
222
+ /** @type {Map<string, unknown>} */
223
+ const entries = new Map();
224
+ /** @type {string | undefined} */
225
+ let currentKey;
226
+
227
+ for (const child of dictChildren) {
228
+ if (hasOwnRecordKey(child, "key")) {
229
+ currentKey = getTextNodeValue(child.key);
230
+ continue;
231
+ }
232
+
233
+ if (currentKey !== undefined && !hasOwnRecordKey(child, "#text")) {
234
+ entries.set(currentKey, child);
235
+ currentKey = undefined;
236
+ }
237
+ }
238
+
239
+ return entries;
240
+ }
241
+
242
+ /**
243
+ * @param {ReadonlyMap<string, unknown>} entries
244
+ * @param {string} key
245
+ *
246
+ * @returns {ReadonlyMap<string, unknown>}
247
+ */
248
+ function getDictionaryValue(entries, key) {
249
+ const value = entries.get(key);
250
+
251
+ if (!isRecord(value)) {
252
+ return new Map();
253
+ }
254
+
255
+ const dictChildren = getArrayProperty(value, "dict");
256
+ return dictChildren === undefined
257
+ ? new Map()
258
+ : getDictionaryEntries(dictChildren);
259
+ }
260
+
261
+ /**
262
+ * @param {Record<string, unknown>} manifest
263
+ *
264
+ * @returns {string}
265
+ */
266
+ function getManifestJson(manifest) {
267
+ return `${JSON.stringify(manifest, null, 4)}\n`;
268
+ }
269
+
270
+ /**
271
+ * @param {unknown} value
272
+ *
273
+ * @returns {string | undefined}
274
+ */
275
+ function getStringValue(value) {
276
+ if (!isRecord(value)) {
277
+ return undefined;
278
+ }
279
+
280
+ return getTextNodeValue(value.string);
281
+ }
282
+
283
+ /**
284
+ * @param {unknown} value
285
+ *
286
+ * @returns {string | undefined}
287
+ */
288
+ function getTextNodeValue(value) {
289
+ if (!isUnknownArray(value)) {
290
+ return undefined;
291
+ }
292
+
293
+ const firstValue = value[0];
294
+ if (!isRecord(firstValue)) {
295
+ return undefined;
296
+ }
297
+
298
+ const textValue = firstValue["#text"];
299
+ return typeof textValue === "string" ? textValue : undefined;
300
+ }
301
+
302
+ /**
303
+ * @param {readonly unknown[]} settingsArray
304
+ *
305
+ * @returns {ThemeColors}
306
+ */
307
+ function getThemeColors(settingsArray) {
308
+ const firstSettingsItem = settingsArray.find((item) =>
309
+ hasOwnRecordKey(item, "dict")
310
+ );
311
+
312
+ if (!isRecord(firstSettingsItem)) {
313
+ return {
314
+ background: null,
315
+ caret: null,
316
+ foreground: null,
317
+ invisibles: null,
318
+ lineHighlight: null,
319
+ selection: null,
320
+ };
321
+ }
322
+
323
+ const firstSettingsDict = getArrayProperty(firstSettingsItem, "dict");
324
+ const firstSettingsEntries =
325
+ firstSettingsDict === undefined
326
+ ? new Map()
327
+ : getDictionaryEntries(firstSettingsDict);
328
+ const globalSettings = getDictionaryValue(firstSettingsEntries, "settings");
329
+
330
+ return {
331
+ background: getColorValue(globalSettings, "background"),
332
+ caret: getColorValue(globalSettings, "caret"),
333
+ foreground: getColorValue(globalSettings, "foreground"),
334
+ invisibles: getColorValue(globalSettings, "invisibles"),
335
+ lineHighlight: getColorValue(globalSettings, "lineHighlight"),
336
+ selection: getColorValue(globalSettings, "selection"),
337
+ };
338
+ }
339
+
340
+ /**
341
+ * @param {string} directory
342
+ *
343
+ * @returns {Promise<readonly string[]>}
344
+ */
345
+ async function getThemeFiles(directory) {
346
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- The generator intentionally reads the repo-local themes directory.
347
+ const directoryEntries = await readdir(directory, { withFileTypes: true });
348
+
349
+ return directoryEntries
350
+ .filter(
351
+ (directoryEntry) =>
352
+ directoryEntry.isFile() &&
353
+ directoryEntry.name.endsWith(".tmTheme")
354
+ )
355
+ .map((directoryEntry) => directoryEntry.name)
356
+ .toSorted((left, right) => left.localeCompare(right))
357
+ .map((fileName) => path.join(directory, fileName));
358
+ }
359
+
360
+ /**
361
+ * @param {readonly unknown[]} settingsArray
362
+ *
363
+ * @returns {readonly string[]}
364
+ */
365
+ function getThemeScopes(settingsArray) {
366
+ return settingsArray
367
+ .flatMap((item) => {
368
+ if (!hasOwnRecordKey(item, "dict")) {
369
+ return [];
370
+ }
371
+
372
+ const dictChildren = getArrayProperty(item, "dict");
373
+ const entries =
374
+ dictChildren === undefined
375
+ ? new Map()
376
+ : getDictionaryEntries(dictChildren);
377
+ const scope = getStringValue(entries.get("scope"));
378
+
379
+ if (scope === undefined) {
380
+ return [];
381
+ }
382
+
383
+ return scope
384
+ .split(",")
385
+ .map((scopePart) => scopePart.trim())
386
+ .filter((scopePart) => scopePart.length > 0);
387
+ })
388
+ .toSorted((left, right) => left.localeCompare(right));
389
+ }
390
+
391
+ /**
392
+ * @param {ReadonlyMap<string, unknown>} topLevelEntries
393
+ *
394
+ * @returns {readonly unknown[]}
395
+ */
396
+ function getThemeSettings(topLevelEntries) {
397
+ const settingsNode = topLevelEntries.get("settings");
398
+
399
+ if (!isRecord(settingsNode)) {
400
+ return [];
401
+ }
402
+
403
+ return getArrayProperty(settingsNode, "array") ?? [];
404
+ }
405
+
406
+ /**
407
+ * @param {readonly unknown[]} settingsArray
408
+ *
409
+ * @returns {ThemeStatistics}
410
+ */
411
+ function getThemeStatistics(settingsArray) {
412
+ const settingsCount = settingsArray.filter((item) =>
413
+ hasOwnRecordKey(item, "dict")
414
+ ).length;
415
+ const scopes = getThemeScopes(settingsArray);
416
+ const colorReferences = settingsArray
417
+ .filter((item) => hasOwnRecordKey(item, "dict"))
418
+ .flatMap((item) => {
419
+ if (!isRecord(item)) {
420
+ return [];
421
+ }
422
+
423
+ const dictChildren = getArrayProperty(item, "dict");
424
+ const entries =
425
+ dictChildren === undefined
426
+ ? new Map()
427
+ : getDictionaryEntries(dictChildren);
428
+ const settings = getDictionaryValue(entries, "settings");
429
+
430
+ return [...settings.values()].filter((value) => {
431
+ const stringValue = getStringValue(value);
432
+ return stringValue === undefined
433
+ ? false
434
+ : parseColor(stringValue) !== null;
435
+ });
436
+ }).length;
437
+
438
+ return {
439
+ colorReferences,
440
+ scopedSettings: scopes.length,
441
+ settings: settingsCount,
442
+ uniqueScopes: new Set(scopes).size,
443
+ };
444
+ }
445
+
446
+ /**
447
+ * @param {unknown} parsedDocument
448
+ *
449
+ * @returns {ReadonlyMap<string, unknown>}
450
+ */
451
+ function getTopLevelDictionary(parsedDocument) {
452
+ if (!isUnknownArray(parsedDocument)) {
453
+ return new Map();
454
+ }
455
+
456
+ const plistNode = parsedDocument.find((node) =>
457
+ hasOwnRecordKey(node, "plist")
458
+ );
459
+
460
+ if (!isRecord(plistNode)) {
461
+ return new Map();
462
+ }
463
+
464
+ const plistChildren = getArrayProperty(plistNode, "plist");
465
+ if (plistChildren === undefined) {
466
+ return new Map();
467
+ }
468
+
469
+ const dictNode = plistChildren.find((node) =>
470
+ hasOwnRecordKey(node, "dict")
471
+ );
472
+
473
+ if (!isRecord(dictNode)) {
474
+ return new Map();
475
+ }
476
+
477
+ const dictChildren = getArrayProperty(dictNode, "dict");
478
+ return dictChildren === undefined
479
+ ? new Map()
480
+ : getDictionaryEntries(dictChildren);
481
+ }
482
+
483
+ /**
484
+ * @param {unknown} value
485
+ * @param {string} key
486
+ *
487
+ * @returns {value is Record<string, unknown>}
488
+ */
489
+ function hasOwnRecordKey(value, key) {
490
+ return isRecord(value) && Object.hasOwn(value, key);
491
+ }
492
+
493
+ /**
494
+ * @param {unknown} value
495
+ *
496
+ * @returns {value is Record<string, unknown>}
497
+ */
498
+ function isRecord(value) {
499
+ return typeof value === "object" && value !== null && !Array.isArray(value);
500
+ }
501
+
502
+ /**
503
+ * @param {unknown} value
504
+ *
505
+ * @returns {value is readonly unknown[]}
506
+ */
507
+ function isUnknownArray(value) {
508
+ return Array.isArray(value);
509
+ }
510
+
511
+ /**
512
+ * @returns {Promise<number>}
513
+ */
514
+ async function main() {
515
+ if (shouldCheck === shouldWrite) {
516
+ process.stderr.write("Specify exactly one of --check or --write.\n");
517
+ return 1;
518
+ }
519
+
520
+ const manifest = await buildManifest();
521
+ const manifestJson = getManifestJson(manifest);
522
+
523
+ if (shouldCheck) {
524
+ return getCheckExitCode(manifestJson);
525
+ }
526
+
527
+ await writeManifest(manifestJson);
528
+ process.stdout.write("Wrote metadata/themes.json.\n");
529
+ return 0;
530
+ }
531
+
532
+ /**
533
+ * @param {string} color
534
+ *
535
+ * @returns {readonly [number, number, number] | null}
536
+ */
537
+ function parseColor(color) {
538
+ const normalizedColor = color.trim();
539
+ const hexMatch = /^#(?<hex>[\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/iv.exec(
540
+ normalizedColor
541
+ );
542
+
543
+ if (hexMatch?.groups === undefined) {
544
+ return null;
545
+ }
546
+
547
+ const { hex } = hexMatch.groups;
548
+
549
+ if (hex.length === 3) {
550
+ return [
551
+ Number.parseInt(`${hex[0]}${hex[0]}`, 16),
552
+ Number.parseInt(`${hex[1]}${hex[1]}`, 16),
553
+ Number.parseInt(`${hex[2]}${hex[2]}`, 16),
554
+ ];
555
+ }
556
+
557
+ return [
558
+ Number.parseInt(hex.slice(0, 2), 16),
559
+ Number.parseInt(hex.slice(2, 4), 16),
560
+ Number.parseInt(hex.slice(4, 6), 16),
561
+ ];
562
+ }
563
+
564
+ /**
565
+ * @param {string} filePath
566
+ *
567
+ * @returns {Promise<ThemeMetadata>}
568
+ */
569
+ async function parseThemeFile(filePath) {
570
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- Theme paths come from the repo-local themes directory listing.
571
+ const text = await readFile(filePath, "utf8");
572
+ const xmlValidation = SyntaxValidator.validate(text);
573
+
574
+ if (xmlValidation !== true) {
575
+ const { col, line, msg } = xmlValidation.err;
576
+ throw new Error(
577
+ `${filePath}: XML parse error at ${line}:${col}: ${msg}`
578
+ );
579
+ }
580
+
581
+ const parsedDocument = /** @type {unknown} */ (parser.parse(text));
582
+ const topLevelEntries = getTopLevelDictionary(parsedDocument);
583
+ const name = getStringValue(topLevelEntries.get("name"));
584
+ const uuid = getStringValue(topLevelEntries.get("uuid"));
585
+
586
+ if (name === undefined || uuid === undefined) {
587
+ throw new Error(`${filePath}: missing top-level name or uuid`);
588
+ }
589
+
590
+ const settingsArray = getThemeSettings(topLevelEntries);
591
+ const colors = getThemeColors(settingsArray);
592
+ const fileName = path.basename(filePath);
593
+ const relativePath = path
594
+ .relative(rootDirectory, filePath)
595
+ .replaceAll(path.sep, "/");
596
+ const scopes = [...new Set(getThemeScopes(settingsArray))];
597
+
598
+ return {
599
+ appearance: detectAppearance(colors.background),
600
+ author: getStringValue(topLevelEntries.get("author")) ?? null,
601
+ colors,
602
+ colorSpace: getStringValue(topLevelEntries.get("colorSpace")) ?? null,
603
+ fileName,
604
+ id: createThemeId(fileName),
605
+ name,
606
+ path: relativePath,
607
+ scopes,
608
+ semanticClass:
609
+ getStringValue(topLevelEntries.get("semanticClass")) ?? null,
610
+ statistics: getThemeStatistics(settingsArray),
611
+ uuid,
612
+ };
613
+ }
614
+
615
+ /**
616
+ * @param {readonly string[]} filePaths
617
+ *
618
+ * @returns {Promise<readonly ThemeMetadata[]>}
619
+ */
620
+ async function parseThemes(filePaths) {
621
+ const themes = await Promise.all(
622
+ filePaths.map((filePath) => parseThemeFile(filePath))
623
+ );
624
+ /** @type {Map<string, number>} */
625
+ const idCounts = new Map();
626
+
627
+ return themes.map((theme) => {
628
+ const idCount = idCounts.get(theme.id) ?? 0;
629
+ idCounts.set(theme.id, idCount + 1);
630
+
631
+ if (idCount === 0) {
632
+ return theme;
633
+ }
634
+
635
+ return {
636
+ ...theme,
637
+ id: `${theme.id}-${idCount + 1}`,
638
+ };
639
+ });
640
+ }
641
+
642
+ /**
643
+ * @returns {Promise<string>}
644
+ */
645
+ async function readExistingManifest() {
646
+ try {
647
+ return await readFile(manifestPath, "utf8");
648
+ } catch {
649
+ return "";
650
+ }
651
+ }
652
+
653
+ /**
654
+ * @returns {Promise<void>}
655
+ */
656
+ async function run() {
657
+ try {
658
+ process.exitCode = await main();
659
+ } catch (error) {
660
+ process.stderr.write(`${String(error)}\n`);
661
+ process.exitCode = 1;
662
+ }
663
+ }
664
+
665
+ /**
666
+ * @param {string} manifestJson
667
+ *
668
+ * @returns {Promise<void>}
669
+ */
670
+ async function writeManifest(manifestJson) {
671
+ await mkdir(metadataDirectory, { recursive: true });
672
+
673
+ await writeFile(manifestPath, manifestJson, "utf8");
674
+ process.stdout.write(
675
+ `Schema location: ${path.relative(rootDirectory, schemaPath)}\n`
676
+ );
677
+ }
678
+
679
+ // eslint-disable-next-line unicorn/prefer-top-level-await -- This published-module config also enforces n/no-top-level-await.
680
+ void run();