tosijs-ui 1.6.1 → 1.6.2

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.
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env bun
2
+ /*#
3
+ # make-icon-data
4
+
5
+ <!--{ "pin": "bottom" }-->
6
+
7
+ Ingests SVG files from icon directories and generates an icon-data module
8
+ (TypeScript or JavaScript). This is the build tool behind the `icons` system.
9
+
10
+ ## Usage
11
+
12
+ bun run bin/make-icon-data.js [options]
13
+
14
+ ### Options
15
+
16
+ - `--input <dir1,dir2,...>` — directories to scan (default: `./icons`)
17
+ - `--output <path>` — output file (default: `./src/icon-data.ts`)
18
+ - `--optimize <true|false>` — round coordinates based on viewBox size (default: true)
19
+
20
+ ### Examples
21
+
22
+ bun run bin/make-icon-data.js
23
+ bun run bin/make-icon-data.js --input ./my-icons --output ./dist/icons.js
24
+ bun run bin/make-icon-data.js --optimize false
25
+
26
+ ## Directory conventions
27
+
28
+ ### Style classes
29
+
30
+ Folder names control CSS classes on the generated SVGs:
31
+
32
+ - `color/` — icons with baked-in colors (class `color`, fill/stroke preserved)
33
+ - `stroked/` — stroke-only icons (class `stroked`, fill styles removed)
34
+ - `filled/` — fill-only icons (class `filled`, stroke styles removed)
35
+
36
+ ### Directional folders
37
+
38
+ Place icons in specially named folders to auto-generate directional
39
+ redirects, eliminating redundant SVG files:
40
+
41
+ - `right-left/` — base icon points right; generates a flipped left variant
42
+ - `up-down/` — base icon points up; generates a flipped down variant
43
+ - `up-down-right-left/` — base icon points right; generates down (90°),
44
+ left (180°), and up (270°) rotation variants
45
+
46
+ For example, `chevron-right.svg` in an `up-down-right-left/` folder produces
47
+ `chevronRight` (SVG) plus `chevronDown`, `chevronLeft`, `chevronUp` (redirects).
48
+
49
+ ### Comma-separated names
50
+
51
+ For icons where the directional variant has a different name (not just a
52
+ direction swap), use commas in the filename:
53
+
54
+ skip-forward,skip-back.svg → skipForward (SVG) + skipBack (redirect)
55
+ refresh-cw,refresh-ccw.svg → refreshCw (SVG) + refreshCcw (redirect)
56
+
57
+ The first name is the base, additional names are mapped to the folder's
58
+ variant suffixes in order.
59
+
60
+ ## SVG processing
61
+
62
+ The tool automatically:
63
+ - Strips XML declarations, namespaces, IDs, and comments
64
+ - Removes redundant attributes (default fills, strokes)
65
+ - Collapses whitespace
66
+ - Rounds coordinates based on viewBox size (larger viewBox = fewer decimals)
67
+ - Converts filenames to camelCase (`arrow-right.svg` → `arrowRight`)
68
+ */
69
+
70
+ /*{ "parent": "Appendices" }*/
71
+
72
+ import fs from 'fs'
73
+ import path from 'path'
74
+
75
+ // Default values for options
76
+ const defaultOptions = {
77
+ input: './icons',
78
+ output: './src/icon-data.ts',
79
+ optimize: true, // Optimize coordinate precision based on viewBox size
80
+ }
81
+ const flags = Object.keys(defaultOptions)
82
+
83
+ // Parse command-line arguments
84
+ const args = process.argv.slice(2)
85
+ const parsedArgs = {}
86
+ for (let i = 0; i < args.length; i++) {
87
+ const arg = args[i]
88
+ if (arg.startsWith('--')) {
89
+ const key = arg.substring(2)
90
+ const value = args[i + 1]
91
+ if (value && !value.startsWith('--')) {
92
+ parsedArgs[key] = value
93
+ i++ // Skip the value in the next iteration
94
+ } else {
95
+ parsedArgs[key] = true // Handle boolean flags (though not used for these options currently)
96
+ }
97
+ }
98
+ }
99
+
100
+ // Combine default options with parsed arguments
101
+ const options = { ...defaultOptions }
102
+ for (const flag of flags) {
103
+ if (parsedArgs[flag] !== undefined) {
104
+ options[flag] = parsedArgs[flag]
105
+ }
106
+ }
107
+
108
+ // Destructure options for easier use
109
+ const { input: iconDirectories, output: outputFilePath, optimize } = options
110
+ const isTypescript = outputFilePath.endsWith('.ts')
111
+ const shouldOptimize = optimize === true || optimize === 'true'
112
+
113
+ // Calculate appropriate decimal precision based on viewBox size
114
+ // Larger viewBox = fewer decimal places needed
115
+ function getPrecisionForViewBox(viewBoxSize) {
116
+ if (viewBoxSize >= 512) return 0
117
+ if (viewBoxSize >= 64) return 1
118
+ return 2
119
+ }
120
+
121
+ // Round numbers in SVG content based on viewBox-appropriate precision
122
+ function optimizeCoordinates(svgSource, precision) {
123
+ return svgSource.replace(/\d+\.\d+/g, (number) => {
124
+ const rounded = Number(number).toFixed(precision)
125
+ // Remove trailing zeros and unnecessary decimal point
126
+ return parseFloat(rounded).toString()
127
+ })
128
+ }
129
+
130
+ // Extract viewBox size from SVG
131
+ function getViewBoxSize(svgSource) {
132
+ const match = svgSource.match(/viewBox="([^"]+)"/)
133
+ if (match) {
134
+ const parts = match[1].split(/\s+/)
135
+ if (parts.length >= 4) {
136
+ const width = parseFloat(parts[2])
137
+ const height = parseFloat(parts[3])
138
+ return Math.max(width, height)
139
+ }
140
+ }
141
+ return 24 // Default assumption
142
+ }
143
+
144
+ const typeDeclaration = isTypescript
145
+ ? 'export interface IconData { [key: string]: string }'
146
+ : '' // No type declaration for JS output
147
+
148
+ const iconData = {}
149
+ const iconRedirects = {}
150
+
151
+ // Directional folder conventions:
152
+ // right-left: base has "Right", generate "Left" as flip
153
+ // up-down: base has "Up", generate "Down" as flip
154
+ // up-down-right-left: base has "Right", generate Down/Left/Up via rotation
155
+ const DIRECTION_FOLDERS = {
156
+ 'right-left': {
157
+ base: 'Right',
158
+ variants: { Left: '0f' },
159
+ },
160
+ 'up-down': {
161
+ base: 'Up',
162
+ variants: { Down: '1f' },
163
+ },
164
+ 'up-down-right-left': {
165
+ base: 'Right',
166
+ variants: {
167
+ Down: '90r',
168
+ Left: '180r',
169
+ Up: '270r',
170
+ },
171
+ },
172
+ }
173
+
174
+ function getDirectionConfig(dir) {
175
+ const dirName = path.basename(dir)
176
+ return DIRECTION_FOLDERS[dirName] || null
177
+ }
178
+
179
+ function generateDirectionalRedirects(name, dirConfig) {
180
+ // Find the base direction word in the camelCase name
181
+ const baseDir = dirConfig.base
182
+ if (!name.includes(baseDir)) return
183
+
184
+ for (const [targetDir, suffix] of Object.entries(dirConfig.variants)) {
185
+ const redirectName = name.replace(baseDir, targetDir)
186
+ if (redirectName !== name) {
187
+ iconRedirects[redirectName] = name + suffix
188
+ }
189
+ }
190
+ }
191
+
192
+ function findIcons(dirs, ignore = []) {
193
+ function traverseDirectory(dir) {
194
+ if (!fs.existsSync(dir)) {
195
+ console.warn(`Warning: Directory not found: ${dir}. Skipping.`)
196
+ return
197
+ }
198
+ // Sort so output is reproducible across machines — readdirSync returns
199
+ // entries in filesystem order, which differs by OS and otherwise produces
200
+ // spurious reordering diffs in the generated icon-data on every build.
201
+ const files = fs.readdirSync(dir).sort()
202
+ if (ignore.includes(dir)) {
203
+ return
204
+ }
205
+
206
+ files.forEach((file) => {
207
+ const filePath = path.join(dir, file)
208
+ const stats = fs.statSync(filePath)
209
+
210
+ if (stats.isDirectory()) {
211
+ traverseDirectory(filePath)
212
+ } else if (path.extname(file) === '.svg') {
213
+ const content = fs.readFileSync(filePath, 'utf8')
214
+ const rawName = file.split('.')[0]
215
+ const names = rawName.split(',').map((n) =>
216
+ n.trim().replace(/-([a-z0-9])/g, (_, char) => char.toLocaleUpperCase())
217
+ )
218
+ const name = names[0]
219
+ let svgSource = content
220
+ .replace(/(<\?xml.*?>|<!DOCTYPE.*?>)\s?/g, '')
221
+ .replace(/<svg.*?>/, (a) =>
222
+ a.replace(
223
+ /\s(x|y|width|height|class|xmlns|xmlns:xlink)="[^"]+"/g,
224
+ ''
225
+ )
226
+ )
227
+ .replace(
228
+ /\s?\b(opacity:1;|fill="#000000"|fill="#000"|fill="none"|class="[^"]+"|stroke="currentColor"|stroke="#000000"|stroke-linejoin="[^"]+"|stroke-width="[^"]+"|(stroke-)?stroke-linecap="[^"]+")/g,
229
+ ''
230
+ )
231
+ .replace(/stroke-stroke/g, 'stroke')
232
+ .replace(/fill-fill/g, 'fill')
233
+ .replace(/\s+/g, ' ')
234
+ .replace(/>\s+</g, '><')
235
+ .replace(/\s(id)="[^"]+"/g, '')
236
+ .replace(/<!--.*?-->/g, '')
237
+
238
+ // Optimize coordinate precision based on viewBox size
239
+ if (shouldOptimize) {
240
+ const viewBoxSize = getViewBoxSize(svgSource)
241
+ const precision = getPrecisionForViewBox(viewBoxSize)
242
+ svgSource = optimizeCoordinates(svgSource, precision)
243
+ }
244
+ const classes = []
245
+ if (dir.includes('color')) {
246
+ classes.push('color')
247
+ } else {
248
+ // If not a color icon, remove fill/stroke styles
249
+ svgSource = svgSource.replace(/(fill|stroke)(-\w+)?:[^;]+;?/g, '')
250
+ }
251
+ if (dir.includes('stroked')) {
252
+ classes.push('stroked')
253
+ svgSource = svgSource.replace(/(fill)(-\w+)?:[^;]+;?/g, '')
254
+ }
255
+ if (dir.includes('filled')) {
256
+ classes.push('filled')
257
+ svgSource = svgSource.replace(/(stroke)(-\w+)?:[^;]+;?/g, '')
258
+ }
259
+ if (classes.length) {
260
+ svgSource = svgSource.replace(
261
+ /^<svg/,
262
+ `<svg class="${classes.join(' ')}"`
263
+ )
264
+ }
265
+ iconData[name] = svgSource
266
+
267
+ // Comma-separated names: skip-forward,skip-back.svg
268
+ // First name is the base, subsequent names are redirects using
269
+ // the folder's convention (0f for right-left, etc.)
270
+ const dirConfig = getDirectionConfig(dir)
271
+ if (names.length > 1 && dirConfig) {
272
+ const suffixes = Object.values(dirConfig.variants)
273
+ for (let n = 1; n < names.length && n - 1 < suffixes.length; n++) {
274
+ iconRedirects[names[n]] = name + suffixes[n - 1]
275
+ }
276
+ }
277
+
278
+ // Generate directional redirects based on folder convention
279
+ if (dirConfig) {
280
+ generateDirectionalRedirects(name, dirConfig)
281
+ }
282
+ }
283
+ })
284
+ }
285
+
286
+ dirs.forEach((dir) => {
287
+ traverseDirectory(dir)
288
+ })
289
+ }
290
+
291
+ findIcons(iconDirectories.split(','))
292
+
293
+ // Manual redirects for icons that don't fit folder conventions
294
+ const manualRedirects = {
295
+ arrowDownRight: 'arrowUpRight90r',
296
+ arrowDownLeft: 'arrowUpRight180r',
297
+ arrowUpLeft: 'arrowUpRight270r',
298
+ }
299
+ Object.assign(iconRedirects, manualRedirects)
300
+
301
+ // Merge redirects — only add if the target name doesn't already have SVG data
302
+ let redirectCount = 0
303
+ for (const [name, redirect] of Object.entries(iconRedirects)) {
304
+ if (!iconData[name]) {
305
+ iconData[name] = redirect
306
+ redirectCount++
307
+ }
308
+ }
309
+ if (redirectCount > 0) {
310
+ console.log(`Generated ${redirectCount} directional redirects`)
311
+ }
312
+
313
+ // Ensure the output directory exists
314
+ const outputDir = path.dirname(outputFilePath)
315
+ if (!fs.existsSync(outputDir)) {
316
+ fs.mkdirSync(outputDir, { recursive: true })
317
+ }
318
+
319
+ const source =
320
+ (typeDeclaration ? typeDeclaration + '\n\n' : '') +
321
+ 'export default ' +
322
+ JSON.stringify(iconData, null, 2).replace(/"(\w+)":/g, '$1:') +
323
+ (isTypescript ? ' as IconData\n' : '\n')
324
+
325
+ fs.writeFileSync(outputFilePath, source, 'utf8')
326
+
327
+ console.log(`Successfully generated icon data to: ${outputFilePath}`)
@@ -1 +1,11 @@
1
- export declare function generateLlmsTxt(outputPath: string): void;
1
+ export interface LlmsTxtMeta {
2
+ name?: string;
3
+ description?: string;
4
+ /** site origin, used for the Docs link */
5
+ baseUrl?: string;
6
+ /** project links — `github` / `npm` (or any) become Source/npm links */
7
+ projectLinks?: Record<string, string | undefined>;
8
+ /** optional framing line(s) under the description */
9
+ tagline?: string;
10
+ }
11
+ export declare function generateLlmsTxt(outputPath: string, meta?: LlmsTxtMeta): void;
@@ -33,8 +33,8 @@ function extractDescription(text) {
33
33
  }
