yapin.js-client 0.1.4 → 0.1.5

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.
package/LICENSE CHANGED
@@ -19,3 +19,49 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ---
24
+
25
+ Third-party notices
26
+
27
+ The built-in icon set in src/shared/icons.js is taken from Lucide
28
+ (https://lucide.dev). Lucide is ISC licensed, and the icons it inherits from
29
+ Feather are MIT licensed. Both notices follow, and are reproduced as required.
30
+
31
+ ISC License
32
+
33
+ Copyright (c) 2026 Lucide Icons and Contributors.
34
+
35
+ Permission to use, copy, modify, and/or distribute this software for any
36
+ purpose with or without fee is hereby granted, provided that the above
37
+ copyright notice and this permission notice appear in all copies.
38
+
39
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
40
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
41
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
42
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
43
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
44
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
45
+ PERFORMANCE OF THIS SOFTWARE.
46
+
47
+ MIT License
48
+
49
+ Copyright (c) 2013-present Cole Bemis
50
+
51
+ Permission is hereby granted, free of charge, to any person obtaining a copy
52
+ of this software and associated documentation files (the "Software"), to deal
53
+ in the Software without restriction, including without limitation the rights
54
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
55
+ copies of the Software, and to permit persons to whom the Software is
56
+ furnished to do so, subject to the following conditions:
57
+
58
+ The above copyright notice and this permission notice shall be included in all
59
+ copies or substantial portions of the Software.
60
+
61
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
62
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
63
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
64
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
65
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
66
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
67
+ SOFTWARE.
@@ -95,6 +95,17 @@ settings: {
95
95
  | `customCSS.title`, `customCSS.description` | | Its heading and hint. |
96
96
  | `user` | | Like `plugins.user`, with `maxBytes` 2000000. |
97
97
 
98
+ ## `icons`
99
+
100
+ Icons your client app adds to the built-in set, usable by name anywhere an `icon` option is accepted. See [ui.md](ui.md#your-own-icons).
101
+
102
+ | Option | Default | |
103
+ | --- | --- | --- |
104
+ | `dirs` | `['icons']` | Folders with `.svg` icons. Each is registered under its file name, so `icons/brand.svg` is `icon: 'brand'`. Names starting with `_` or `.` are skipped. |
105
+ | `set` | `{}` | Icons given inline as `{ name: '<svg…>' }`. Wins over a folder file of the same name. |
106
+
107
+ A name that matches a built-in replaces it. Markup is inlined into the payload at build time and sanitized when drawn; draw on a 24×24 `viewBox` with `stroke="currentColor"` and no `width`/`height` on the root `<svg>`.
108
+
98
109
  ## `pages`
99
110
 
100
111
  | Option | Default | |
package/docs/ui.md CHANGED
@@ -44,6 +44,7 @@ ui.icon(svgMarkupOrImageURL)
44
44
  `ui.icon()`, and anywhere else an `icon` option is accepted (page icons, About fields, `ui.card`, `ui.button`, toasts, dialogs, the [welcome and what's new screens](welcome.md)), takes:
45
45
 
46
46
  - A built-in name: `'sparkles'`, `'rocket'`, `'puzzle'`, `'palette'`, `'sliders'`, `'info'`, `'check'`, `'x'`, `'alertTriangle'`, `'alertCircle'`, `'chevronRight'`, `'megaphone'`, `'bell'`, `'external'`, `'heart'`, `'copy'`, `'folder'`, `'shield'`.
47
+ - A name your client app registered itself (see below).
47
48
  - SVG markup, sanitized (no scripts, event handlers, `foreignObject` or external links).
48
49
  - An https or `data:image/…` URL.
49
50
  - An element.
@@ -56,6 +57,35 @@ client.pages.add({ id: 'tools', title: 'Tools', icon: 'sliders', render })
56
57
 
57
58
  Every icon is a single colour that follows `currentColor`, so it matches whatever text colour it sits in, in any theme.
58
59
 
60
+ The built-ins are [Lucide](https://lucide.dev), so Lucide's own names work too: `'triangle-alert'` is the same icon as `'alertTriangle'`, and names match in either kebab-case or camelCase. Lucide draws at a 2 px stroke; to re-weight the whole set, set `stroke-width` on `.yc-icon svg` in your `themes.css` rather than editing the markup.
61
+
62
+ ### Your own icons
63
+
64
+ The built-in set is only a default: a client app can add to it, or replace any of it. The simplest way is a folder — every `.svg` in [`icons.dirs`](configuration.md#icons) is registered under its file name, so `icons/brand.svg` becomes `icon: 'brand'`. Give an icon the same name as a built-in and yours replaces it, which is how you swap the whole look without touching the framework:
65
+
66
+ ```
67
+ icons/
68
+ brand.svg → icon: 'brand'
69
+ rocket.svg → replaces the built-in 'rocket'
70
+ ```
71
+
72
+ Files starting with `_` or `.` are skipped. The markup is inlined into the payload at build time, so there's no loading to wait for, and it's sanitized when it's drawn.
73
+
74
+ Icons can also be given inline in the config, which wins over a folder file of the same name:
75
+
76
+ ```js
77
+ icons: { set: { brand: '<svg viewBox="0 0 24 24">…</svg>' } }
78
+ ```
79
+
80
+ A plugin can register its own at runtime:
81
+
82
+ ```js
83
+ client.ui.registerIcons({ sparkle: '<svg viewBox="0 0 24 24">…</svg>' });
84
+ client.ui.iconNames(); // every name available, built-in and registered
85
+ ```
86
+
87
+ Write icons on a 24×24 `viewBox` with `stroke="currentColor"` and no `width`/`height` on the root `<svg>` (CSS sizes them, from 13 px in About chips up to 26 px in the About logo tile), so they theme and scale like the built-ins. Markup that isn't SVG is reported through the client app's error handler rather than drawn.
88
+
59
89
  ## Controls
60
90
 
61
91
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yapin.js-client",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A zero-dependency framework for building Yapin’ client mods: plugins, themes, settings pages, badges, API access and a branded installer.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -54,16 +54,42 @@ export function findThemes(dirs) {
54
54
  return out;
55
55
  }
56
56
 
57
+ /**
58
+ * Icons in a folder: every `.svg` (not starting with _ or .), registered under its file name, so
59
+ * `icons/brand.svg` becomes `icon: 'brand'`. The markup is inlined into the payload; `width` and
60
+ * `height` are dropped because CSS sizes icons, and it is sanitized when drawn.
61
+ */
62
+ export function findIcons(dirs) {
63
+ const out = {};
64
+ for (const dir of dirs) {
65
+ if (!existsSync(dir)) continue;
66
+ for (const name of readdirSync(dir).sort()) {
67
+ if (!/\.svg$/i.test(name) || /^[_.]/.test(name)) continue;
68
+ const markup = readFileSync(join(dir, name), 'utf8').trim();
69
+ if (!markup.includes('<svg')) throw new Error(`Icon ${join(dir, name)} is not an SVG`);
70
+ // Exports are pretty-printed; collapse them so the payload stays small. Only the root <svg> loses
71
+ // width/height: the same attributes on a shape (a <rect>, say) are geometry and must stay.
72
+ out[basename(name, extname(name))] = markup
73
+ .replace(/<svg\b[^>]*>/, (open) => open.replace(/\s+(width|height)="[^"]*"/g, '').replace(/\s+/g, ' ').replace(/ (\/?)>$/, '$1>'))
74
+ .replace(/>\s+</g, '><');
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
57
80
  /** The part of the config the running client app gets (plain data, no build paths). */
58
- export function runtimeConfig(config) {
81
+ export function runtimeConfig(config, { icons } = {}) {
59
82
  const { dirs: _pd, ...plugins } = config.plugins;
60
83
  const { dirs: _td, css: _css, ...themes } = config.themes;
84
+ const { dirs: _id, set, ...iconOptions } = config.icons ?? {};
61
85
  return {
62
86
  origins: config.origins,
63
87
  settings: config.settings,
64
88
  runtime: config.runtime,
65
89
  plugins,
66
90
  themes,
91
+ // Folder icons first, so an inline `icons.set` entry wins on a name clash.
92
+ icons: { ...iconOptions, set: { ...icons, ...set } },
67
93
  pages: config.pages,
68
94
  about: config.about,
69
95
  welcome: config.welcome,
@@ -223,6 +249,7 @@ export async function build(config, { root, mode = 'production', onStep = () =>
223
249
  onStep('Collecting plugins and themes');
224
250
  const pluginFiles = findPlugins(config.plugins.dirs);
225
251
  const themeFiles = findThemes(config.themes.dirs);
252
+ const iconFiles = findIcons(config.icons?.dirs ?? []);
226
253
  const baseCSS = config.themes.css ? readFileSync(config.themes.css, 'utf8') : '';
227
254
 
228
255
  const imports = [`import { boot } from ${JSON.stringify(toPosix(join(SRC, 'runtime', 'boot.js')))};`];
@@ -260,7 +287,7 @@ export default (args) => boot(args, { entry, plugins, themes });
260
287
  build: { time: new Date().toISOString(), mode },
261
288
  git,
262
289
  baseCSS,
263
- config: runtimeConfig(config),
290
+ config: runtimeConfig(config, { icons: iconFiles }),
264
291
  files: {},
265
292
  };
266
293
  if (mode === 'development') {
@@ -52,6 +52,13 @@ export const defaults = Object.freeze({
52
52
  },
53
53
  },
54
54
 
55
+ icons: {
56
+ // Every .svg in these folders is registered under its file name, so `icons/brand.svg` is `icon: 'brand'`.
57
+ dirs: ['icons'],
58
+ // Icons given inline as `{ name: '<svg…>' }`. A name that matches a built-in replaces it.
59
+ set: {},
60
+ },
61
+
55
62
  pages: {
56
63
  section: null,
57
64
  position: 'end',
@@ -8,7 +8,7 @@ import { idPattern } from '../shared/constants.js';
8
8
  export const CONFIG_FILES = ['yapin-client.config.js', 'yapin-client.config.mjs', 'yapin-client.config.json'];
9
9
 
10
10
  /** Sections that reach the running client app, so they must be JSON. */
11
- const RUNTIME_SECTIONS = ['settings', 'runtime', 'plugins', 'themes', 'pages', 'about', 'welcome', 'whatsNew', 'badges', 'api', 'host', 'desktop', 'origins', 'links'];
11
+ const RUNTIME_SECTIONS = ['settings', 'runtime', 'plugins', 'themes', 'icons', 'pages', 'about', 'welcome', 'whatsNew', 'badges', 'api', 'host', 'desktop', 'origins', 'links'];
12
12
 
13
13
  export class ConfigError extends Error {
14
14
  constructor(problems, file) {
@@ -141,7 +141,7 @@ export function validateConfig(input, { root = process.cwd(), file = null } = {}
141
141
  });
142
142
  config.api.allowedOrigins = [...allowed];
143
143
 
144
- for (const kind of ['plugins', 'themes']) {
144
+ for (const kind of ['plugins', 'themes', 'icons']) {
145
145
  config[kind].dirs = (config[kind].dirs ?? []).map((d) => (isAbsolute(d) ? d : resolve(root, d)));
146
146
  }
147
147
  if (config.entry) {
@@ -46,7 +46,7 @@ export class Client {
46
46
  this.host = new Host(config.host ?? {}, { win });
47
47
  this.dom = new DomWatcher({ onError: (error) => this.reportError(error, { where: 'dom' }) });
48
48
  this.styles = new StyleManager(win?.document);
49
- this.ui = new UI({ host: this.host, openExternal: (url) => this.openExternal(url), onError: (error) => this.reportError(error, { where: 'ui' }) });
49
+ this.ui = new UI({ host: this.host, openExternal: (url) => this.openExternal(url), onError: (error) => this.reportError(error, { where: 'ui' }), icons: config.icons?.set });
50
50
  this.react = {
51
51
  fiberOf,
52
52
  propsOf,
package/src/runtime/ui.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SettingTypes } from './settings.js';
2
- import { builtinIcon } from '../shared/icons.js';
2
+ import { builtinIcon, ICONS } from '../shared/icons.js';
3
3
  import { safeURL, toArray } from './util.js';
4
4
 
5
5
  /**
@@ -171,13 +171,45 @@ export const baseCSS = `
171
171
  */
172
172
  export class UI {
173
173
  #toastRoot = null;
174
+ #icons = new Map();
174
175
 
175
- constructor({ host, openExternal, onError } = {}) {
176
+ constructor({ host, openExternal, onError, icons } = {}) {
176
177
  this.host = host;
177
178
  this.openExternal = openExternal ?? ((url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'));
178
179
  this.onError = onError ?? ((error) => console.error(error));
179
180
  this.h = h;
180
181
  this.svg = svg;
182
+ if (icons) this.registerIcons(icons);
183
+ }
184
+
185
+ /**
186
+ * Adds icons to this client app's set, as `{ name: '<svg…>' }`. They can be used by name anywhere an
187
+ * icon is accepted, exactly like the built-ins, and a name that matches a built-in replaces it, so a
188
+ * client app can swap the whole look without touching the framework. Markup is sanitized when drawn.
189
+ *
190
+ * The build registers `icons.dirs` and `icons.set` from the config automatically; plugins can call
191
+ * this to add their own. Names are matched case-insensitively in kebab-case or camelCase.
192
+ */
193
+ registerIcons(icons) {
194
+ for (const [name, markup] of Object.entries(icons ?? {})) {
195
+ if (typeof name !== 'string' || !name) continue;
196
+ if (typeof markup !== 'string' || !markup.includes('<svg')) {
197
+ this.onError(new TypeError(`Icon ${JSON.stringify(name)} must be SVG markup`));
198
+ continue;
199
+ }
200
+ this.#icons.set(name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), markup);
201
+ }
202
+ return this;
203
+ }
204
+
205
+ /** Every icon name this client app can use: the built-ins plus any registered ones. */
206
+ iconNames() {
207
+ return [...new Set([...Object.keys(ICONS), ...this.#icons.keys()])].sort();
208
+ }
209
+
210
+ /** Looks up an icon name: this client app's own icons first, then the built-ins. */
211
+ #lookupIcon(name) {
212
+ return this.#icons.get(name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())) ?? builtinIcon(name);
181
213
  }
182
214
 
183
215
  #cls(...keys) {
@@ -213,11 +245,11 @@ export class UI {
213
245
  return h('div', { class: 'yc-empty' }, text);
214
246
  }
215
247
 
216
- /** An icon: a built-in name ('rocket', 'puzzle', …), SVG markup (sanitized), an https/data image URL, or an element. */
248
+ /** An icon: a name ('rocket', 'puzzle', … or one registered by this client app), SVG markup (sanitized), an https/data image URL, or an element. */
217
249
  icon(source) {
218
250
  if (!source) return null;
219
251
  if (source instanceof Node) return h('span', { class: 'yc-icon' }, source);
220
- if (typeof source === 'string' && !source.includes('<') && !/^(https?:|data:)/.test(source)) source = builtinIcon(source) ?? source;
252
+ if (typeof source === 'string' && !source.includes('<') && !/^(https?:|data:)/.test(source)) source = this.#lookupIcon(source) ?? source;
221
253
  const asSvg = svg(source);
222
254
  if (asSvg) return h('span', { class: 'yc-icon', attrs: { 'aria-hidden': 'true' } }, asSvg);
223
255
  const url = safeURL(source, { protocols: ['https:', 'data:'] });
@@ -1,5 +1,5 @@
1
1
  /** The framework version. Kept in step with package.json (a test checks it). */
2
- export const version = '0.1.4';
2
+ export const version = '0.1.5';
3
3
 
4
4
  /**
5
5
  * The plugin API version. Plugins declare the one they were written for (`apiVersion`), and the
@@ -6,47 +6,75 @@
6
6
  *
7
7
  * Anywhere that takes an icon also still takes raw SVG markup, an https/data image URL, or an element;
8
8
  * names here are just a shortcut so client apps don't have to carry their own SVGs for common things.
9
+ *
10
+ * The icons are Lucide (https://lucide.dev), kept byte-for-byte as upstream draws them apart from the
11
+ * `width`/`height` attributes, which are dropped because CSS sizes them (`.yc-icon`). Lucide draws at
12
+ * a 2px stroke; to re-weight the whole set, set `stroke-width` on `.yc-icon svg` in CSS rather than
13
+ * editing the paths here, so the set stays easy to update from upstream.
14
+ *
15
+ * Lucide is ISC licensed, and the icons it inherits from Feather are MIT licensed. Both notices are
16
+ * reproduced in LICENSE, under "Third-party notices", and must be kept with any copy of this file.
17
+ *
18
+ * ISC: Copyright (c) 2026 Lucide Icons and Contributors.
19
+ * MIT: Copyright (c) 2013-present Cole Bemis (Feather).
20
+ *
21
+ * Generated from the upstream SVGs; see LICENSE for the full notices.
9
22
  */
10
23
  export const ICONS = Object.freeze({
11
24
  sparkles:
12
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M6.3 6.3l1.8 1.8M15.9 15.9l1.8 1.8M17.7 6.3l-1.8 1.8M8.1 15.9l-1.8 1.8"/><circle cx="12" cy="12" r="2.1"/></svg>',
25
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/></svg>',
13
26
  rocket:
14
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 9.5c2-2 3-5 2.6-6.6-1.6-.4-4.6.6-6.6 2.6-1.8 1.8-4.3 5.2-5 7l2.9 3c1.9-.7 5.3-3.2 7-5Z"/><path d="M9 15l-3 3M13.5 6.5l4 4M8 16.5c0 1.5-1 2.5-3.5 3 .5-2.5 1.5-3.5 3-3.5Z"/></svg>',
27
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09"/><path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05"/></svg>',
15
28
  puzzle:
16
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h3a1.5 1.5 0 0 1 0 3 1.5 1.5 0 0 0 0 3h3a1 1 0 0 1 1 1v3a1.5 1.5 0 0 1-3 0 1.5 1.5 0 0 0-3 0v3a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-3a1.5 1.5 0 0 1 0-3 1.5 1.5 0 0 0 0-3V6a1 1 0 0 1 1-1h3V4Z"/></svg>',
29
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>',
17
30
  palette:
18
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 1 0 0 18c1.1 0 2-.9 2-2 0-.5-.2-1-.5-1.3-.3-.4-.5-.8-.5-1.3 0-1.1.9-2 2-2h2a4 4 0 0 0 4-4c0-4.4-4-7.4-9-7.4Z"/><circle cx="7.5" cy="12" r="1.1" fill="currentColor" stroke="none"/><circle cx="9.5" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="14.5" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="16.5" cy="12" r="1.1" fill="currentColor" stroke="none"/></svg>',
19
- sliders:
20
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h7M15 7h5M4 17h11M19 17h1M9 4.5v5M17 14.5v5"/></svg>',
31
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg>',
32
+ sliders: // lucide: sliders-horizontal
33
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 5H3"/><path d="M12 19H3"/><path d="M14 3v4"/><path d="M16 17v4"/><path d="M21 12h-9"/><path d="M21 19h-5"/><path d="M21 5h-7"/><path d="M8 10v4"/><path d="M8 12H3"/></svg>',
21
34
  info:
22
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><circle cx="12" cy="8" r=".2" fill="currentColor"/></svg>',
35
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
23
36
  check:
24
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5 10-11"/></svg>',
25
- x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>',
26
- alertTriangle:
27
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4 3 20h18L12 4Z"/><path d="M12 10v4"/><circle cx="12" cy="17" r=".2" fill="currentColor"/></svg>',
28
- alertCircle:
29
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5"/><circle cx="12" cy="16.5" r=".2" fill="currentColor"/></svg>',
30
- chevronRight:
31
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>',
37
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
38
+ x:
39
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
40
+ alertTriangle: // lucide: triangle-alert
41
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>',
42
+ alertCircle: // lucide: circle-alert
43
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>',
44
+ chevronRight: // lucide: chevron-right
45
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>',
32
46
  megaphone:
33
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11v2a2 2 0 0 0 2 2h1l9 4V5l-9 4H5a2 2 0 0 0-2 2Z"/><path d="M15 9.5a3.5 3.5 0 0 1 0 5"/></svg>',
47
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"/><path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14"/><path d="M8 6v8"/></svg>',
34
48
  bell:
35
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 10a6 6 0 1 1 12 0c0 3 1 4.5 2 5.5H4c1-1 2-2.5 2-5.5Z"/><path d="M10 19.5a2 2 0 0 0 4 0"/></svg>',
36
- external:
37
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-3"/><path d="M13 3h8v8"/><path d="M21 3 10 14"/></svg>',
49
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>',
50
+ external: // lucide: external-link
51
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>',
38
52
  heart:
39
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20s-7.5-4.6-9.5-9.3C1.1 7.7 3 4.5 6.4 4.5c2 0 3.4 1.1 4.1 2.3.7-1.2 2.1-2.3 4.1-2.3 3.4 0 5.3 3.2 3.9 6.2C19.5 15.4 12 20 12 20Z"/></svg>',
53
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/></svg>',
40
54
  copy:
41
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="8.5" width="11.5" height="11.5" rx="2"/><path d="M15.5 8.5V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7.5a2 2 0 0 0 2 2h2.5"/></svg>',
55
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>',
42
56
  folder:
43
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7a2 2 0 0 1 2-2h4l2 2.5h7a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2V7Z"/></svg>',
44
- shield:
45
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6l7-3Z"/><path d="M9 12l2 2 4-4.5"/></svg>',
57
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>',
58
+ shield: // lucide: shield-check
59
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>',
60
+ });
61
+
62
+ /**
63
+ * Lucide's own names for icons whose key here differs, so `icon: 'triangle-alert'` works as well as
64
+ * `icon: 'alertTriangle'`. Keys are camelCase because names are camelised before lookup.
65
+ */
66
+ const ALIASES = Object.freeze({
67
+ slidersHorizontal: 'sliders',
68
+ triangleAlert: 'alertTriangle',
69
+ circleAlert: 'alertCircle',
70
+ externalLink: 'external',
71
+ shieldCheck: 'shield',
72
+ close: 'x',
46
73
  });
47
74
 
48
- /** Looks up a built-in icon by name (case-insensitive). Returns raw SVG markup, or null. */
75
+ /** Looks up a built-in icon by name (case-insensitive, kebab-case or camelCase). Returns raw SVG markup, or null. */
49
76
  export function builtinIcon(name) {
50
77
  if (typeof name !== 'string') return null;
51
- return ICONS[name] ?? ICONS[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] ?? null;
78
+ const camel = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
79
+ return ICONS[name] ?? ICONS[camel] ?? ICONS[ALIASES[camel]] ?? null;
52
80
  }
File without changes
@@ -22,6 +22,12 @@ export default defineConfig({
22
22
  dirs: ['./themes'],
23
23
  },
24
24
 
25
+ // Drop .svg files in ./icons to use them by name (icons/brand.svg -> icon: 'brand').
26
+ // A name matching a built-in replaces it. See docs/ui.md.
27
+ icons: {
28
+ dirs: ['./icons'],
29
+ },
30
+
25
31
  installer: {
26
32
  preset: 'default',
27
33
  marketing: './installer/marketing.json',
package/types/index.d.ts CHANGED
@@ -96,6 +96,13 @@ export interface Config {
96
96
  customCSS?: { enabled?: boolean; title?: string; description?: string | null };
97
97
  user?: UserInstallConfig;
98
98
  };
99
+ /** Icons this client app adds to the built-in set, usable by name anywhere an `icon` is accepted. */
100
+ icons?: {
101
+ /** Folders with `.svg` icons; each is registered under its file name (`icons/brand.svg` -> `'brand'`). */
102
+ dirs?: string[];
103
+ /** Icons given inline as `{ name: '<svg…>' }`. Wins over a folder file of the same name, and a name matching a built-in replaces it. */
104
+ set?: Record<string, string>;
105
+ };
99
106
  pages?: {
100
107
  /** The nav label above the client app's pages (default: its name). */
101
108
  section?: string | null;
@@ -220,6 +227,8 @@ export interface BuildResult {
220
227
  export declare function build(config: Record<string, any>, options: { root: string; mode?: 'production' | 'development'; installer?: boolean; onStep?: (step: string) => void }): Promise<BuildResult>;
221
228
  export declare function findPlugins(dirs: string[]): string[];
222
229
  export declare function findThemes(dirs: string[]): string[];
230
+ /** Every `.svg` in the folders, keyed by file name, with the root `<svg>` size stripped. */
231
+ export declare function findIcons(dirs: string[]): Record<string, string>;
223
232
  export declare function runtimeConfig(config: Record<string, any>): Record<string, any>;
224
233
 
225
234
  export declare function pack(options: {
@@ -84,6 +84,10 @@ export declare class UI {
84
84
  tag(text: string, variant?: 'warn' | 'danger'): HTMLElement;
85
85
  empty(text: string): HTMLElement;
86
86
  icon(source: string | Node): HTMLElement | null;
87
+ /** Adds icons to this client app's set as `{ name: '<svg…>' }`; a name matching a built-in replaces it. */
88
+ registerIcons(icons: Record<string, string>): this;
89
+ /** Every icon name available: the built-ins plus any registered. */
90
+ iconNames(): string[];
87
91
  button(options: { label: string; variant?: 'primary' | 'ghost' | 'danger'; small?: boolean; disabled?: boolean; title?: string; icon?: string; onClick?: (event: MouseEvent) => unknown }): HTMLButtonElement;
88
92
  link(options: { href: string; label?: string; title?: string }): HTMLElement;
89
93
  switch(options: { checked?: boolean; disabled?: boolean; label?: string; onChange?: (checked: boolean, event: Event) => void }): HTMLInputElement;