@moewolf/moe-icons-cli 0.0.1 → 0.0.3

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 (151) hide show
  1. package/README.md +249 -10
  2. package/bin/check-node.cjs +36 -0
  3. package/bin/moeicons.js +12 -1
  4. package/dist/api/client.d.ts +13 -2
  5. package/dist/api/client.js +145 -21
  6. package/dist/auth/device-login.d.ts +10 -7
  7. package/dist/auth/device-login.js +60 -37
  8. package/dist/auth/login-schemas.d.ts +24 -0
  9. package/dist/auth/login-schemas.js +67 -0
  10. package/dist/catalog/catalog.d.ts +11 -0
  11. package/dist/catalog/catalog.js +43 -0
  12. package/dist/catalog/catalog.json +12768 -3965
  13. package/dist/cli.d.ts +11 -2
  14. package/dist/cli.js +773 -147
  15. package/dist/commands/parser.d.ts +13 -2
  16. package/dist/commands/parser.js +138 -50
  17. package/dist/config-package/generated/SOURCE.json +25 -0
  18. package/dist/config-package/generated/moeicons-config.schema.json +191 -0
  19. package/dist/config-package/generated/moeicons.config.jsonc +42 -0
  20. package/dist/config-package/generated/render-config.cjs +145 -0
  21. package/dist/config-package/generated/validate-config.cjs +293 -0
  22. package/dist/config-package/generated-config.d.ts +32 -0
  23. package/dist/config-package/generated-config.js +19 -0
  24. package/dist/core/auth.d.ts +24 -0
  25. package/dist/core/auth.js +127 -8
  26. package/dist/core/bitmap-assets.d.ts +19 -5
  27. package/dist/core/bitmap-assets.js +40 -31
  28. package/dist/core/bitmap-shard-budget.d.ts +43 -0
  29. package/dist/core/bitmap-shard-budget.js +16 -0
  30. package/dist/core/bitmap-shard-cache.d.ts +68 -0
  31. package/dist/core/bitmap-shard-cache.js +115 -0
  32. package/dist/core/bitmap-shard-download.d.ts +130 -0
  33. package/dist/core/bitmap-shard-download.js +359 -0
  34. package/dist/core/bitmap-shard-resolver.d.ts +129 -0
  35. package/dist/core/bitmap-shard-resolver.js +262 -0
  36. package/dist/core/bitmap-shards.d.ts +70 -0
  37. package/dist/core/bitmap-shards.js +249 -0
  38. package/dist/core/bootstrap.d.ts +37 -0
  39. package/dist/core/bootstrap.js +54 -0
  40. package/dist/core/cache.d.ts +34 -0
  41. package/dist/core/cache.js +96 -0
  42. package/dist/core/context.d.ts +26 -0
  43. package/dist/core/doctor.d.ts +48 -0
  44. package/dist/core/doctor.js +190 -0
  45. package/dist/core/free-download.d.ts +64 -6
  46. package/dist/core/free-download.js +402 -30
  47. package/dist/core/generate.d.ts +29 -0
  48. package/dist/core/generate.js +341 -65
  49. package/dist/core/icon-selection.d.ts +8 -0
  50. package/dist/core/icon-selection.js +19 -0
  51. package/dist/core/install.d.ts +14 -0
  52. package/dist/core/install.js +132 -13
  53. package/dist/core/library-update.d.ts +5 -1
  54. package/dist/core/library-update.js +176 -17
  55. package/dist/core/local-test-env.d.ts +24 -0
  56. package/dist/core/local-test-env.js +43 -0
  57. package/dist/core/metadata-sync.d.ts +30 -0
  58. package/dist/core/metadata-sync.js +97 -0
  59. package/dist/core/pro-download.d.ts +63 -0
  60. package/dist/core/pro-download.js +247 -27
  61. package/dist/core/pro-install.d.ts +11 -3
  62. package/dist/core/pro-install.js +181 -19
  63. package/dist/core/pro-resources.d.ts +79 -0
  64. package/dist/core/pro-resources.js +204 -0
  65. package/dist/core/pro-selected-download.d.ts +18 -0
  66. package/dist/core/pro-selected-download.js +269 -0
  67. package/dist/core/release-descriptor.d.ts +42 -1
  68. package/dist/core/release-descriptor.js +117 -9
  69. package/dist/core/resource-variant.d.ts +27 -10
  70. package/dist/core/resource-variant.js +75 -34
  71. package/dist/core/selected-resources.d.ts +59 -0
  72. package/dist/core/selected-resources.js +350 -0
  73. package/dist/core/signed-artifact.d.ts +8 -1
  74. package/dist/core/signed-artifact.js +35 -11
  75. package/dist/core/target-subtree.d.ts +8 -0
  76. package/dist/core/target-subtree.js +54 -3
  77. package/dist/core/version-service.d.ts +11 -0
  78. package/dist/core/version-service.js +129 -24
  79. package/dist/core/wizard.d.ts +20 -17
  80. package/dist/core/wizard.js +66 -28
  81. package/dist/errors/errors.d.ts +2 -1
  82. package/dist/errors/errors.js +1 -0
  83. package/dist/generator/generate.d.ts +1 -1
  84. package/dist/generator/generate.js +420 -213
  85. package/dist/generator/shared/SOURCE.json +7 -0
  86. package/dist/generator/shared/svg-model.cjs +156 -0
  87. package/dist/generator/svg-model.d.ts +16 -0
  88. package/dist/generator/svg-model.js +4 -0
  89. package/dist/generator/theme-resolve.d.ts +2 -0
  90. package/dist/generator/theme-resolve.js +57 -26
  91. package/dist/mcp/server.d.ts +23 -36
  92. package/dist/mcp/server.js +74 -152
  93. package/dist/mcp/services.d.ts +4 -0
  94. package/dist/mcp/services.js +230 -0
  95. package/dist/metadata/archive.d.ts +38 -0
  96. package/dist/metadata/archive.js +94 -0
  97. package/dist/metadata/manifest.d.ts +33 -0
  98. package/dist/metadata/manifest.js +136 -0
  99. package/dist/metadata/version.d.ts +22 -0
  100. package/dist/metadata/version.js +53 -0
  101. package/dist/project/anchors/application.d.ts +13 -0
  102. package/dist/project/anchors/application.js +377 -0
  103. package/dist/project/anchors/apply.d.ts +28 -0
  104. package/dist/project/anchors/apply.js +65 -0
  105. package/dist/project/anchors/diagnose.d.ts +26 -0
  106. package/dist/project/anchors/diagnose.js +90 -0
  107. package/dist/project/anchors/helpers.d.ts +20 -0
  108. package/dist/project/anchors/helpers.js +55 -0
  109. package/dist/project/anchors/moeicons-config.d.ts +21 -0
  110. package/dist/project/anchors/moeicons-config.js +83 -0
  111. package/dist/project/anchors/parse.d.ts +14 -0
  112. package/dist/project/anchors/parse.js +36 -0
  113. package/dist/project/anchors/project-manifest.d.ts +19 -0
  114. package/dist/project/anchors/project-manifest.js +109 -0
  115. package/dist/project/anchors/style.d.ts +12 -0
  116. package/dist/project/anchors/style.js +87 -0
  117. package/dist/project/anchors/types.d.ts +32 -0
  118. package/dist/project/anchors/types.js +9 -0
  119. package/dist/project/config.d.ts +59 -5
  120. package/dist/project/config.js +234 -102
  121. package/dist/project/install-metadata.d.ts +23 -2
  122. package/dist/project/install-metadata.js +58 -7
  123. package/dist/project/install.d.ts +24 -4
  124. package/dist/project/install.js +402 -190
  125. package/dist/project/project-lock.d.ts +2 -2
  126. package/dist/project/project-lock.js +4 -4
  127. package/dist/project/tailwind.d.ts +2 -2
  128. package/dist/project/tailwind.js +3 -4
  129. package/dist/project/tar-gz.d.ts +18 -1
  130. package/dist/project/tar-gz.js +40 -3
  131. package/dist/tui/components.d.ts +62 -0
  132. package/dist/tui/components.js +100 -0
  133. package/dist/tui/primitives.d.ts +1 -0
  134. package/dist/tui/primitives.js +8 -1
  135. package/dist/ui/banner.d.ts +31 -3
  136. package/dist/ui/banner.js +120 -25
  137. package/dist/ui/branded-prompts.d.ts +41 -0
  138. package/dist/ui/branded-prompts.js +80 -0
  139. package/dist/ui/clack.d.ts +6 -3
  140. package/dist/ui/clack.js +56 -5
  141. package/dist/ui/create-ui.d.ts +1 -0
  142. package/dist/ui/create-ui.js +5 -1
  143. package/dist/ui/generated/wordmark-large.d.ts +17 -0
  144. package/dist/ui/generated/wordmark-large.js +28 -0
  145. package/dist/ui/generated/wordmark.d.ts +2 -0
  146. package/dist/ui/generated/wordmark.js +2 -0
  147. package/dist/ui/non-interactive.js +8 -0
  148. package/dist/ui/stream.js +30 -0
  149. package/dist/ui/theme.d.ts +42 -0
  150. package/dist/ui/theme.js +59 -0
  151. package/package.json +14 -6