34
34
  return '';
35
35
  }
36
- export function generateLlmsTxt(outputPath) {
37
- const config = JSON.parse(fs.readFileSync('package.json', 'utf8'));
36
+ export function generateLlmsTxt(outputPath, meta = {}) {
37
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
38
38
  const docs = [];
39
39
  const files = fs.readdirSync(SRC).filter((f) => f.endsWith('.ts'));
40
40
  for (const file of files) {
@@ -59,17 +59,23 @@ export function generateLlmsTxt(outputPath) {
59
59
  });
60
60
  }
61
61
  docs.sort((a, b) => a.title.localeCompare(b.title));
62
+ const name = meta.name ?? pkg.name;
63
+ const description = meta.description ?? pkg.description;
64
+ const links = [];
65
+ if (meta.baseUrl)
66
+ links.push(`- Docs: ${meta.baseUrl}`);
67
+ if (meta.projectLinks?.github)
68
+ links.push(`- Source: ${meta.projectLinks.github}`);
69
+ const npm = meta.projectLinks?.npm ?? `https://www.npmjs.com/package/${pkg.name}`;
70
+ if (npm)
71
+ links.push(`- npm: ${npm}`);
62
72
  const lines = [
63
- `# ${config.name} v${config.version}`,
73
+ `# ${name} v${pkg.version}`,
64
74
  '',
65
- config.description,
75
+ description,
76
+ ...(meta.tagline ? ['', meta.tagline] : []),
66
77
  '',
67
- 'Web component library built on tosijs. Components augment HTML5/CSS3',
68
- 'rather than replacing native elements.',
69
- '',
70
- '- Docs: https://ui.tosijs.net',
71
- '- Source: https://github.com/tonioloewald/xinjs-ui',
72
- '- npm: https://www.npmjs.com/package/tosijs-ui',
78
+ ...links,
73
79
  '',
74
80
  '## Documentation',
75
81
  '',
@@ -57,7 +57,17 @@ export async function buildSite(config) {
57
57
  console.time('build');
58
58
  // Optionally also build the library (ESM + type declarations) — for repos
59
59
  // whose single build publishes both an npm package and its doc site.
60
- if (config.emitLibrary) {
60
+ if (config.libraryTsconfig) {
61
+ // Consumer-controlled library build (handles root noEmit, removeComments,
62
+ // outDir, etc.).
63
+ try {
64
+ await $ `bun tsc -p ${config.libraryTsconfig}`;
65
+ }
66
+ catch {
67
+ console.log('library (tsc -p) build finished');
68
+ }
69
+ }
70
+ else if (config.emitLibrary) {
61
71
  try {
62
72
  await $ `bun tsc --declaration --incremental --outDir dist`;
63
73
  }
@@ -70,6 +80,13 @@ export async function buildSite(config) {
70
80
  // consumer supplies (e.g. via staticDirs or an absolute URL).
71
81
  const scriptName = (config.scriptUrl ?? '/iife.js').replace(/^\//, '');
72
82
  if (config.bundleEntry) {
83
+ // IIFE externals become a synchronous require() shim that throws at
84
+ // module-eval ("Dynamic require of … is not supported") — the build still
85
+ // succeeds, so warn rather than fail silently.
86
+ if (config.bundleExternals && config.bundleExternals.length > 0) {
87
+ console.warn(`⚠️ bundleExternals in an IIFE bundle (${config.bundleExternals.join(', ')}): Bun emits a dynamic require() shim that throws at runtime. Load these\n` +
88
+ ` via import() (dynamically, so the bundler keeps them async) or an importmap, not a static import.`);
89
+ }
73
90
  const result = await Bun.build({
74
91
  entrypoints: [config.bundleEntry],
75
92
  outdir: DIST,
@@ -87,11 +104,29 @@ export async function buildSite(config) {
87
104
  }
88
105
  await $ `cp ${DIST}/${scriptName} ${PUBLIC}`.text();
89
106
  const bundleFile = await Bun.file(`${DIST}/${scriptName}`).arrayBuffer();
107
+ // import.meta is illegal in a classic <script> — if it survived bundling
108
+ // (a branch the bundler couldn't eliminate) the IIFE will SyntaxError.
109
+ if (Buffer.from(bundleFile).toString('utf8').includes('import.meta')) {
110
+ console.warn(`⚠️ ${scriptName} contains \`import.meta\`, which is a SyntaxError in a classic <script>.\n` +
111
+ ` A dependency referenced it in a branch the bundler couldn't drop — mark that dep external\n` +
112
+ ` (+ importmap) or choose a browser-only entry point.`);
113
+ }
90
114
  const bundleGzip = gzipSync(Buffer.from(bundleFile));
91
115
  console.log(`${scriptName}: ${(bundleFile.byteLength / 1024).toFixed(1)}kb (${(bundleGzip.length / 1024).toFixed(1)}kb gzip)`);
92
116
  }
93
- if (config.llmsTxt ?? true) {
94
- generateLlmsTxt('llms.txt');
117
+ if (config.llmsTxt !== false) {
118
+ if (typeof config.llmsTxt === 'function') {
119
+ const corpus = JSON.parse(await Bun.file('demo/docs.json').text());
120
+ await Bun.write('llms.txt', config.llmsTxt(corpus));
121
+ }
122
+ else {
123
+ generateLlmsTxt('llms.txt', {
124
+ name: config.name,
125
+ description: config.description,
126
+ baseUrl: config.baseUrl,
127
+ projectLinks: config.projectLinks,
128
+ });
129
+ }
95
130
  }
96
131
  // Generate the static, pre-rendered doc site (one /slug/index.html per doc).
97
132
  // Runs after the static-asset copy so the generated index.html (README) wins,
@@ -1,5 +1,6 @@
1
1
  import type { ProjectLinks, LinkItem } from '../../doc-browser';
2
2
  import type { DocSystemTheme } from '../doc-system-styles';
3
+ import type { Doc } from './docs';
3
4
  export type SiteHost = 'github-pages' | 'firebase' | 'static';
4
5
  export interface SiteConfig {
5
6
  /** project / brand name — header, <title> suffix, og:site_name */
@@ -82,13 +83,26 @@ export interface SiteConfig {
82
83
  */
83
84
  prebuild?: () => void | Promise<void>;
84
85
  /**
85
- * Also build the library: `tsc --declaration --outDir dist` (ESM + types).
86
- * Default false. Repos whose single build publishes BOTH an npm package and
87
- * its doc site (the tosijs-* libs) set this true; a pure docs site omits it.
86
+ * Also build the library: `tsc --declaration --incremental --outDir dist`
87
+ * (ESM + types). Default false. Repos whose single build publishes BOTH an
88
+ * npm package and its doc site (the tosijs-* libs) set this true; a pure docs
89
+ * site omits it. Ignored when `libraryTsconfig` is set.
88
90
  */
89
91
  emitLibrary?: boolean;
90
- /** emit llms.txt agent-discoverability index. Default true. */
91
- llmsTxt?: boolean;
92
+ /**
93
+ * Path to a tsconfig for the library build, run as `tsc -p <path>` instead of
94
+ * the fixed `emitLibrary` command. Use this when the root tsconfig has
95
+ * `noEmit: true`, or to control `removeComments`/`outDir`/`declaration`
96
+ * yourself (e.g. keep doc comments in the published JS for AI readers).
97
+ */
98
+ libraryTsconfig?: string;
99
+ /**
100
+ * Emit llms.txt agent-discoverability index. Default true (uses `name` /
101
+ * `description` / `baseUrl` / `projectLinks`). Set false to skip, or pass a
102
+ * function for a fully custom index — it receives the doc corpus and returns
103
+ * the file contents.
104
+ */
105
+ llmsTxt?: boolean | ((docs: Doc[]) => string);
92
106
  /** served web-root output dir, default 'docs' */
93
107
  outputDir?: string;
94
108
  /** dev-server port, default 8787 */