Skip to content
Source
Sign inSign up
Open History to see the last commit

export-brand-icons.ts

792 lines24.2 KB

1#!/usr/bin/env node
2​
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Console from "effect/Console";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import { Command, Flag } from "effect/cli";
14import { ChildProcess, ChildProcessSpawner } from "effect/process";
15​
16import { BRAND_ASSET_PATHS, DEVELOPMENT_PUBLIC_ICON_OVERRIDES } from "./lib/brand-assets.ts";
17import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts";
18​
19const DESIGN_GENERATION = 26;
20const ICON_COMPOSER_EXECUTABLE_PARTS = [
21 "Contents",
22 "Applications",
23 "Icon Composer.app",
24 "Contents",
25 "Executables",
26 "ictool",
27] as const;
28const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [
29 "Icon Composer.app",
30 "Contents",
31 "Executables",
32 "ictool",
33] as const;
34const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
35​
36const IconComposerVersion = Schema.Struct({
37 "bundle-version": Schema.NonEmptyString,
38 "short-bundle-version": Schema.NonEmptyString,
39});
40const decodeIconComposerVersion = Schema.decodeUnknownEffect(
41 Schema.fromJsonString(IconComposerVersion),
42);
43​
44type IconPlatform = "iOS";
45​
46interface VariantOutputs {
47 readonly ios: string;
48 readonly macos: string;
49 readonly universal: string;
50 readonly appleTouch: string;
51 readonly webIcon192: string;
52 readonly webIcon512: string;
53 readonly favicon16: string;
54 readonly favicon32: string;
55 readonly faviconIco: string;
56 readonly windowsIco: string;
58​
59interface IconVariant {
60 readonly label: string;
61 readonly source: string;
62 readonly outputs: VariantOutputs;
64​
65interface IconComposerTool {
66 readonly path: string;
67 readonly version: string;
68 readonly bundleVersion: string;
69 readonly supportsDesignGeneration: boolean;
71​
72interface CommandResult {
73 readonly stdout: string;
74 readonly stderr: string;
75 readonly exitCode: number;
77​
78export class IconExportFileSystemError extends Schema.TaggedError<IconExportFileSystemError>()(
79 "IconExportFileSystemError",
80 {
81 operation: Schema.Literals([
82 "resolve-repository-root",
83 "check-path",
84 "read-directory",
85 "read-file",
86 "make-directory",
87 "make-temp-directory",
88 "make-temp-file",
89 "write-file",
90 "rename-file",
91 ]),
92 path: Schema.String,
93 cause: Schema.Defect(),
94 },
95) {
96 override get message(): string {
97 return `Icon export file-system operation '${this.operation}' failed for ${this.path}.`;
98 }
100​
101export class IconExportProcessError extends Schema.TaggedError<IconExportProcessError>()(
102 "IconExportProcessError",
103 {
104 operation: Schema.Literals(["spawn", "collect-stdout", "collect-stderr", "wait-for-exit"]),
105 command: Schema.String,
106 argumentCount: NonNegativeInt,
107 cause: Schema.Defect(),
108 },
109) {
110 override get message(): string {
111 return `Icon export process operation '${this.operation}' failed for ${this.command}.`;
112 }
114​
115export class IconExportCommandFailedError extends Schema.TaggedError<IconExportCommandFailedError>()(
116 "IconExportCommandFailedError",
117 {
118 command: Schema.String,
119 argumentCount: NonNegativeInt,
120 exitCode: Schema.Int,
121 sourcePath: Schema.String,
122 size: Schema.Int,
123 stdout: Schema.optional(Schema.String),
124 stderr: Schema.optional(Schema.String),
125 },
126) {
127 override get message(): string {
128 return `Icon Composer failed to export ${this.sourcePath} at ${this.size}x${this.size}.`;
129 }
131​
132export class IconExportToolResolutionError extends Schema.TaggedError<IconExportToolResolutionError>()(
133 "IconExportToolResolutionError",
134 {
135 reason: Schema.Literals(["configured-invalid", "configured-outdated", "not-found"]),
136 designGeneration: Schema.Int,
137 toolPath: Schema.optional(Schema.String),
138 version: Schema.optional(Schema.String),
139 },
140) {
141 override get message(): string {
142 switch (this.reason) {
143 case "configured-invalid":
144 return `ICON_COMPOSER_TOOL does not point to Icon Composer's export-capable ictool: ${this.toolPath}`;
145 case "configured-outdated":
146 return `ICON_COMPOSER_TOOL points to Icon Composer ${this.version}, but version 2 or newer is required for design generation ${this.designGeneration}.`;
147 case "not-found":
148 return `Could not find an Icon Composer 2.x exporter compatible with design generation ${this.designGeneration}. Install a compatible Icon Composer/Xcode or set ICON_COMPOSER_TOOL to Icon Composer.app/Contents/Executables/ictool.`;
149 }
150 }
152​
153export class IconExportSourceMissingError extends Schema.TaggedError<IconExportSourceMissingError>()(
154 "IconExportSourceMissingError",
155 {
156 sourcePath: Schema.String,
157 },
158) {
159 override get message(): string {
160 return `Missing Icon Composer source project: ${this.sourcePath}`;
161 }
163​
164export class IconExportRenditionError extends Schema.TaggedError<IconExportRenditionError>()(
165 "IconExportRenditionError",
166 {
167 sourcePath: Schema.String,
168 outputPath: Schema.String,
169 expectedSize: Schema.Int,
170 actualWidth: Schema.optional(Schema.Int),
171 actualHeight: Schema.optional(Schema.Int),
172 cause: Schema.optional(Schema.Defect()),
173 },
174) {
175 override get message(): string {
176 const actual =
177 this.actualWidth === undefined || this.actualHeight === undefined
178 ? "an invalid PNG"
179 : `${this.actualWidth}x${this.actualHeight}`;
180 return `Icon Composer produced ${actual}; expected ${this.expectedSize}x${this.expectedSize} for ${this.sourcePath}.`;
181 }
183​
184export class IconExportEncodingError extends Schema.TaggedError<IconExportEncodingError>()(
185 "IconExportEncodingError",
186 {
187 variant: Schema.String,
188 cause: Schema.Defect(),
189 },
190) {
191 override get message(): string {
192 return `Failed to encode ICO renditions for the ${this.variant} icon.`;
193 }
195​
196export class IconExportAssetsStaleError extends Schema.TaggedError<IconExportAssetsStaleError>()(
197 "IconExportAssetsStaleError",
198 {
199 paths: Schema.Array(Schema.String),
200 },
201) {
202 override get message(): string {
203 return `Generated icon assets are stale:\n${this.paths.map((path) => `- ${path}`).join("\n")}`;
204 }
206​
207const ICON_VARIANTS = [
208 {
209 label: "development",
210 source: BRAND_ASSET_PATHS.developmentIconComposerProject,
211 outputs: {
212 ios: BRAND_ASSET_PATHS.developmentIosIconPng,
213 macos: BRAND_ASSET_PATHS.developmentDesktopIconPng,
214 universal: BRAND_ASSET_PATHS.developmentUniversalIconPng,
215 appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
216 webIcon192: BRAND_ASSET_PATHS.developmentWebIcon192Png,
217 webIcon512: BRAND_ASSET_PATHS.developmentWebIcon512Png,
218 favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png,
219 favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
220 faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco,
221 windowsIco: BRAND_ASSET_PATHS.developmentWindowsIconIco,
222 },
223 },
224 {
225 label: "preview",
226 source: BRAND_ASSET_PATHS.nightlyIconComposerProject,
227 outputs: {
228 ios: BRAND_ASSET_PATHS.nightlyIosIconPng,
229 macos: BRAND_ASSET_PATHS.nightlyMacIconPng,
230 universal: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
231 appleTouch: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng,
232 webIcon192: BRAND_ASSET_PATHS.nightlyWebIcon192Png,
233 webIcon512: BRAND_ASSET_PATHS.nightlyWebIcon512Png,
234 favicon16: BRAND_ASSET_PATHS.nightlyWebFavicon16Png,
235 favicon32: BRAND_ASSET_PATHS.nightlyWebFavicon32Png,
236 faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco,
237 windowsIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
238 },
239 },
240 {
241 label: "production",
242 source: BRAND_ASSET_PATHS.productionIconComposerProject,
243 outputs: {
244 ios: BRAND_ASSET_PATHS.productionIosIconPng,
245 macos: BRAND_ASSET_PATHS.productionMacIconPng,
246 universal: BRAND_ASSET_PATHS.productionLinuxIconPng,
247 appleTouch: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
248 webIcon192: BRAND_ASSET_PATHS.productionWebIcon192Png,
249 webIcon512: BRAND_ASSET_PATHS.productionWebIcon512Png,
250 favicon16: BRAND_ASSET_PATHS.productionWebFavicon16Png,
251 favicon32: BRAND_ASSET_PATHS.productionWebFavicon32Png,
252 faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco,
253 windowsIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
254 },
255 },
256] as const satisfies ReadonlyArray<IconVariant>;
257​
258const RepositoryRoot = Effect.service(Path.Path).pipe(
259 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
260 Effect.mapError(
261 (cause) =>
262 new IconExportFileSystemError({
263 operation: "resolve-repository-root",
264 path: new URL("..", import.meta.url).pathname,
265 cause,
266 }),
267 ),
268);
269​
270const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>) =>
271 stream.pipe(
272 Stream.decodeText(),
273 Stream.runFold(
274 () => "",
275 (acc, chunk) => acc + chunk,
276 ),
277 );
278​
279const runCommand = Effect.fn("iconExport.runCommand")(function* (
280 command: string,
281 args: ReadonlyArray<string>,
282) {
283 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
284 const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe(
285 Effect.mapError(
286 (cause) =>
287 new IconExportProcessError({
288 operation: "spawn",
289 command,
290 argumentCount: args.length,
291 cause,
292 }),
293 ),
294 );
295 const [stdout, stderr, exitCode] = yield* Effect.all(
296 [
297 collectStreamAsString(child.stdout).pipe(
298 Effect.mapError(
299 (cause) =>
300 new IconExportProcessError({
301 operation: "collect-stdout",
302 command,
303 argumentCount: args.length,
304 cause,
305 }),
306 ),
307 ),
308 collectStreamAsString(child.stderr).pipe(
309 Effect.mapError(
310 (cause) =>
311 new IconExportProcessError({
312 operation: "collect-stderr",
313 command,
314 argumentCount: args.length,
315 cause,
316 }),
317 ),
318 ),
319 child.exitCode.pipe(
320 Effect.map(Number),
321 Effect.mapError(
322 (cause) =>
323 new IconExportProcessError({
324 operation: "wait-for-exit",
325 command,
326 argumentCount: args.length,
327 cause,
328 }),
329 ),
330 ),
331 ],
332 { concurrency: "unbounded" },
333 );
334​
335 return { stdout, stderr, exitCode } satisfies CommandResult;
336});
337​
338const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) =>
339 path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1));
340​
341const readSelectedDeveloperDirectory = Effect.fn("iconExport.readSelectedDeveloperDirectory")(
342 function* () {
343 const result = yield* runCommand("xcode-select", ["-p"]).pipe(Effect.option);
344 return Option.flatMap(result, (output) => {
345 const developerDirectory = output.stdout.trim();
346 return output.exitCode === 0 && developerDirectory.length > 0
347 ? Option.some(developerDirectory)
348 : Option.none();
349 });
350 },
351);
352​
353const findXcodeAppCandidates = Effect.fn("iconExport.findXcodeAppCandidates")(function* (
354 directory: string,
355) {
356 const fs = yield* FileSystem.FileSystem;
357 const path = yield* Path.Path;
358 const entries = yield* fs.readDirectory(directory).pipe(
359 Effect.mapError(
360 (cause) =>
361 new IconExportFileSystemError({
362 operation: "read-directory",
363 path: directory,
364 cause,
365 }),
366 ),
367 Effect.orElseSucceed(() => []),
368 );
369 return entries
370 .filter((entry) => /^Xcode.*\.app$/.test(entry))
371 .map((entry) => path.join(directory, entry, ...ICON_COMPOSER_EXECUTABLE_PARTS));
372});
373​
374const probeIconComposerTool = Effect.fn("iconExport.probeIconComposerTool")(function* (
375 candidate: string,
376) {
377 const fs = yield* FileSystem.FileSystem;
378 const exists = yield* fs.exists(candidate).pipe(
379 Effect.mapError(
380 (cause) =>
381 new IconExportFileSystemError({
382 operation: "check-path",
383 path: candidate,
384 cause,
385 }),
386 ),
387 Effect.orElseSucceed(() => false),
388 );
389 if (!exists) return Option.none<IconComposerTool>();
390​
391 const result = yield* runCommand(candidate, ["--version"]).pipe(Effect.option);
392 if (Option.isNone(result) || result.value.exitCode !== 0) {
393 return Option.none<IconComposerTool>();
394 }
395​
396 const version = yield* decodeIconComposerVersion(result.value.stdout).pipe(Effect.option);
397 if (Option.isNone(version)) return Option.none<IconComposerTool>();
398​
399 const bundleVersion = version.value["bundle-version"];
400 const shortVersion = version.value["short-bundle-version"];
401 return Option.some({
402 path: candidate,
403 version: `${shortVersion} (${bundleVersion})`,
404 bundleVersion,
405 supportsDesignGeneration: Number.parseInt(shortVersion, 10) >= 2,
406 });
407});
408​
409const resolveIconComposerTool = Effect.fn("iconExport.resolveIconComposerTool")(function* () {
410 const path = yield* Path.Path;
411 const environment = yield* HostProcess.Environment;
412 const configuredTool = environment.ICON_COMPOSER_TOOL?.trim();
413 if (configuredTool) {
414 const tool = yield* probeIconComposerTool(configuredTool);
415 if (Option.isNone(tool)) {
416 return yield* new IconExportToolResolutionError({
417 reason: "configured-invalid",
418 designGeneration: DESIGN_GENERATION,
419 toolPath: configuredTool,
420 });
421 }
422 if (!tool.value.supportsDesignGeneration) {
423 return yield* new IconExportToolResolutionError({
424 reason: "configured-outdated",
425 designGeneration: DESIGN_GENERATION,
426 toolPath: configuredTool,
427 version: tool.value.version,
428 });
429 }
430 return tool.value;
431 }
432​
433 const selectedDeveloperDirectory = yield* readSelectedDeveloperDirectory();
434 const configuredDeveloperDirectory = environment.DEVELOPER_DIR?.trim();
435 const homeDirectory = environment.HOME?.trim();
436 const searchDirectories = [
437 "/Applications",
438 ...(homeDirectory ? [path.join(homeDirectory, "Downloads")] : []),
439 ];
440 const xcodeCandidates = yield* Effect.forEach(searchDirectories, findXcodeAppCandidates, {
441 concurrency: "unbounded",
442 });
443 const candidates = new Set<string>([
444 ...(configuredDeveloperDirectory
445 ? [iconComposerToolFromDeveloperDirectory(configuredDeveloperDirectory, path)]
446 : []),
447 ...Option.match(selectedDeveloperDirectory, {
448 onNone: () => [],
449 onSome: (developerDirectory) => [
450 iconComposerToolFromDeveloperDirectory(developerDirectory, path),
451 ],
452 }),
453 path.join("/Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS),
454 ...(homeDirectory
455 ? [path.join(homeDirectory, "Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS)]
456 : []),
457 ...xcodeCandidates.flat(),
458 ]);
459 const probed = yield* Effect.forEach([...candidates], probeIconComposerTool, {
460 concurrency: "unbounded",
461 });
462 const compatibleTools = probed
463 .filter(Option.isSome)
464 .map((tool) => tool.value)
465 .filter((tool) => tool.supportsDesignGeneration)
466 .sort((left, right) =>
467 right.bundleVersion.localeCompare(left.bundleVersion, undefined, { numeric: true }),
468 );
469 const newestTool = compatibleTools[0];
470 if (newestTool) return newestTool;
471​
472 return yield* new IconExportToolResolutionError({
473 reason: "not-found",
474 designGeneration: DESIGN_GENERATION,
475 });
476});
477​
478const renderIcon = Effect.fn("iconExport.renderIcon")(function* (
479 toolPath: string,
480 sourcePath: string,
481 outputPath: string,
482 platform: IconPlatform,
483 size: number,
484) {
485 const fs = yield* FileSystem.FileSystem;
486 const args = [
487 sourcePath,
488 "--export-image",
489 "--output-file",
490 outputPath,
491 "--platform",
492 platform,
493 "--rendition",
494 "Default",
495 "--width",
496 String(size),
497 "--height",
498 String(size),
499 "--scale",
500 "1",
501 "--design-generation",
502 String(DESIGN_GENERATION),
503 ];
504 const result = yield* runCommand(toolPath, args);
505 if (result.exitCode !== 0) {
506 return yield* new IconExportCommandFailedError({
507 command: toolPath,
508 argumentCount: args.length,
509 exitCode: result.exitCode,
510 sourcePath,
511 size,
512 ...(result.stdout.trim() ? { stdout: result.stdout.trim() } : {}),
513 ...(result.stderr.trim() ? { stderr: result.stderr.trim() } : {}),
514 });
515 }
516​
517 const contents = yield* fs.readFile(outputPath).pipe(
518 Effect.mapError(
519 (cause) =>
520 new IconExportFileSystemError({
521 operation: "read-file",
522 path: outputPath,
523 cause,
524 }),
525 ),
526 );
527 const buffer = Buffer.from(contents);
528 const dimensions = yield* Effect.try({
529 try: () => readPngDimensions(buffer),
530 catch: (cause) =>
531 new IconExportRenditionError({
532 sourcePath,
533 outputPath,
534 expectedSize: size,
535 cause,
536 }),
537 });
538 if (dimensions.width !== size || dimensions.height !== size) {
539 return yield* new IconExportRenditionError({
540 sourcePath,
541 outputPath,
542 expectedSize: size,
543 actualWidth: dimensions.width,
544 actualHeight: dimensions.height,
545 });
546 }
547 return buffer;
548});
549​
550const renderVariant = Effect.fn("iconExport.renderVariant")(function* (
551 toolPath: string,
552 repositoryRoot: string,
553 temporaryDirectory: string,
554 variant: IconVariant,
555) {
556 const fs = yield* FileSystem.FileSystem;
557 const path = yield* Path.Path;
558 const sourcePath = path.join(repositoryRoot, variant.source);
559 const sourceExists = yield* fs.exists(sourcePath).pipe(
560 Effect.mapError(
561 (cause) =>
562 new IconExportFileSystemError({
563 operation: "check-path",
564 path: sourcePath,
565 cause,
566 }),
567 ),
568 );
569 if (!sourceExists) {
570 return yield* new IconExportSourceMissingError({ sourcePath: variant.source });
571 }
572​
573 const renditionCache = new Map<string, Buffer>();
574 const render = Effect.fn("iconExport.renderVariant.rendition")(function* (
575 platform: IconPlatform,
576 size: number,
577 ) {
578 const cacheKey = `${platform}-${size}`;
579 const cached = renditionCache.get(cacheKey);
580 if (cached) return cached;
581​
582 const outputPath = path.join(temporaryDirectory, `${variant.label}-${platform}-${size}.png`);
583 const contents = yield* renderIcon(toolPath, sourcePath, outputPath, platform, size);
584 renditionCache.set(cacheKey, contents);
585 return contents;
586 });
587​
588 const ios = yield* render("iOS", 1024);
589 const icoRenditions = yield* Effect.forEach(
590 WINDOWS_ICON_SIZES,
591 (size) => render("iOS", size).pipe(Effect.map((contents) => ({ size, contents }))),
592 { concurrency: 1 },
593 );
594 const ico = yield* Effect.try({
595 try: () => encodePngIco(icoRenditions),
596 catch: (cause) => new IconExportEncodingError({ variant: variant.label, cause }),
597 });
598​
599 return new Map<string, Buffer>([
600 [variant.outputs.ios, ios],
601 [variant.outputs.universal, ios],
602 [variant.outputs.appleTouch, yield* render("iOS", 180)],
603 [variant.outputs.webIcon192, yield* render("iOS", 192)],
604 [variant.outputs.webIcon512, yield* render("iOS", 512)],
605 [variant.outputs.favicon16, yield* render("iOS", 16)],
606 [variant.outputs.favicon32, yield* render("iOS", 32)],
607 [variant.outputs.faviconIco, ico],
608 [variant.outputs.windowsIco, ico],
609 ]);
610});
611​
612const writeAtomically = Effect.fn("iconExport.writeAtomically")(function* (
613 repositoryRoot: string,
614 relativePath: string,
615 contents: Buffer,
616) {
617 const fs = yield* FileSystem.FileSystem;
618 const path = yield* Path.Path;
619 const targetPath = path.join(repositoryRoot, relativePath);
620 const targetDirectory = path.dirname(targetPath);
621 yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe(
622 Effect.mapError(
623 (cause) =>
624 new IconExportFileSystemError({
625 operation: "make-directory",
626 path: targetDirectory,
627 cause,
628 }),
629 ),
630 );
631 const temporaryPath = yield* fs
632 .makeTempFileScoped({
633 directory: targetDirectory,
634 prefix: ".t3-icon-export-",
635 suffix: ".tmp",
636 })
637 .pipe(
638 Effect.mapError(
639 (cause) =>
640 new IconExportFileSystemError({
641 operation: "make-temp-file",
642 path: targetDirectory,
643 cause,
644 }),
645 ),
646 );
647 yield* fs.writeFile(temporaryPath, contents).pipe(
648 Effect.mapError(
649 (cause) =>
650 new IconExportFileSystemError({
651 operation: "write-file",
652 path: temporaryPath,
653 cause,
654 }),
655 ),
656 );
657 yield* fs.rename(temporaryPath, targetPath).pipe(
658 Effect.mapError(
659 (cause) =>
660 new IconExportFileSystemError({
661 operation: "rename-file",
662 path: targetPath,
663 cause,
664 }),
665 ),
666 );
667});
668​
669const isCurrent = Effect.fn("iconExport.isCurrent")(function* (
670 repositoryRoot: string,
671 relativePath: string,
672 expected: Buffer,
673) {
674 const fs = yield* FileSystem.FileSystem;
675 const path = yield* Path.Path;
676 const targetPath = path.join(repositoryRoot, relativePath);
677 const exists = yield* fs.exists(targetPath).pipe(
678 Effect.mapError(
679 (cause) =>
680 new IconExportFileSystemError({
681 operation: "check-path",
682 path: targetPath,
683 cause,
684 }),
685 ),
686 );
687 if (!exists) return false;
688​
689 const actual = yield* fs.readFile(targetPath).pipe(
690 Effect.mapError(
691 (cause) =>
692 new IconExportFileSystemError({
693 operation: "read-file",
694 path: targetPath,
695 cause,
696 }),
697 ),
698 );
699 return Buffer.from(actual).equals(expected);
700});
701​
702export const exportBrandIcons = Effect.fn("exportBrandIcons")(function* (checkOnly: boolean) {
703 const fs = yield* FileSystem.FileSystem;
704 const repositoryRoot = yield* RepositoryRoot;
705 const tool = yield* resolveIconComposerTool();
706 const temporaryDirectory = yield* fs
707 .makeTempDirectoryScoped({
708 prefix: "t3-icon-export-",
709 })
710 .pipe(
711 Effect.mapError(
712 (cause) =>
713 new IconExportFileSystemError({
714 operation: "make-temp-directory",
715 path: "system temporary directory",
716 cause,
717 }),
718 ),
719 );
720 yield* Console.log(
721 `Exporting icons with Icon Composer ${tool.version}, design generation ${DESIGN_GENERATION}.`,
722 );
723​
724 const generated = new Map<string, Buffer>();
725 for (const variant of ICON_VARIANTS) {
726 yield* Console.log(`Rendering ${variant.label} from ${variant.source}...`);
727 const variantAssets = yield* renderVariant(
728 tool.path,
729 repositoryRoot,
730 temporaryDirectory,
731 variant,
732 );
733 for (const [relativePath, contents] of variantAssets) {
734 generated.set(relativePath, contents);
735 }
736 }
737​
738 for (const override of DEVELOPMENT_PUBLIC_ICON_OVERRIDES) {
739 const sourceContents = generated.get(override.sourceRelativePath);
740 if (sourceContents === undefined) {
741 return yield* Effect.die(
742 new Error(`Generated development web icon is missing: ${override.sourceRelativePath}`),
743 );
744 }
745 generated.set(override.targetRelativePath, sourceContents);
746 }
747​
748 if (checkOnly) {
749 const stale = yield* Effect.filter(
750 [...generated.entries()],
751 ([relativePath, contents]) =>
752 isCurrent(repositoryRoot, relativePath, contents).pipe(Effect.map((current) => !current)),
753 { concurrency: "unbounded" },
754 );
755 if (stale.length > 0) {
756 return yield* new IconExportAssetsStaleError({
757 paths: stale.map(([relativePath]) => relativePath),
758 });
759 }
760 yield* Console.log(`All ${generated.size} generated icon assets are current.`);
761 return;
762 }
763​
764 yield* Effect.forEach(
765 generated,
766 ([relativePath, contents]) => writeAtomically(repositoryRoot, relativePath, contents),
767 { concurrency: 1, discard: true },
768 );
769 yield* Console.log(`Updated ${generated.size} generated icon assets.`);
770});
771​
772export const exportBrandIconsCommand = Command.make(
773 "export-brand-icons",
774 {
775 check: Flag.Boolean("check").pipe(
776 Flag.withDescription("Verify generated icon assets without modifying files."),
777 Flag.withDefault(false),
778 ),
779 },
780 ({ check }) => exportBrandIcons(check).pipe(Effect.scoped),
781).pipe(
782 Command.withDescription(
783 "Export development, preview, and production assets from Icon Composer projects.",
784 ),
785);
786​
787if (import.meta.main) {
788 Command.run(exportBrandIconsCommand, { version: "0.0.0" }).pipe(
789 Effect.provide(NodeServices.layer),
790 NodeRuntime.runMain,
791 );