@@ -2,13 +2,44 @@ import { catalog, findCatalogIcon } from "../catalog/catalog.js";
2
2
  import { reportLibraryExportNameCollisions, reportProxyNameCollisions, toLibraryExportName, toProxyName, } from "../core/icon-names.js";
3
3
  import { selectBitmapVariantAssets } from "../core/bitmap-assets.js";
4
4
  import { createHash } from "node:crypto";
5
+ import { posix } from "node:path";
5
6
  import { decodeUtf8 } from "../project/tar-gz.js";
7
+ import { prepareSvg, rootPaintAttributes } from "./svg-model.js";
6
8
  import { CN_HELPER_SOURCE } from "./cn.js";
9
+ import { resolveIconTheme, themeHasIcon } from "../core/icon-selection.js";
7
10
  import { bitmapWrapperImportName, reactBitmapWrapperSource, resolveThemes, svgInternalImportName, vueBitmapWrapperSource, } from "./theme-resolve.js";
8
11
  export { toLibraryExportName, toProxyName };
9
12
  /** @deprecated Use toProxyName; kept so existing generator tests keep compiling. */
10
13
  export const toPascalCase = toProxyName;
11
14
  const OWNER_HEADER = "// Generated by moeicons CLI. Do not edit manually; re-run `moeicons generate`.";
15
+ /**
16
+ * Vanilla project top-level index re-exports each selected style group as a
17
+ * namespace (`export * as MoeOutline from './moe-outline'`), mirroring the
18
+ * code-library layer. Two style groups must therefore never map to the same
19
+ * namespace identifier, and the namespace must not shadow the reserved
20
+ * runtime/types exports.
21
+ */
22
+ function reportVanillaNamespaceCollisions(config) {
23
+ const reserved = new Set(["createMoeiconsRuntime", "Theme", "VanillaIconOptions", "default"]);
24
+ const seen = new Map();
25
+ const problems = [];
26
+ const styleGroups = [...new Set(Object.values(config.themes).map((entry) => entry.styleGroup))];
27
+ for (const styleGroup of styleGroups) {
28
+ const ns = toProxyName(styleGroup);
29
+ if (reserved.has(ns)) {
30
+ problems.push(`vanilla style group "${styleGroup}" maps to reserved export "${ns}"`);
31
+ continue;
32
+ }
33
+ const owner = seen.get(ns);
34
+ if (owner) {
35
+ problems.push(`vanilla style groups "${owner}" and "${styleGroup}" both map to namespace "${ns}"`);
36
+ }
37
+ else {
38
+ seen.set(ns, styleGroup);
39
+ }
40
+ }
41
+ return problems;
42
+ }
12
43
  function themeClassExpr(themes) {
13
44
  const entries = themes
14
45
  .map((theme) => {
@@ -18,7 +49,23 @@ function themeClassExpr(themes) {
18
49
  .join("\n");
19
50
  return `const themeClassName: Partial<Record<Theme, string | undefined>> = {\n${entries}\n};\n`;
20
51
  }
21
- function appendReactFiles(files, rel, config, themes) {
52
+ function themeDefaultsExpr(themes) {
53
+ return `const themeDefaultSize: Record<Theme, number> = {\n${themes.map((theme) => ` ${JSON.stringify(theme.theme)}: ${theme.entry.defaultSize ?? 24},`).join("\n")}\n};\n` +
54
+ `const themeStrokeWidth: Partial<Record<Theme, number>> = {\n${themes.map((theme) => ` ${JSON.stringify(theme.theme)}: ${theme.entry.strokeWidth === undefined ? "undefined" : theme.entry.strokeWidth},`).join("\n")}\n};\n`;
55
+ }
56
+ function installedIconImport(outputDir, target, group, iconId) {
57
+ const file = `${toProxyName(iconId)}${target === "vue" ? ".vue" : ""}.js`;
58
+ const relative = posix.relative(outputDir.replace(/\/$/, ""), `.moeicons/artifact/${target}/${group}/${file}`);
59
+ return relative.startsWith(".") ? relative : `./${relative}`;
60
+ }
61
+ function effectiveThemeForIcon(iconId, logical, themes, config, sourceCatalog) {
62
+ const selected = resolveIconTheme(config, sourceCatalog, logical.theme, iconId);
63
+ const actual = themes.find((theme) => theme.theme === selected);
64
+ if (!actual)
65
+ throw new Error(`icon "${iconId}" has no selected variant for theme "${logical.theme}"`);
66
+ return actual;
67
+ }
68
+ function appendReactFiles(files, rel, config, themes, sourceCatalog) {
22
69
  files.push({
23
70
  path: rel("cn.ts"),
24
71
  content: CN_HELPER_SOURCE,
@@ -26,12 +73,24 @@ function appendReactFiles(files, rel, config, themes) {
26
73
  files.push({
27
74
  path: rel("types.ts"),
28
75
  content: `${OWNER_HEADER}
29
- export interface IconProps {
76
+ import type * as React from "react";
77
+ export interface IconProps extends React.AriaAttributes, React.DOMAttributes<Element> {
30
78
  className?: string;
79
+ style?: React.CSSProperties;
31
80
  size?: number;
81
+ width?: number | string;
82
+ height?: number | string;
32
83
  strokeWidth?: number;
33
- "aria-label"?: string;
34
- [key: string]: unknown;
84
+ color?: string;
85
+ fill?: string;
86
+ stroke?: string;
87
+ focusable?: boolean | "auto" | "true" | "false";
88
+ alt?: string;
89
+ title?: string;
90
+ role?: React.AriaRole;
91
+ draggable?: boolean;
92
+ id?: string;
93
+ tabIndex?: number;
35
94
  }
36
95
  export type Theme =
37
96
  ${Object.keys(config.themes)
@@ -39,97 +98,95 @@ ${Object.keys(config.themes)
39
98
  .join("\n")};
40
99
  `,
41
100
  });
42
- const importLines = [];
101
+ // Each public icon proxy imports only its own theme variants. A generated
102
+ // global registry would eagerly import every configured icon.
43
103
  for (const resolved of themes) {
44
- if (resolved.kind === "svg") {
45
- for (const iconId of config.icons) {
46
- const alias = svgInternalImportName(resolved.theme, resolved.entry.styleGroup, iconId);
47
- importLines.push(`import { ${toLibraryExportName(iconId)} as ${alias} } from "moe-icons/${config.tier}/react/${resolved.entry.styleGroup}";`);
48
- }
49
- }
50
- else if (resolved.variant) {
51
- for (const iconId of config.icons) {
52
- const wrapperName = bitmapWrapperImportName(resolved.theme, iconId);
53
- const wrapperPath = `./wrappers/${wrapperName}`;
54
- importLines.push(`import { ${wrapperName} } from "${wrapperPath}";`);
55
- files.push({
56
- path: rel(`wrappers/${wrapperName}.tsx`),
57
- content: `${OWNER_HEADER}
58
- ${reactBitmapWrapperSource(iconId, wrapperName, resolved.variant, resolved.entry.className)}`,
59
- });
60
- }
104
+ for (const iconId of config.icons) {
105
+ const actual = effectiveThemeForIcon(iconId, resolved, themes, config, sourceCatalog);
106
+ if (actual.kind !== "bitmap" || !actual.variant)
107
+ continue;
108
+ const wrapperName = bitmapWrapperImportName(resolved.theme, iconId);
109
+ files.push({
110
+ path: rel(`wrappers/${wrapperName}.tsx`),
111
+ content: `${OWNER_HEADER}
112
+ ${reactBitmapWrapperSource(iconId, wrapperName, actual.variant, resolved.entry.className)}`,
113
+ });
61
114
  }
62
115
  }
63
- files.push({
64
- path: rel("registry.ts"),
65
- content: `${OWNER_HEADER}
66
- import type { IconProps, Theme } from "./types";
67
- import type * as React from "react";
68
- ${importLines.join("\n")}
69
-
70
- export type IconId =
71
- ${config.icons.map((id) => ` | "${id}"`).join("\n")};
72
-
73
- export interface ThemeRegistry {
74
- ${config.icons.map((id) => ` readonly ${toPascalCase(id)}: React.ComponentType<IconProps>;`).join("\n")}
75
- }
76
-
77
- export const registry: Record<Theme, ThemeRegistry> = {
78
- ${themes
79
- .map((resolved) => {
80
- const entries = config.icons
81
- .map((id) => {
82
- const component = resolved.kind === "bitmap"
83
- ? bitmapWrapperImportName(resolved.theme, id)
84
- : svgInternalImportName(resolved.theme, resolved.entry.styleGroup, id);
85
- return ` ${toPascalCase(id)}: ${component},`;
86
- })
87
- .join("\n");
88
- return ` "${resolved.theme}": {\n${entries}\n },`;
89
- })
90
- .join("\n")}
91
- };
92
- `,
93
- });
94
116
  files.push({
95
117
  path: rel("provider.tsx"),
96
118
  content: `${OWNER_HEADER}
97
- import { createContext, useContext, useState, type PropsWithChildren } from "react";
119
+ import { createContext, useCallback, useContext, useMemo, useState, type PropsWithChildren } from "react";
98
120
  import type { Theme } from "./types";
99
121
 
100
122
  interface MoeiconsThemeState { readonly theme: Theme; readonly setTheme: (theme: Theme) => void; }
101
123
  const MoeiconsThemeContext = createContext<MoeiconsThemeState | undefined>(undefined);
102
- export function MoeiconsProvider(props: PropsWithChildren<{ theme?: Theme }>) {
103
- const [theme, setTheme] = useState<Theme>(props.theme ?? ${JSON.stringify(config.defaultTheme)});
104
- return <MoeiconsThemeContext.Provider value={{ theme, setTheme }}>{props.children}</MoeiconsThemeContext.Provider>;
124
+ const fallbackState: MoeiconsThemeState = { theme: ${JSON.stringify(config.defaultTheme)}, setTheme: () => {} };
125
+ export function MoeiconsProvider(props: PropsWithChildren<{ theme?: Theme; defaultTheme?: Theme; onThemeChange?: (theme: Theme) => void }>) {
126
+ const [localTheme, setLocalTheme] = useState<Theme>(props.defaultTheme ?? ${JSON.stringify(config.defaultTheme)});
127
+ const theme = props.theme ?? localTheme;
128
+ const setTheme = useCallback((next: Theme) => {
129
+ if (props.theme === undefined) setLocalTheme(next);
130
+ props.onThemeChange?.(next);
131
+ }, [props.theme, props.onThemeChange]);
132
+ const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
133
+ return <MoeiconsThemeContext.Provider value={value}>{props.children}</MoeiconsThemeContext.Provider>;
105
134
  }
106
135
  export function useMoeiconsTheme(): MoeiconsThemeState {
107
- const state = useContext(MoeiconsThemeContext);
108
- if (!state) throw new Error("useMoeiconsTheme must be used inside <MoeiconsProvider>");
109
- return state;
136
+ return useContext(MoeiconsThemeContext) ?? fallbackState;
110
137
  }
111
138
  `,
112
139
  });
113
140
  for (const iconId of config.icons) {
114
141
  const pascal = toPascalCase(iconId);
142
+ const ownImports = themes.map((theme) => {
143
+ const actual = effectiveThemeForIcon(iconId, theme, themes, config, sourceCatalog);
144
+ if (actual.kind === "bitmap") {
145
+ const symbol = bitmapWrapperImportName(theme.theme, iconId);
146
+ return `import { ${symbol} } from "../wrappers/${symbol}";`;
147
+ }
148
+ const symbol = svgInternalImportName(theme.theme, actual.entry.styleGroup, iconId);
149
+ return `import ${symbol} from "${installedIconImport(posix.join(config.outputDir, "icons"), "react", actual.entry.styleGroup, iconId)}";`;
150
+ }).join("\n");
151
+ const renderBranches = themes.map((theme) => {
152
+ const actual = effectiveThemeForIcon(iconId, theme, themes, config, sourceCatalog);
153
+ const symbol = actual.kind === "bitmap"
154
+ ? bitmapWrapperImportName(theme.theme, iconId)
155
+ : svgInternalImportName(theme.theme, actual.entry.styleGroup, iconId);
156
+ const refType = actual.kind === "bitmap" ? "HTMLImageElement" : "SVGSVGElement";
157
+ return ` case ${JSON.stringify(theme.theme)}:
158
+ return <${symbol} ref={ref as React.Ref<${refType}>} width={resolvedSize} height={resolvedSize} strokeWidth={strokeWidth ?? themeStrokeWidth[${JSON.stringify(theme.theme)}]} aria-label={rest["aria-label"] ?? alt} {...rest} className={cn("moe-icon", themeClassName[${JSON.stringify(theme.theme)}], className)} />;`;
159
+ }).join("\n");
160
+ const defaultTheme = themes.find((theme) => theme.theme === config.defaultTheme);
161
+ const defaultActual = effectiveThemeForIcon(iconId, defaultTheme, themes, config, sourceCatalog);
162
+ const defaultSymbol = defaultActual.kind === "bitmap"
163
+ ? bitmapWrapperImportName(defaultTheme.theme, iconId)
164
+ : svgInternalImportName(defaultTheme.theme, defaultActual.entry.styleGroup, iconId);
165
+ const defaultRefType = defaultActual.kind === "bitmap" ? "HTMLImageElement" : "SVGSVGElement";
166
+ const singleTheme = themes.length === 1;
115
167
  files.push({
116
168
  path: rel(`icons/${pascal}.tsx`),
117
169
  content: `${OWNER_HEADER}
118
170
  import type { IconProps } from "../types";
119
- import { registry } from "../registry";
120
- import { useMoeiconsTheme } from "../provider";
171
+ ${singleTheme ? "" : 'import { useMoeiconsTheme } from "../provider";'}
121
172
  import { cn } from "../cn";
122
- import type { Theme } from "../types";
123
-
124
- ${themeClassExpr(themes)}
173
+ ${singleTheme ? "" : 'import type { Theme } from "../types";'}
174
+ import * as React from "react";
175
+ ${ownImports}
125
176
 
126
- export function ${pascal}(props: IconProps) {
127
- const { className, size, ...rest } = props;
177
+ ${singleTheme ? "" : themeClassExpr(themes)}
178
+ ${singleTheme ? "" : themeDefaultsExpr(themes)}
179
+ export const ${pascal} = /* @__PURE__ */ React.forwardRef<SVGSVGElement | HTMLImageElement, IconProps>((props, ref) => {
180
+ const { className, size, strokeWidth, alt, ...rest } = props;
181
+ ${singleTheme ? ` return <${defaultSymbol} ref={ref as React.Ref<${defaultRefType}>} width={size ?? ${defaultTheme.entry.defaultSize ?? 24}} height={size ?? ${defaultTheme.entry.defaultSize ?? 24}} strokeWidth={strokeWidth ?? ${defaultTheme.entry.strokeWidth === undefined ? "undefined" : defaultTheme.entry.strokeWidth}} aria-label={rest["aria-label"] ?? alt} {...rest} className={cn("moe-icon", ${JSON.stringify(defaultTheme.entry.className ?? "")}, className)} />;` : `
128
182
  const { theme } = useMoeiconsTheme();
129
- const Component = registry[theme]?.${pascal} ?? registry[${JSON.stringify(config.defaultTheme)}].${pascal};
130
- const sizeClass = typeof size === "number" ? \`w-[\${size}px] h-[\${size}px]\` : undefined;
131
- return <Component {...rest} size={size} className={cn("moe-icon", themeClassName[theme as Theme], sizeClass, className)} />;
132
- }
183
+ const resolvedSize = size ?? themeDefaultSize[theme] ?? 24;
184
+ switch (theme) {
185
+ ${renderBranches}
186
+ default:
187
+ return <${defaultSymbol} ref={ref as React.Ref<${defaultRefType}>} width={resolvedSize} height={resolvedSize} strokeWidth={strokeWidth ?? themeStrokeWidth[${JSON.stringify(config.defaultTheme)}]} aria-label={rest["aria-label"] ?? alt} {...rest} className={cn("moe-icon", themeClassName[${JSON.stringify(config.defaultTheme)}], className)} />;
188
+ }`}
189
+ });
133
190
  `,
134
191
  });
135
192
  }
@@ -143,7 +200,7 @@ ${config.icons.map((id) => `export { ${toPascalCase(id)} } from "./icons/${toPas
143
200
  `,
144
201
  });
145
202
  }
146
- function appendVueFiles(files, rel, config, themes) {
203
+ function appendVueFiles(files, rel, config, themes, sourceCatalog) {
147
204
  files.push({
148
205
  path: rel("cn.ts"),
149
206
  content: CN_HELPER_SOURCE,
@@ -151,12 +208,22 @@ function appendVueFiles(files, rel, config, themes) {
151
208
  files.push({
152
209
  path: rel("types.ts"),
153
210
  content: `${OWNER_HEADER}
154
- export interface IconProps {
155
- class?: string;
211
+ import type { SVGAttributes } from "vue";
212
+ export interface IconProps extends Omit<SVGAttributes, "class" | "focusable" | "draggable"> {
213
+ class?: import("clsx").ClassValue;
156
214
  size?: number;
215
+ width?: number | string;
216
+ height?: number | string;
157
217
  strokeWidth?: number;
218
+ color?: string;
219
+ fill?: string;
220
+ stroke?: string;
221
+ focusable?: boolean | "auto" | "true" | "false";
158
222
  "aria-label"?: string;
159
- [key: string]: unknown;
223
+ alt?: string;
224
+ title?: string;
225
+ "aria-hidden"?: boolean | "true" | "false";
226
+ draggable?: boolean;
160
227
  }
161
228
 
162
229
  export type Theme =
@@ -179,73 +246,39 @@ export interface MoeiconsThemeState {
179
246
  export const MOEICONS_THEME_KEY: InjectionKey<MoeiconsThemeState> = Symbol("moeicons-theme");
180
247
  `,
181
248
  });
182
- const importLines = [];
183
249
  for (const resolved of themes) {
184
- if (resolved.kind === "svg") {
185
- for (const iconId of config.icons) {
186
- const alias = svgInternalImportName(resolved.theme, resolved.entry.styleGroup, iconId);
187
- importLines.push(`import { ${toLibraryExportName(iconId)} as ${alias} } from "moe-icons/${config.tier}/vue/${resolved.entry.styleGroup}";`);
188
- }
189
- }
190
- else if (resolved.variant) {
191
- for (const iconId of config.icons) {
192
- const wrapperName = bitmapWrapperImportName(resolved.theme, iconId);
193
- importLines.push(`import { ${wrapperName} } from "./wrappers/${wrapperName}";`);
194
- files.push({
195
- path: rel(`wrappers/${wrapperName}.ts`),
196
- content: `${OWNER_HEADER}
197
- ${vueBitmapWrapperSource(iconId, wrapperName, resolved.variant, resolved.entry.className)}`,
198
- });
199
- }
250
+ for (const iconId of config.icons) {
251
+ const actual = effectiveThemeForIcon(iconId, resolved, themes, config, sourceCatalog);
252
+ if (actual.kind !== "bitmap" || !actual.variant)
253
+ continue;
254
+ const wrapperName = bitmapWrapperImportName(resolved.theme, iconId);
255
+ files.push({
256
+ path: rel(`wrappers/${wrapperName}.ts`),
257
+ content: `${OWNER_HEADER}
258
+ ${vueBitmapWrapperSource(iconId, wrapperName, actual.variant, resolved.entry.className)}`,
259
+ });
200
260
  }
201
261
  }
202
- files.push({
203
- path: rel("registry.ts"),
204
- content: `${OWNER_HEADER}
205
- import type { Component } from "vue";
206
- import type { Theme } from "./types";
207
- ${importLines.join("\n")}
208
-
209
- export type IconId =
210
- ${config.icons.map((id) => ` | "${id}"`).join("\n")};
211
-
212
- export interface ThemeRegistry {
213
- ${config.icons.map((id) => ` readonly ${toPascalCase(id)}: Component;`).join("\n")}
214
- }
215
-
216
- export const registry: Record<Theme, ThemeRegistry> = {
217
- ${themes
218
- .map((resolved) => {
219
- const entries = config.icons
220
- .map((id) => {
221
- const component = resolved.kind === "bitmap"
222
- ? bitmapWrapperImportName(resolved.theme, id)
223
- : svgInternalImportName(resolved.theme, resolved.entry.styleGroup, id);
224
- return ` ${toPascalCase(id)}: ${component},`;
225
- })
226
- .join("\n");
227
- return ` "${resolved.theme}": {\n${entries}\n },`;
228
- })
229
- .join("\n")}
230
- };
231
- `,
232
- });
233
262
  files.push({
234
263
  path: rel("provider.ts"),
235
264
  content: `${OWNER_HEADER}
236
- import { defineComponent, provide, ref, type PropType } from "vue";
265
+ import { computed, defineComponent, provide, ref, type PropType } from "vue";
237
266
  import type { Theme } from "./types";
238
267
  import { MOEICONS_THEME_KEY, type MoeiconsThemeState } from "./theme";
239
268
 
240
269
  export const MoeiconsProvider = defineComponent({
241
270
  name: "MoeiconsProvider",
242
271
  props: {
243
- theme: { type: String as PropType<Theme>, default: ${JSON.stringify(config.defaultTheme)} as Theme },
272
+ theme: { type: String as PropType<Theme>, default: undefined },
273
+ defaultTheme: { type: String as PropType<Theme>, default: ${JSON.stringify(config.defaultTheme)} as Theme },
244
274
  },
245
- setup(props, { slots }) {
246
- const theme = ref<Theme>(props.theme);
275
+ emits: ["update:theme"],
276
+ setup(props, { slots, emit }) {
277
+ const localTheme = ref<Theme>(props.defaultTheme);
278
+ const theme = computed<Theme>(() => props.theme ?? localTheme.value);
247
279
  const setTheme = (next: Theme) => {
248
- theme.value = next;
280
+ if (props.theme === undefined) localTheme.value = next;
281
+ emit("update:theme", next);
249
282
  };
250
283
  const state: MoeiconsThemeState = { theme, setTheme };
251
284
  provide(MOEICONS_THEME_KEY, state);
@@ -257,46 +290,115 @@ export const MoeiconsProvider = defineComponent({
257
290
  files.push({
258
291
  path: rel("composable.ts"),
259
292
  content: `${OWNER_HEADER}
260
- import { inject } from "vue";
293
+ import { inject, ref } from "vue";
261
294
  import { MOEICONS_THEME_KEY } from "./theme";
262
295
  import type { MoeiconsThemeState } from "./theme";
296
+ import type { Theme } from "./types";
263
297
 
264
298
  export function useMoeiconsTheme(): MoeiconsThemeState {
265
- const state = inject(MOEICONS_THEME_KEY);
266
- if (!state) {
267
- throw new Error("useMoeiconsTheme must be used inside <MoeiconsProvider>");
268
- }
269
- return state;
299
+ const state = inject(MOEICONS_THEME_KEY, undefined);
300
+ return state ?? { theme: ref<Theme>(${JSON.stringify(config.defaultTheme)}), setTheme: () => {} };
270
301
  }
271
302
  `,
272
303
  });
273
304
  for (const iconId of config.icons) {
274
305
  const pascal = toPascalCase(iconId);
306
+ const ownImports = themes.map((theme) => {
307
+ const actual = effectiveThemeForIcon(iconId, theme, themes, config, sourceCatalog);
308
+ if (actual.kind === "bitmap") {
309
+ const symbol = bitmapWrapperImportName(theme.theme, iconId);
310
+ return `import { ${symbol} } from "../wrappers/${symbol}";`;
311
+ }
312
+ const symbol = svgInternalImportName(theme.theme, actual.entry.styleGroup, iconId);
313
+ return `import ${symbol} from "${installedIconImport(posix.join(config.outputDir, "icons"), "vue", actual.entry.styleGroup, iconId)}";`;
314
+ }).join("\n");
315
+ const ownVariants = themes.map((theme) => {
316
+ const actual = effectiveThemeForIcon(iconId, theme, themes, config, sourceCatalog);
317
+ const symbol = actual.kind === "bitmap"
318
+ ? bitmapWrapperImportName(theme.theme, iconId)
319
+ : svgInternalImportName(theme.theme, actual.entry.styleGroup, iconId);
320
+ return ` ${JSON.stringify(theme.theme)}: ${symbol},`;
321
+ }).join("\n");
322
+ const singleTheme = themes.length === 1;
323
+ const onlyTheme = themes[0];
324
+ const onlyActual = effectiveThemeForIcon(iconId, onlyTheme, themes, config, sourceCatalog);
325
+ const onlySymbol = onlyActual.kind === "bitmap"
326
+ ? bitmapWrapperImportName(onlyTheme.theme, iconId)
327
+ : svgInternalImportName(onlyTheme.theme, onlyActual.entry.styleGroup, iconId);
275
328
  files.push({
276
329
  path: rel(`icons/${pascal}.ts`),
277
330
  content: `${OWNER_HEADER}
278
- import { computed, defineComponent, h, inject } from "vue";
279
- import { MOEICONS_THEME_KEY, type MoeiconsThemeState } from "../theme";
280
- import { registry } from "../registry";
331
+ import { ${singleTheme ? "defineComponent, h" : "computed, defineComponent, h, inject, type Component"} } from "vue";
332
+ ${singleTheme ? "" : 'import { MOEICONS_THEME_KEY, type MoeiconsThemeState } from "../theme";'}
281
333
  import { cn } from "../cn";
282
- import type { Theme } from "../types";
334
+ ${singleTheme ? "" : 'import type { Theme } from "../types";'}
335
+ ${ownImports}
283
336
 
284
- ${themeClassExpr(themes)}
337
+ ${singleTheme ? "" : themeClassExpr(themes)}
338
+ ${singleTheme ? "" : themeDefaultsExpr(themes)}
339
+ ${singleTheme ? "" : `
340
+ const variants: Record<Theme, Component> = {
341
+ ${ownVariants}
342
+ };`}
285
343
 
286
- export const ${pascal} = defineComponent({
344
+ export const ${pascal} = /* @__PURE__ */ defineComponent({
287
345
  name: "${pascal}",
288
- setup(_props, { attrs }) {
346
+ inheritAttrs: false,
347
+ props: {
348
+ size: Number,
349
+ width: [Number, String],
350
+ height: [Number, String],
351
+ strokeWidth: Number,
352
+ color: String,
353
+ fill: String,
354
+ stroke: String,
355
+ focusable: { type: [Boolean, String], default: undefined },
356
+ alt: String,
357
+ title: String,
358
+ draggable: { type: Boolean, default: undefined },
359
+ },
360
+ setup(props, { attrs }) {
361
+ ${singleTheme ? "" : `
289
362
  const state = inject<MoeiconsThemeState | undefined>(MOEICONS_THEME_KEY, undefined);
290
- const component = computed(() => registry[(state?.theme.value ?? ${JSON.stringify(config.defaultTheme)}) as Theme]?.${pascal} ?? registry[${JSON.stringify(config.defaultTheme)}].${pascal});
363
+ const component = computed(() => variants[(state?.theme.value ?? ${JSON.stringify(config.defaultTheme)}) as Theme] ?? variants[${JSON.stringify(config.defaultTheme)}]);
364
+ `}
291
365
  return () => {
292
- const className = typeof attrs.class === "string" ? attrs.class : undefined;
293
- const size = typeof attrs.size === "number" ? attrs.size : undefined;
294
- const sizeClass = typeof size === "number" ? \`w-[\${size}px] h-[\${size}px]\` : undefined;
366
+ const className = attrs.class as Parameters<typeof cn>[number];
367
+ const size = props.size;
368
+ ${singleTheme ? `
369
+ const resolvedSize = size ?? ${onlyTheme.entry.defaultSize ?? 24};
370
+ return h(${onlySymbol}, {
371
+ ...attrs,
372
+ ...(attrs["aria-label"] === undefined && props.alt === undefined ? {} : { "aria-label": attrs["aria-label"] ?? props.alt }),
373
+ width: props.width ?? resolvedSize,
374
+ height: props.height ?? resolvedSize,
375
+ strokeWidth: props.strokeWidth ?? ${onlyTheme.entry.strokeWidth === undefined ? "undefined" : onlyTheme.entry.strokeWidth},
376
+ ...(props.color === undefined ? {} : { color: props.color }),
377
+ ...(props.fill === undefined ? {} : { fill: props.fill }),
378
+ ...(props.stroke === undefined ? {} : { stroke: props.stroke }),
379
+ ...(props.focusable === undefined ? {} : { focusable: props.focusable }),
380
+ ...(props.alt === undefined ? {} : { alt: props.alt }),
381
+ ...(props.title === undefined ? {} : { title: props.title }),
382
+ ...(props.draggable === undefined ? {} : { draggable: props.draggable }),
383
+ class: cn("moe-icon", ${JSON.stringify(onlyTheme.entry.className ?? "")}, className),
384
+ });` : `
295
385
  const theme = (state?.theme.value ?? ${JSON.stringify(config.defaultTheme)}) as Theme;
386
+ const resolvedSize = size ?? themeDefaultSize[theme] ?? 24;
296
387
  return h(component.value, {
297
388
  ...attrs,
298
- class: cn("moe-icon", themeClassName[theme], sizeClass, className),
299
- });
389
+ ...(attrs["aria-label"] === undefined && props.alt === undefined ? {} : { "aria-label": attrs["aria-label"] ?? props.alt }),
390
+ width: props.width ?? resolvedSize,
391
+ height: props.height ?? resolvedSize,
392
+ strokeWidth: props.strokeWidth ?? themeStrokeWidth[theme],
393
+ ...(props.color === undefined ? {} : { color: props.color }),
394
+ ...(props.fill === undefined ? {} : { fill: props.fill }),
395
+ ...(props.stroke === undefined ? {} : { stroke: props.stroke }),
396
+ ...(props.focusable === undefined ? {} : { focusable: props.focusable }),
397
+ ...(props.alt === undefined ? {} : { alt: props.alt }),
398
+ ...(props.title === undefined ? {} : { title: props.title }),
399
+ ...(props.draggable === undefined ? {} : { draggable: props.draggable }),
400
+ class: cn("moe-icon", themeClassName[theme], className),
401
+ });`}
300
402
  };
301
403
  },
302
404
  });
@@ -317,55 +419,17 @@ ${config.icons
317
419
  `,
318
420
  });
319
421
  }
320
- function parseSvgNodes(source) {
321
- const root = { children: [] };
322
- const stack = [root];
323
- const svg = source.match(/<svg\b[^>]*>/i);
324
- if (!svg)
325
- throw new Error("SVG root element is missing");
326
- const viewBox = svg[0].match(/\bviewBox\s*=\s*["']([^"']+)["']/i)?.[1] ?? "0 0 24 24";
327
- const inner = source.slice((svg.index ?? 0) + svg[0].length).replace(/<\/svg>\s*$/i, "");
328
- const tokenRe = /<!--[\s\S]*?-->|<[^>]+>|[^<]+/g;
329
- for (const match of inner.matchAll(tokenRe)) {
330
- const token = match[0];
331
- if (token.startsWith("<!--") || !token.trim())
332
- continue;
333
- if (token.startsWith("</")) {
334
- if (stack.length > 1)
335
- stack.pop();
336
- continue;
337
- }
338
- if (!token.startsWith("<")) {
339
- stack[stack.length - 1]?.children.push({ text: token });
340
- continue;
341
- }
342
- const selfClosing = /\/\s*>$/.test(token);
343
- const body = token.slice(1, token.length - (selfClosing ? 2 : 1)).trim();
344
- const name = body.match(/^[A-Za-z][\w:.-]*/)?.[0];
345
- if (!name)
346
- continue;
347
- const attrs = [];
348
- body.slice(name.length).replace(/([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g, (_full, attr, doubleValue, singleValue, bareValue) => {
349
- attrs.push([attr, doubleValue ?? singleValue ?? bareValue ?? ""]);
350
- return "";
351
- });
352
- const node = { name, attrs, children: [] };
353
- stack[stack.length - 1]?.children.push(node);
354
- if (!selfClosing)
355
- stack.push(node);
356
- }
357
- return { viewBox, children: root.children };
358
- }
359
422
  function vanillaFactorySource(name, source, strategy) {
360
- const parsed = parseSvgNodes(source);
361
- const paint = strategy === "solid" ? [["fill", "currentColor"]] : strategy === "outline" ? [["stroke", "currentColor"], ["fill", "none"]] : [];
362
- const graphic = new Set(["path", "circle", "ellipse", "line", "polyline", "polygon", "rect", "text", "use", "image"]);
423
+ const parsed = prepareSvg(source, strategy);
424
+ const paint = rootPaintAttributes(parsed.rootAttrs, strategy);
363
425
  const lines = [
364
426
  "const SVG_NS = 'http://www.w3.org/2000/svg';", "",
365
427
  "export interface VanillaIconOptions extends Record<string, string | number | undefined> { className?: string; strokeWidth?: number; }", "",
366
428
  `export function create${name}(options: VanillaIconOptions = {}): SVGElement {`,
367
429
  " const svg = document.createElementNS(SVG_NS, 'svg');",
368
430
  ` svg.setAttribute('viewBox', ${JSON.stringify(parsed.viewBox)});`,
431
+ " svg.setAttribute('width', '24');",
432
+ " svg.setAttribute('height', '24');",
369
433
  " svg.setAttribute('class', options.className ? `moe-icon ${options.className}` : 'moe-icon');",
370
434
  ];
371
435
  for (const [attr, value] of paint)
@@ -381,15 +445,9 @@ function vanillaFactorySource(name, source, strategy) {
381
445
  }
382
446
  const variable = `node${counter++}`;
383
447
  lines.push(` const ${variable} = document.createElementNS(SVG_NS, ${JSON.stringify(node.name)});`);
384
- for (const [attr, value] of node.attrs)
385
- lines.push(` ${variable}.setAttribute(${JSON.stringify(attr)}, ${JSON.stringify(value)});`);
386
- if (graphic.has(node.name.toLowerCase())) {
387
- if (strategy === "outline" && !node.attrs.some(([attr]) => attr.toLowerCase() === "stroke"))
388
- lines.push(` ${variable}.setAttribute('stroke', 'currentColor');`);
389
- if (strategy === "outline" && !node.attrs.some(([attr]) => attr.toLowerCase() === "fill"))
390
- lines.push(` ${variable}.setAttribute('fill', 'none');`);
391
- if (strategy === "solid" && !node.attrs.some(([attr]) => attr.toLowerCase() === "fill"))
392
- lines.push(` ${variable}.setAttribute('fill', 'currentColor');`);
448
+ for (const [attr, value] of node.attrs) {
449
+ const namespace = attr.startsWith("xlink:") ? "http://www.w3.org/1999/xlink" : attr.startsWith("xml:") ? "http://www.w3.org/XML/1998/namespace" : undefined;
450
+ lines.push(namespace ? ` ${variable}.setAttributeNS(${JSON.stringify(namespace)}, ${JSON.stringify(attr)}, ${JSON.stringify(value)});` : ` ${variable}.setAttribute(${JSON.stringify(attr)}, ${JSON.stringify(value)});`);
393
451
  }
394
452
  emit(node.children, variable);
395
453
  lines.push(` ${parent}.appendChild(${variable});`);
@@ -399,6 +457,119 @@ function vanillaFactorySource(name, source, strategy) {
399
457
  lines.push(" return svg;", "}", "", `export default create${name};`, "");
400
458
  return lines.join("\n");
401
459
  }
460
+ /**
461
+ * CLI project-layer Vanilla runtime (E2E-C4). Underlying code-library factories
462
+ * stay theme-less; this runtime maps logical themes → style groups and updates
463
+ * already-mounted DOM nodes on setTheme.
464
+ */
465
+ function vanillaRuntimeSource(config, themes, sourceCatalog) {
466
+ const defaultTheme = config.defaultTheme;
467
+ const imports = [];
468
+ const factoryEntries = [];
469
+ for (const resolved of themes) {
470
+ const theme = resolved.theme;
471
+ const iconEntries = [];
472
+ for (const iconId of config.icons) {
473
+ const group = effectiveThemeForIcon(iconId, resolved, themes, config, sourceCatalog).entry.styleGroup;
474
+ const pascal = toPascalCase(iconId);
475
+ const alias = `create${pascal}_${theme.replace(/[^a-zA-Z0-9]/g, "_")}`;
476
+ imports.push(`import { create${pascal} as ${alias} } from "./${group}/${pascal}";`);
477
+ iconEntries.push(` ${JSON.stringify(iconId)}: ${alias}`);
478
+ }
479
+ factoryEntries.push(` ${JSON.stringify(theme)}: {\n${iconEntries.join(",\n")}\n }`);
480
+ }
481
+ return `${imports.join("\n")}
482
+ import type { Theme, VanillaIconOptions } from "./types";
483
+
484
+ const defaultTheme = ${JSON.stringify(defaultTheme)} as Theme;
485
+ const themeDefaults: Record<Theme, VanillaIconOptions> = {
486
+ ${themes.map((theme) => ` ${JSON.stringify(theme.theme)}: { width: ${theme.entry.defaultSize ?? 24}, height: ${theme.entry.defaultSize ?? 24}${theme.entry.strokeWidth === undefined ? "" : `, strokeWidth: ${theme.entry.strokeWidth}`}${theme.entry.className === undefined ? "" : `, className: ${JSON.stringify(theme.entry.className)}`} },`).join("\n")}
487
+ };
488
+ const factories: Record<Theme, Record<string, (options?: VanillaIconOptions) => SVGElement>> = {
489
+ ${factoryEntries.join(",\n")}
490
+ };
491
+
492
+ function resolveTheme(theme: string | undefined): Theme {
493
+ if (theme && theme in factories) return theme as Theme;
494
+ return defaultTheme;
495
+ }
496
+
497
+ function iconOptions(theme: Theme, options: VanillaIconOptions): VanillaIconOptions {
498
+ return { ...themeDefaults[theme], ...options };
499
+ }
500
+
501
+ export interface MoeiconsRuntime {
502
+ readonly getTheme: () => Theme;
503
+ readonly setTheme: (theme: Theme | string) => void;
504
+ readonly mount: (host: Element, iconId: string, options?: VanillaIconOptions) => MoeiconsMount;
505
+ readonly mountIcon: (host: Element, iconId: string, options?: VanillaIconOptions) => SVGElement;
506
+ readonly destroy: () => void;
507
+ }
508
+
509
+ export interface MoeiconsMount {
510
+ readonly node: SVGElement;
511
+ readonly update: (options: VanillaIconOptions) => void;
512
+ readonly on: (type: string, listener: EventListener) => () => void;
513
+ readonly unmount: () => void;
514
+ }
515
+
516
+ /** Project-layer runtime: theme ↔ styleGroup mapping with live DOM updates. */
517
+ export function createMoeiconsRuntime(options: { theme?: Theme | string } = {}): MoeiconsRuntime {
518
+ let theme = resolveTheme(options.theme);
519
+ const mounts = new Set<{ host: Element; iconId: string; options: VanillaIconOptions; node: SVGElement; listeners: Map<string, Set<EventListener>> }>();
520
+ const render = (record: { iconId: string; options: VanillaIconOptions; node: SVGElement; listeners: Map<string, Set<EventListener>> }) => {
521
+ const factory = factories[theme]?.[record.iconId] ?? factories[defaultTheme]?.[record.iconId];
522
+ if (!factory) throw new Error(\`unknown icon "\${record.iconId}"\`);
523
+ const nextNode = factory(iconOptions(theme, record.options));
524
+ for (const [type, listeners] of record.listeners) for (const listener of listeners) nextNode.addEventListener(type, listener);
525
+ record.node.replaceWith(nextNode);
526
+ record.node = nextNode;
527
+ };
528
+ const mount = (host: Element, iconId: string, iconOptions: VanillaIconOptions = {}): MoeiconsMount => {
529
+ const factory = factories[theme]?.[iconId] ?? factories[defaultTheme]?.[iconId];
530
+ if (!factory) throw new Error(\`unknown icon "\${iconId}"\`);
531
+ const record = { host, iconId, options: iconOptions, node: factory({ ...themeDefaults[theme], ...iconOptions }), listeners: new Map<string, Set<EventListener>>() };
532
+ host.appendChild(record.node);
533
+ mounts.add(record);
534
+ return {
535
+ get node() { return record.node; },
536
+ update(next) { if (!mounts.has(record)) return; record.options = next; render(record); },
537
+ on(type, listener) {
538
+ if (!mounts.has(record)) return () => {};
539
+ let listeners = record.listeners.get(type);
540
+ if (!listeners) { listeners = new Set(); record.listeners.set(type, listeners); }
541
+ listeners.add(listener);
542
+ record.node.addEventListener(type, listener);
543
+ return () => { record.listeners.get(type)?.delete(listener); record.node.removeEventListener(type, listener); };
544
+ },
545
+ unmount() { if (!mounts.has(record)) return; record.node.remove(); record.listeners.clear(); mounts.delete(record); },
546
+ };
547
+ };
548
+
549
+ const api: MoeiconsRuntime = {
550
+ getTheme: () => theme,
551
+ setTheme(next) {
552
+ const resolved = resolveTheme(next);
553
+ if (resolved === theme) return;
554
+ theme = resolved;
555
+ for (const record of mounts) {
556
+ if (!record.node.isConnected) { mounts.delete(record); continue; }
557
+ render(record);
558
+ }
559
+ },
560
+ mount,
561
+ mountIcon(host, iconId, iconOptions = {}) {
562
+ return mount(host, iconId, iconOptions).node;
563
+ },
564
+ destroy() {
565
+ for (const mount of mounts) mount.node.remove();
566
+ mounts.clear();
567
+ },
568
+ };
569
+ return api;
570
+ }
571
+ `;
572
+ }
402
573
  function archiveManifest(archiveFiles) {
403
574
  const entry = Object.entries(archiveFiles).find(([path]) => /(?:^|\/)assets\/manifest\.json$/.test(path));
404
575
  if (!entry)
@@ -417,12 +588,15 @@ function archiveManifest(archiveFiles) {
417
588
  function sha256Hex(bytes) {
418
589
  return createHash("sha256").update(bytes).digest("hex");
419
590
  }
420
- function selectRawAssets(archiveFiles, themes, icons) {
591
+ function selectRawAssets(archiveFiles, themes, config, sourceCatalog) {
421
592
  const manifest = archiveManifest(archiveFiles);
422
593
  if (!manifest)
423
594
  return { ok: false, errors: ["installed artifact assets manifest is missing or invalid"] };
424
595
  const base = manifest.path.slice(0, manifest.path.lastIndexOf("/"));
425
- const wanted = new Set(themes.filter((theme) => theme.kind === "svg").flatMap((theme) => icons.map((icon) => `${theme.entry.styleGroup}/${icon}.svg`)));
596
+ const wanted = new Set(themes.flatMap((theme) => config.icons.flatMap((icon) => {
597
+ const actual = effectiveThemeForIcon(icon, theme, themes, config, sourceCatalog);
598
+ return actual.kind === "svg" ? [`${actual.entry.styleGroup}/${icon}.svg`] : [];
599
+ })));
426
600
  const assets = [];
427
601
  const errors = [];
428
602
  for (const path of wanted) {
@@ -459,6 +633,9 @@ export function planGeneratedFiles(config, outputDir, options = {}) {
459
633
  }
460
634
  errors.push(...reportProxyNameCollisions(config.icons));
461
635
  errors.push(...reportLibraryExportNameCollisions(config.icons));
636
+ if (config.target === "vanilla") {
637
+ errors.push(...reportVanillaNamespaceCollisions(config));
638
+ }
462
639
  for (const iconId of config.icons) {
463
640
  const catalogIcon = findCatalogIcon(iconId, options.catalog ?? catalog);
464
641
  if (!catalogIcon) {
@@ -466,9 +643,8 @@ export function planGeneratedFiles(config, outputDir, options = {}) {
466
643
  continue;
467
644
  }
468
645
  for (const [theme, entry] of Object.entries(config.themes)) {
469
- if (!catalogIcon.availableIn.includes(entry.styleGroup)) {
470
- errors.push(`icon "${iconId}" is not available in style group "${entry.styleGroup}" for theme "${theme}"`);
471
- }
646
+ if (!resolveIconTheme(config, options.catalog ?? catalog, theme, iconId))
647
+ errors.push(`icon "${iconId}" is not available in style group "${entry.styleGroup}" for theme "${theme}"; no selected variant satisfies missingIconPolicy`);
472
648
  }
473
649
  }
474
650
  const resolved = resolveThemes(config, options.catalog ?? catalog);
@@ -489,38 +665,58 @@ export function planGeneratedFiles(config, outputDir, options = {}) {
489
665
  if (options.archiveFiles === undefined) {
490
666
  return { ok: false, errors: ["vanilla target requires an installed artifact"] };
491
667
  }
492
- const raw = selectRawAssets(options.archiveFiles, resolved.themes, config.icons);
668
+ const raw = selectRawAssets(options.archiveFiles, resolved.themes, config, options.catalog ?? catalog);
493
669
  if (!raw.ok)
494
670
  return raw;
495
671
  const rawByPath = new Map(raw.assets.map((file) => [file.path.replace(/^assets\//, ""), file.content]));
672
+ const archivePaths = new Set(Object.keys(options.archiveFiles));
496
673
  const groups = new Set(resolved.themes.map((theme) => theme.entry.styleGroup));
497
- files.push({ path: rel("types.ts"), content: "export interface VanillaIconOptions extends Record<string, string | number | undefined> { className?: string; strokeWidth?: number; }\n" });
674
+ files.push({ path: rel("types.ts"), content: "export interface VanillaIconOptions extends Record<string, string | number | undefined> { className?: string; strokeWidth?: number; }\nexport type Theme = " + Object.keys(config.themes).map((t) => JSON.stringify(t)).join(" | ") + ";\n" });
498
675
  for (const styleGroup of groups) {
499
676
  const exports = ["// Vanilla factory exports"];
500
- for (const iconId of config.icons) {
677
+ for (const iconId of config.icons.filter((id) => resolved.themes.some((theme) => theme.entry.styleGroup === styleGroup && themeHasIcon(config, options.catalog ?? catalog, theme.theme, id)))) {
501
678
  const name = toPascalCase(iconId);
502
679
  const source = rawByPath.get(`${styleGroup}/${iconId}.svg`);
503
680
  if (!source || typeof source === "string")
504
681
  return { ok: false, errors: [`raw asset missing: ${styleGroup}/${iconId}.svg`] };
505
- const strategy = styleGroup.endsWith("-outline") ? "outline" : styleGroup.endsWith("-solid") ? "solid" : "mixed";
506
- files.push({ path: rel(`${styleGroup}/${name}.ts`), content: vanillaFactorySource(name, decodeUtf8(source), strategy) });
682
+ const kind = resolved.themes.find((theme) => theme.entry.styleGroup === styleGroup)?.group.type;
683
+ const strategy = kind === "outline" || kind === "solid" ? kind : "mixed";
684
+ const artifactPath = `.moeicons/artifact/vanilla/${styleGroup}/${name}.js`;
685
+ const archiveHasFactory = archivePaths.has(`vanilla/${styleGroup}/${name}.js`) ||
686
+ archivePaths.has(`${config.tier}/vanilla/${styleGroup}/${name}.js`);
687
+ const imported = posix.relative(posix.join(outputDir, styleGroup), artifactPath);
688
+ const specifier = imported.startsWith(".") ? imported : `./${imported}`;
689
+ files.push({ path: rel(`${styleGroup}/${name}.ts`), content: archiveHasFactory
690
+ ? `${OWNER_HEADER}\nexport { create${name}, default } from ${JSON.stringify(specifier)};\n`
691
+ : vanillaFactorySource(name, decodeUtf8(source), strategy) });
507
692
  exports.push(`export { default as ${toLibraryExportName(iconId)}, create${name} } from './${name}';`);
508
693
  }
509
694
  files.push({ path: rel(`${styleGroup}/index.ts`), content: `${exports.join("\n")}\n` });
510
695
  }
511
- files.push({ path: rel("index.ts"), content: `${[...groups].map((group) => `export * from './${group}';`).join("\n")}\n` });
696
+ files.push({
697
+ path: rel("runtime.ts"),
698
+ content: vanillaRuntimeSource(config, resolved.themes, options.catalog ?? catalog),
699
+ });
700
+ const groupNamespaces = [...groups].map((group) => {
701
+ const ns = toProxyName(group);
702
+ return `export * as ${ns} from './${group}';`;
703
+ });
704
+ files.push({
705
+ path: rel("index.ts"),
706
+ content: `${groupNamespaces.join("\n")}\nexport { createMoeiconsRuntime } from './runtime';\nexport type { Theme, VanillaIconOptions } from './types';\n`,
707
+ });
512
708
  }
513
709
  else if (target === "vue") {
514
- appendVueFiles(files, rel, config, resolved.themes);
710
+ appendVueFiles(files, rel, config, resolved.themes, options.catalog ?? catalog);
515
711
  }
516
712
  else {
517
- appendReactFiles(files, rel, config, resolved.themes);
713
+ appendReactFiles(files, rel, config, resolved.themes, options.catalog ?? catalog);
518
714
  }
519
715
  if (target === "assets") {
520
716
  if (options.archiveFiles === undefined) {
521
717
  return { ok: false, errors: ["assets target requires an installed artifact"] };
522
718
  }
523
- const raw = selectRawAssets(options.archiveFiles, resolved.themes, config.icons);
719
+ const raw = selectRawAssets(options.archiveFiles, resolved.themes, config, options.catalog ?? catalog);
524
720
  if (!raw.ok)
525
721
  return raw;
526
722
  for (const asset of raw.assets)
@@ -540,7 +736,18 @@ export function planGeneratedFiles(config, outputDir, options = {}) {
540
736
  ],
541
737
  };
542
738
  }
543
- const selected = selectBitmapVariantAssets(options.archiveFiles, unique, config.icons);
739
+ // Exact catalog variants are the v2 evidence. Frozen v1 catalogs did not
740
+ // carry this field and therefore use only the explicit legacy parser. A
741
+ // mixed archive/catalog state is ambiguous and must not be guessed.
742
+ const mediaModes = new Set(bitmapThemes.map((theme) => theme.group.variants && theme.group.variants.length > 0 ? 2 : 1));
743
+ if (mediaModes.size !== 1) {
744
+ return { ok: false, errors: ["bitmap themes mix v1 and v2 catalog contracts"] };
745
+ }
746
+ const mediaContractVersion = [...mediaModes][0];
747
+ const selected = selectBitmapVariantAssets(options.archiveFiles, unique, config.icons, { mediaContractVersion, requestedPairs: resolved.themes.flatMap((theme) => config.icons.flatMap((iconId) => {
748
+ const actual = effectiveThemeForIcon(iconId, theme, resolved.themes, config, options.catalog ?? catalog);
749
+ return actual.variant ? [{ variantId: actual.variant.resourceVariantId, iconId }] : [];
750
+ })) });
544
751
  if (!selected.ok)
545
752
  return { ok: false, errors: [...selected.errors] };
546
753
  for (const asset of selected.assets) {