codz-cli 0.1.2 → 0.1.8

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.
@@ -21,6 +21,8 @@ import { ToolError } from "../core/types.js";
21
21
  import { kebab, snake } from "../input/naming.js";
22
22
  import { access, androidPrefill, apiRef, classOfFile, ktInitial, context, dataViewModel, ensureUiState, fileOf, finish, formViewModel, injection, names, routeOf } from "./androidUi.js";
23
23
  import { responseShape, titleField } from "./screen.js";
24
+ import { androidPaging, pagedViewModel } from "./androidUi.js";
25
+ import { pagingSummary, pagingWarnings } from "./paging.js";
24
26
  /** Libraries the generated Activities use, with versions known to work together. */
25
27
  const VIEW_LIBS = [
26
28
  { key: 'androidx-core-ktx', coord: 'androidx.core:core-ktx', version: '1.15.0', vkey: 'core-ktx' },
@@ -142,7 +144,6 @@ export function planViewsScreen(root, map, opts) {
142
144
  if (x.read(f) !== null)
143
145
  throw new ToolError('route_exists', `${f} already exists.`);
144
146
  const pkg = packageOf(x.srcDir, v.screensDir);
145
- const uiState = ensureUiState(x, v.screensDir);
146
147
  const T = a.returnType;
147
148
  const shape = responseShape(T, map);
148
149
  const model = shape.model;
@@ -152,6 +153,9 @@ export function planViewsScreen(root, map, opts) {
152
153
  const name = p.name.replace(/`/g, '');
153
154
  return /^(Int|Long)$/.test(p.type) ? `${name}.to${p.type}()` : name;
154
155
  });
156
+ if (opts.paginate || opts.search)
157
+ return viewsPaged(v, opts, a, { n, params, required, args, file, vmFile, layout, pkg, shape, model, modelImports, inj, T });
158
+ const uiState = ensureUiState(x, v.screensDir);
155
159
  x.files.set(vmFile, dataViewModel(x, a, n.vm, pkg, params, args, inj, uiState, T, modelImports));
156
160
  x.summary.push(`Create ${vmFile}: ${n.vm} loads ${a.iface}.${a.ep.name}()${params.length ? ` with ${params.join(', ')} from the Intent extras` : ''}`);
157
161
  // Layout.
@@ -534,3 +538,179 @@ export function linkViewsList(text, pkg, t) {
534
538
  return null;
535
539
  return addKotlinImports(out, ['android.content.Intent'], pkg);
536
540
  }
541
+ /** Views: a paged or searchable list (RecyclerView, search box, more as you scroll) with the shared paged ViewModel. */
542
+ function viewsPaged(v, opts, a, o) {
543
+ const { x } = v;
544
+ const { n, pkg, model } = o;
545
+ const paging = androidPaging(a, o.shape, opts, o.T);
546
+ const item = /^List<(.+)>\??$/.exec(o.T)?.[1] ?? 'Any';
547
+ const routeNamed = o.required.map((p, i) => `${p.name} = ${o.args[i]}`);
548
+ x.files.set(o.vmFile, pagedViewModel(x, a, n.vm, pkg, o.params, routeNamed, o.inj, item, paging, o.modelImports));
549
+ const searchable = Boolean(paging.search || paging.clientSearch);
550
+ const pg = paging.paginate;
551
+ const stateName = `${n.vm.replace(/ViewModel$/, '')}ListState`;
552
+ x.summary.push(`Create ${o.vmFile}: ${n.vm} with items, loading, error${searchable ? ', query' : ''}${pg ? ', hasMore' : ''}, calling ${a.iface}.${a.ep.name}()`);
553
+ const tf = titleField(model);
554
+ const stringFields = paging.searchFields.map((f) => (model?.fields.find((m) => m.name === f)?.nullable ? `${access('item', f)}.orEmpty()` : access('item', f)));
555
+ const center = (id, extra = []) => [` android:id="@+id/${id}"`, ' android:layout_width="wrap_content"', ' android:layout_height="wrap_content"', ' android:layout_gravity="center"', ...extra];
556
+ x.files.set(o.layout, [
557
+ XML_HEAD,
558
+ `<LinearLayout ${NS}`,
559
+ ' android:layout_width="match_parent"',
560
+ ' android:layout_height="match_parent"',
561
+ ' android:orientation="vertical">',
562
+ '',
563
+ ' <TextView',
564
+ ' android:layout_width="wrap_content"',
565
+ ' android:layout_height="wrap_content"',
566
+ ' android:padding="16dp"',
567
+ ` android:text="${n.title}"`,
568
+ ' android:textSize="22sp" />',
569
+ ...(searchable ? ['', ' <EditText', ' android:id="@+id/search"', ' android:layout_width="match_parent"', ' android:layout_height="wrap_content"', ' android:layout_marginHorizontal="12dp"', ' android:hint="Search"', ' android:importantForAutofill="no"', ' android:inputType="text"', ' android:singleLine="true" />'] : []),
570
+ '',
571
+ ' <FrameLayout',
572
+ ' android:layout_width="match_parent"',
573
+ ' android:layout_height="0dp"',
574
+ ' android:layout_weight="1">',
575
+ '',
576
+ ' <androidx.recyclerview.widget.RecyclerView',
577
+ ' android:id="@+id/list"',
578
+ ' android:layout_width="match_parent"',
579
+ ' android:layout_height="match_parent" />',
580
+ '',
581
+ ' <ProgressBar',
582
+ ...center('progress', [' android:visibility="gone" />']),
583
+ '',
584
+ ' <TextView',
585
+ ...center('empty', [' android:padding="16dp"', ' android:visibility="gone" />']),
586
+ '',
587
+ ' <LinearLayout',
588
+ ...center('error', [' android:gravity="center"', ' android:orientation="vertical"', ' android:padding="16dp"', ' android:visibility="gone">']),
589
+ '',
590
+ ' <TextView',
591
+ ' android:id="@+id/error_text"',
592
+ ' android:layout_width="wrap_content"',
593
+ ' android:layout_height="wrap_content" />',
594
+ '',
595
+ ' <Button',
596
+ ' android:id="@+id/retry"',
597
+ ' android:layout_width="wrap_content"',
598
+ ' android:layout_height="wrap_content"',
599
+ ' android:text="Retry" />',
600
+ ' </LinearLayout>',
601
+ ' </FrameLayout>',
602
+ ...(pg
603
+ ? ['', ' <ProgressBar', ' android:id="@+id/more"', ' android:layout_width="wrap_content"', ' android:layout_height="wrap_content"', ' android:layout_gravity="center_horizontal"', ' android:visibility="gone" />', '', ' <Button', ' android:id="@+id/more_retry"', ' android:layout_width="wrap_content"', ' android:layout_height="wrap_content"', ' android:layout_gravity="center_horizontal"', ' android:text="Retry"', ' android:visibility="gone" />']
604
+ : []),
605
+ '</LinearLayout>',
606
+ '',
607
+ ].join('\n'));
608
+ const activity = [
609
+ `package ${pkg}`,
610
+ '',
611
+ ...o.inj.activity.annotations,
612
+ `class ${n.activity} : ComponentActivity() {`,
613
+ ` private val viewModel: ${n.vm} ${o.inj.activity.delegate}`,
614
+ ' private val adapter = ItemAdapter()',
615
+ '',
616
+ ' override fun onCreate(savedInstanceState: Bundle?) {',
617
+ ' super.onCreate(savedInstanceState)',
618
+ ` setContentView(R.layout.${n.layout})`,
619
+ ' val list = findViewById<RecyclerView>(R.id.list)',
620
+ ' val layoutManager = LinearLayoutManager(this)',
621
+ ' list.layoutManager = layoutManager',
622
+ ' list.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))',
623
+ ' list.adapter = adapter',
624
+ ...(pg
625
+ ? [
626
+ ' // Near the end of the list: ask for the next page.',
627
+ ' list.addOnScrollListener(object : RecyclerView.OnScrollListener() {',
628
+ ' override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {',
629
+ ' if (layoutManager.findLastVisibleItemPosition() >= adapter.itemCount - 3) viewModel.load()',
630
+ ' }',
631
+ ' })',
632
+ ' findViewById<Button>(R.id.more_retry).setOnClickListener { viewModel.load() }',
633
+ ]
634
+ : []),
635
+ ...(searchable ? [' findViewById<EditText>(R.id.search).doAfterTextChanged { viewModel.search(it?.toString().orEmpty()) }'] : []),
636
+ ` findViewById<Button>(R.id.retry).setOnClickListener { viewModel.load(${pg ? 'reset = true' : ''}) }`,
637
+ ' lifecycleScope.launch {',
638
+ ' repeatOnLifecycle(Lifecycle.State.STARTED) {',
639
+ ' viewModel.state.collect { render(it) }',
640
+ ' }',
641
+ ' }',
642
+ ' }',
643
+ '',
644
+ ` private fun render(s: ${stateName}) {`,
645
+ ` val shown = ${paging.clientSearch ? `if (s.query.isBlank()) s.items else s.items.filter { item -> listOf(${stringFields.join(', ')}).any { it.contains(s.query.trim(), ignoreCase = true) } }` : 's.items'}`,
646
+ ' adapter.submit(shown)',
647
+ ' findViewById<View>(R.id.progress).isVisible = s.loading && s.items.isEmpty()',
648
+ ' findViewById<View>(R.id.error).isVisible = s.error != null && s.items.isEmpty()',
649
+ ' findViewById<TextView>(R.id.error_text).text = s.error',
650
+ ' findViewById<TextView>(R.id.empty).apply {',
651
+ ' isVisible = shown.isEmpty() && !s.loading && s.error == null',
652
+ ` text = ${searchable ? 'if (s.query.isBlank()) "Nothing here yet." else "No matches."' : '"Nothing here yet."'}`,
653
+ ' }',
654
+ ...(pg
655
+ ? [
656
+ ' findViewById<View>(R.id.more).isVisible = s.loading && s.items.isNotEmpty()',
657
+ ' findViewById<View>(R.id.more_retry).isVisible = s.error != null && s.items.isNotEmpty()',
658
+ ' // The first pages may not fill the screen: keep loading until they do.',
659
+ ' val list = findViewById<RecyclerView>(R.id.list)',
660
+ ' list.post { if (s.hasMore && !s.loading && s.error == null && !list.canScrollVertically(1)) viewModel.load() }',
661
+ ]
662
+ : []),
663
+ ' }',
664
+ '',
665
+ ' private class ItemAdapter : RecyclerView.Adapter<ItemAdapter.Holder>() {',
666
+ ` private var items: List<${item}> = emptyList()`,
667
+ '',
668
+ ' class Holder(val text: TextView) : RecyclerView.ViewHolder(text)',
669
+ '',
670
+ ' @SuppressLint("NotifyDataSetChanged")',
671
+ ` fun submit(data: List<${item}>) {`,
672
+ ' items = data',
673
+ ' notifyDataSetChanged()',
674
+ ' }',
675
+ '',
676
+ ' override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder =',
677
+ ' Holder(LayoutInflater.from(parent.context).inflate(android.R.layout.simple_list_item_1, parent, false) as TextView)',
678
+ '',
679
+ ' override fun getItemCount(): Int = items.size',
680
+ '',
681
+ ' override fun onBindViewHolder(holder: Holder, position: Int) {',
682
+ ` holder.text.text = ${tf ? `${access('items[position]', tf)}.toString()` : 'items[position].toString()'}`,
683
+ ' }',
684
+ ' }',
685
+ '}',
686
+ '',
687
+ ].join('\n');
688
+ const imports = [
689
+ 'android.annotation.SuppressLint',
690
+ 'android.os.Bundle',
691
+ 'android.view.LayoutInflater',
692
+ 'android.view.View',
693
+ 'android.view.ViewGroup',
694
+ 'android.widget.Button',
695
+ 'android.widget.TextView',
696
+ 'androidx.activity.ComponentActivity',
697
+ 'androidx.core.view.isVisible',
698
+ 'androidx.lifecycle.Lifecycle',
699
+ 'androidx.lifecycle.lifecycleScope',
700
+ 'androidx.lifecycle.repeatOnLifecycle',
701
+ 'androidx.recyclerview.widget.DividerItemDecoration',
702
+ 'androidx.recyclerview.widget.LinearLayoutManager',
703
+ 'androidx.recyclerview.widget.RecyclerView',
704
+ 'kotlinx.coroutines.launch',
705
+ v.rClass,
706
+ ...o.modelImports,
707
+ ...o.inj.activity.imports,
708
+ ...(searchable ? ['android.widget.EditText', 'androidx.core.widget.doAfterTextChanged'] : []),
709
+ ];
710
+ x.files.set(o.file, addKotlinImports(activity, imports, pkg));
711
+ x.summary.push(`Create ${o.file} and ${o.layout}: ${n.activity} lists them and ${pagingSummary(paging)}`);
712
+ x.warnings.push(...pagingWarnings(paging));
713
+ ensureLibs(x, v.module, ['androidx.core:core-ktx', 'androidx.activity:activity-ktx', 'androidx.lifecycle:lifecycle-runtime-ktx', 'androidx.lifecycle:lifecycle-viewmodel-ktx', 'androidx.recyclerview:recyclerview']);
714
+ registerActivity(x, v.manifest, `${pkg}.${n.activity}`);
715
+ return finish(x, 'screen add', hint(n.activity, o.params));
716
+ }
@@ -1,4 +1,5 @@
1
1
  // api add: cURL / URL / JSON samples → model, function and registration, as a change set.
2
+ import { groupPlanned, modelLangOf } from "../core/modelGroup.js";
2
3
  import { join } from 'node:path';
3
4
  import { loadMap } from "../core/cache.js";
4
5
  import { buildChangeSet } from "../core/changeset.js";
@@ -94,6 +95,8 @@ export async function planApiAdd(root, adapter, opts) {
94
95
  const wire = adapter.planWire({ root, map: planned }, { endpoint: spec.name, module: opts.into, noLoad: opts.noLoad }, edits.files);
95
96
  edits = { files: wire.files, summary: [...edits.summary, ...wire.summary], warnings: [...edits.warnings, ...wire.warnings], generated: [...edits.generated, ...wire.generated] };
96
97
  }
98
+ const grouped = groupPlanned(root, edits.files, edits.generated, map, modelLangOf(adapter.id), opts.split);
99
+ edits = { ...edits, files: grouped.files, generated: grouped.generated };
97
100
  const changeSet = buildChangeSet(root, 'api add', edits);
98
101
  return { changeSet, spec };
99
102
  }
@@ -6,6 +6,7 @@
6
6
  // Filters: --tag, --only, --path <glob>, --method, --max. --list shows what
7
7
  // would be imported and stops. Endpoints that cannot be planned (a model name
8
8
  // clash, say) stop the import unless --skip-conflicts applies the rest.
9
+ import { groupPlanned, modelLangOf } from "../core/modelGroup.js";
9
10
  import { join } from 'node:path';
10
11
  import { loadMap } from "../core/cache.js";
11
12
  import { buildChangeSet } from "../core/changeset.js";
@@ -122,6 +123,7 @@ export async function planApiImport(root, adapter, opts) {
122
123
  warnings.add(`Skipped ${s.name}: ${s.reason}`);
123
124
  if (doc.format === 'har')
124
125
  warnings.add('Response bodies from the HAR file were used only to work out types; none of their values were written.');
125
- const changeSet = buildChangeSet(root, 'api import', { files: overlay, summary, warnings: [...warnings], generated });
126
+ const grouped = groupPlanned(root, overlay, generated, map, modelLangOf(adapter.id), opts.split);
127
+ const changeSet = buildChangeSet(root, 'api import', { files: grouped.files, summary, warnings: [...warnings], generated: grouped.generated });
126
128
  return { changeSet, added, existing, skipped };
127
129
  }
@@ -9,6 +9,7 @@
9
9
  // Breaking changes (a response field removed or changed type, a new required
10
10
  // parameter) are reported with the lines that use them. --check plans
11
11
  // everything and reports drift without writing (exit 1 when out of sync).
12
+ import { groupPlanned, modelLangOf } from "../core/modelGroup.js";
12
13
  import { join } from 'node:path';
13
14
  import { loadMap } from "../core/cache.js";
14
15
  import { buildChangeSet } from "../core/changeset.js";
@@ -216,6 +217,7 @@ export async function planApiSync(root, adapter, opts) {
216
217
  summary.push(`Everything matches ${opts.file}: ${report.unchanged.length} endpoint${report.unchanged.length === 1 ? '' : 's'} checked.`);
217
218
  else
218
219
  summary.unshift(`Sync with ${doc.title || opts.file}: ${report.updated.length} updated, ${report.added.length} added, ${report.removed.length} removed, ${report.unchanged.length} unchanged`);
219
- const changeSet = buildChangeSet(root, 'api sync', { files: overlay, summary, warnings: [...warnings], generated });
220
+ const grouped = groupPlanned(root, overlay, generated, map, modelLangOf(adapter.id), opts.split);
221
+ const changeSet = buildChangeSet(root, 'api sync', { files: grouped.files, summary, warnings: [...warnings], generated: grouped.generated });
220
222
  return { changeSet, ...report };
221
223
  }
@@ -0,0 +1,289 @@
1
+ // codz assets: typed names for the images, icons and fonts in the project,
2
+ // kept in step with the files. Run it again after adding or removing files.
3
+ //
4
+ // Flutter lib/gen/assets.dart: AppImages.logo = 'assets/images/logo.png' (one class per folder),
5
+ // and pubspec.yaml lists every asset folder and font family
6
+ // React Native src/assets/index.ts: images.logo = require('./images/logo.png')
7
+ // Next.js lib/assets.ts: paths under public/ ('/images/logo.png')
8
+ // React (Vite) src/assets.ts: public/ paths, and imports for files in src/assets
9
+ // Android, iOS already typed (R.drawable.logo, Image(.logo)); codz checks resource names
10
+ //
11
+ // Also lists assets no code uses.
12
+ import { join, posix } from 'node:path';
13
+ import { loadMap } from "../core/cache.js";
14
+ import { buildChangeSet } from "../core/changeset.js";
15
+ import { loadConfig } from "../core/config.js";
16
+ import { exists, isDir, readText, walk } from "../core/fsutil.js";
17
+ import { ToolError } from "../core/types.js";
18
+ import { camel, DART_RESERVED, identifier, pascal, TS_RESERVED } from "../input/naming.js";
19
+ const IMAGE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i;
20
+ const FONT = /\.(ttf|otf)$/i;
21
+ const MEDIA = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|json|lottie|riv|mp3|wav|ogg|mp4|webm|ttf|otf|txt|csv|pdf)$/i;
22
+ const HEADER = 'Generated by codz assets. Do not edit: add or remove files, then run codz assets again.';
23
+ /** Files under a folder, skipping hidden files and Flutter's resolution variants (2.0x/). */
24
+ function assetFiles(root, dir) {
25
+ if (!isDir(join(root, dir)))
26
+ return [];
27
+ return walk(root, dir, { maxFiles: 5000 })
28
+ .filter((f) => MEDIA.test(f) && !/(^|\/)\./.test(f) && !/\/\d+(\.\d+)?x\//.test(f))
29
+ .sort();
30
+ }
31
+ /** Unique identifiers for files in one group: logo, logoSvg when two files share a name. */
32
+ function names(files, rel, reserved) {
33
+ const base = (f) => identifier(posix.basename(f).replace(/\.[^.]+$/, ''), reserved) || 'asset';
34
+ const count = new Map();
35
+ for (const f of files)
36
+ count.set(base(f), (count.get(base(f)) ?? 0) + 1);
37
+ const out = new Map();
38
+ const used = new Set();
39
+ for (const f of files) {
40
+ let n = base(f);
41
+ if (count.get(n) > 1) {
42
+ const sub = posix.dirname(rel(f)).split('/').filter((x) => x && x !== '.').pop();
43
+ n = camel(`${posix.basename(f).replace(/\.[^.]+$/, '')} ${posix.extname(f).slice(1)}`) || n;
44
+ if (used.has(n) && sub)
45
+ n = camel(`${sub} ${n}`);
46
+ }
47
+ let k = 2;
48
+ const want = n;
49
+ while (used.has(n))
50
+ n = `${want}${k++}`;
51
+ used.add(n);
52
+ out.set(f, n);
53
+ }
54
+ return out;
55
+ }
56
+ /** Assets no source file mentions (by path, file name or generated name). */
57
+ function unused(root, files, nameOf, sources, generatedFile) {
58
+ const texts = sources.filter((f) => f !== generatedFile).map((f) => readText(join(root, f)) ?? '').join('\n');
59
+ return files.filter((f) => {
60
+ const n = nameOf.get(f);
61
+ return !texts.includes(posix.basename(f)) && !(n && new RegExp(`\\.${n}\\b`).test(texts));
62
+ });
63
+ }
64
+ // ---------- Flutter ----------
65
+ function flutterPubspec(text, dirs, fonts) {
66
+ const lines = text.split('\n');
67
+ let start = lines.findIndex((l) => /^flutter:\s*$/.test(l));
68
+ if (start < 0) {
69
+ lines.push('', 'flutter:');
70
+ start = lines.length - 1;
71
+ }
72
+ let end = start + 1;
73
+ while (end < lines.length && (lines[end].trim() === '' || /^\s/.test(lines[end])))
74
+ end++;
75
+ // Blank lines after the block stay after it.
76
+ while (end > start + 1 && lines[end - 1].trim() === '')
77
+ end--;
78
+ const block = lines.slice(start + 1, end);
79
+ const at = (key) => block.findIndex((l) => new RegExp(`^ ${key}:\\s*$`).test(l));
80
+ const listEnd = (i) => {
81
+ let j = i + 1;
82
+ while (j < block.length && (/^\s{4}/.test(block[j]) || block[j].trim() === ''))
83
+ j++;
84
+ while (j > i + 1 && block[j - 1].trim() === '')
85
+ j--;
86
+ return j;
87
+ };
88
+ if (dirs.length) {
89
+ const i = at('assets');
90
+ if (i < 0)
91
+ block.push(' assets:', ...dirs.map((d) => ` - ${d}/`));
92
+ else {
93
+ const j = listEnd(i);
94
+ const have = block.slice(i + 1, j).map((l) => l.replace(/^\s*-\s*/, '').trim()).filter(Boolean);
95
+ const missing = dirs.map((d) => `${d}/`).filter((d) => !have.includes(d));
96
+ block.splice(j, 0, ...missing.map((d) => ` - ${d}`));
97
+ }
98
+ }
99
+ if (fonts.length) {
100
+ let i = at('fonts');
101
+ if (i < 0) {
102
+ block.push(' fonts:');
103
+ i = block.length - 1;
104
+ }
105
+ const j = listEnd(i);
106
+ const existing = block.slice(i + 1, j).join('\n');
107
+ const add = [];
108
+ for (const f of fonts) {
109
+ if (new RegExp(`family:\\s*${f.family}\\s*$`, 'm').test(existing))
110
+ continue;
111
+ add.push(` - family: ${f.family}`, ' fonts:', ...f.files.flatMap((x) => [` - asset: ${x.asset}`, ...(x.weight && x.weight !== 400 ? [` weight: ${x.weight}`] : []), ...(x.italic ? [' style: italic'] : [])]));
112
+ }
113
+ block.splice(j, 0, ...add);
114
+ }
115
+ return [...lines.slice(0, start + 1), ...block, ...lines.slice(end)].join('\n');
116
+ }
117
+ const WEIGHTS = { thin: 100, extralight: 200, light: 300, regular: 400, medium: 500, semibold: 600, bold: 700, extrabold: 800, black: 900 };
118
+ function planFlutter(root, map, p) {
119
+ const pub = readText(join(root, 'pubspec.yaml'));
120
+ if (pub === null)
121
+ throw new ToolError('not_found', 'No pubspec.yaml.');
122
+ const dir = 'assets';
123
+ const all = assetFiles(root, dir);
124
+ if (!all.length)
125
+ throw new ToolError('no_assets', 'There is no assets/ folder with files yet.', 'Put images in assets/images and fonts in assets/fonts, then run codz assets.');
126
+ const fontFiles = all.filter((f) => FONT.test(f));
127
+ const others = all.filter((f) => !FONT.test(f));
128
+ // Group by the first folder under assets/: assets/images/x.png → AppImages; files directly in assets → AppAssets.
129
+ const groups = new Map();
130
+ for (const f of others) {
131
+ const parts = f.split('/');
132
+ const g = parts.length > 2 ? parts[1] : '';
133
+ groups.set(g, [...(groups.get(g) ?? []), f]);
134
+ }
135
+ const out = ['// ' + HEADER, '// ignore_for_file: constant_identifier_names', ''];
136
+ const nameOf = new Map();
137
+ const usage = [];
138
+ for (const [g, files] of [...groups].sort()) {
139
+ const cls = g ? `App${pascal(g)}` : 'AppAssets';
140
+ const ns = names(files, (f) => f.split('/').slice(2).join('/'), DART_RESERVED);
141
+ out.push(`abstract final class ${cls} {`, ...files.map((f) => ` static const ${ns.get(f)} = '${f}';`), '}', '');
142
+ for (const [f, n] of ns)
143
+ nameOf.set(f, n);
144
+ if (!usage.length)
145
+ usage.push(`${cls}.${ns.get(files[0])}`);
146
+ }
147
+ const families = new Map();
148
+ for (const f of fontFiles) {
149
+ const base = posix.basename(f).replace(FONT, '');
150
+ const [fam, style = 'Regular'] = base.split('-');
151
+ const s = style.toLowerCase();
152
+ const italic = /italic/.test(s);
153
+ const weight = WEIGHTS[s.replace('italic', '') || 'regular'];
154
+ families.set(fam, [...(families.get(fam) ?? []), { asset: f, weight, italic }]);
155
+ }
156
+ if (families.size)
157
+ out.push('abstract final class AppFonts {', ...[...families.keys()].sort().map((fam) => ` static const ${identifier(fam, DART_RESERVED)} = '${fam}';`), '}', '');
158
+ const file = 'lib/gen/assets.dart';
159
+ const text = `${out.join('\n').replace(/\n+$/, '')}\n`;
160
+ if (text !== readText(join(root, file))) {
161
+ p.files.set(file, text);
162
+ p.summary.push(`${exists(join(root, file)) ? 'Update' : 'Create'} ${file}: ${[...groups.keys()].map((g) => (g ? `App${pascal(g)}` : 'AppAssets')).join(', ')}${families.size ? ', AppFonts' : ''} (${others.length} file${others.length === 1 ? '' : 's'}${families.size ? `, ${families.size} font famil${families.size === 1 ? 'y' : 'ies'}` : ''})`);
163
+ }
164
+ const dirs = [...new Set(others.map((f) => posix.dirname(f)))].sort();
165
+ const nextPub = flutterPubspec(pub, dirs, [...families].map(([family, files]) => ({ family, files })));
166
+ if (nextPub !== pub) {
167
+ p.files.set('pubspec.yaml', nextPub);
168
+ p.summary.push('Update pubspec.yaml: every asset folder' + (families.size ? ' and font family' : ''));
169
+ }
170
+ if (usage.length || families.size)
171
+ p.summary.push(`Use them: ${usage.length ? `Image.asset(${usage[0]})` : ''}${usage.length && families.size ? ', ' : ''}${families.size ? `TextStyle(fontFamily: AppFonts.${identifier([...families.keys()].sort()[0], DART_RESERVED)})` : ''}`);
172
+ const idle = unused(root, others, nameOf, walk(root, 'lib', { exts: ['.dart'] }), file);
173
+ if (idle.length)
174
+ p.warnings.push(`Not used anywhere in lib/ (${idle.length}): ${idle.slice(0, 10).join(', ')}${idle.length > 10 ? ', …' : ''}`);
175
+ }
176
+ // ---------- React Native ----------
177
+ function planRn(root, map, p) {
178
+ const src = map.conventions.srcDir || (isDir(join(root, 'src')) ? 'src' : '.');
179
+ const dir = [`${src}/assets`, 'assets'].find((d) => assetFiles(root, d).length);
180
+ if (!dir)
181
+ throw new ToolError('no_assets', `There is no ${src}/assets or assets/ folder with files yet.`, 'Put images in src/assets/images, then run codz assets.');
182
+ const files = assetFiles(root, dir).filter((f) => !FONT.test(f));
183
+ const file = `${dir}/index.ts`;
184
+ const groups = new Map();
185
+ for (const f of files) {
186
+ const rel = f.slice(dir.length + 1).split('/');
187
+ const g = rel.length > 1 ? rel[0] : 'assets';
188
+ groups.set(g, [...(groups.get(g) ?? []), f]);
189
+ }
190
+ const q = "'";
191
+ const out = [`// ${HEADER}`, '// require() needs fixed paths, so every file is listed here.', ''];
192
+ const nameOf = new Map();
193
+ for (const [g, fs] of [...groups].sort()) {
194
+ const ns = names(fs, (f) => f.slice(dir.length + 1), TS_RESERVED);
195
+ out.push(`export const ${identifier(g, TS_RESERVED)} = {`, ...fs.map((f) => ` ${ns.get(f)}: require(${q}./${f.slice(dir.length + 1)}${q}),`), '} as const;', '');
196
+ for (const [f, n] of ns)
197
+ nameOf.set(f, n);
198
+ }
199
+ const text = `${out.join('\n').replace(/\n+$/, '')}\n`;
200
+ if (text !== readText(join(root, file))) {
201
+ p.files.set(file, text);
202
+ p.summary.push(`${exists(join(root, file)) ? 'Update' : 'Create'} ${file}: ${[...groups.keys()].join(', ')} (${files.length} file${files.length === 1 ? '' : 's'})`);
203
+ }
204
+ const first = [...groups].sort()[0];
205
+ p.summary.push(`Use them: <Image source={${identifier(first[0], TS_RESERVED)}.${nameOf.get(first[1][0])}} />`);
206
+ const sources = [src, 'app'].flatMap((d) => walk(root, d, { exts: ['.ts', '.tsx', '.js', '.jsx'] }));
207
+ const idle = unused(root, files, nameOf, sources, file);
208
+ if (idle.length)
209
+ p.warnings.push(`Not used anywhere (${idle.length}): ${idle.slice(0, 10).join(', ')}${idle.length > 10 ? ', …' : ''}`);
210
+ }
211
+ // ---------- Next.js and Vite ----------
212
+ function planWeb(root, map, vite, p) {
213
+ const pub = assetFiles(root, 'public').filter((f) => !/\/(favicon\.ico|robots\.txt|sitemap\.xml|manifest\.json)$/.test(f));
214
+ const srcAssets = vite ? assetFiles(root, 'src/assets') : [];
215
+ if (!pub.length && !srcAssets.length)
216
+ throw new ToolError('no_assets', `There are no files in public/${vite ? ' or src/assets/' : ''} yet.`);
217
+ const file = vite ? 'src/assets.ts' : exists(join(root, 'src/app')) ? 'src/lib/assets.ts' : 'lib/assets.ts';
218
+ const out = [`// ${HEADER}`, ''];
219
+ const imports = [];
220
+ const nameOf = new Map();
221
+ if (srcAssets.length) {
222
+ const ns = names(srcAssets, (f) => f.slice('src/assets/'.length), TS_RESERVED);
223
+ for (const f of srcAssets) {
224
+ const n = ns.get(f);
225
+ imports.push(`import ${n}Url from './${f.slice('src/'.length)}';`);
226
+ nameOf.set(f, n);
227
+ }
228
+ out.splice(2, 0, ...imports, '');
229
+ out.push('/** Files in src/assets: bundled, with hashed names. */', 'export const bundled = {', ...srcAssets.map((f) => ` ${ns.get(f)}: ${ns.get(f)}Url,`), '} as const;', '');
230
+ }
231
+ if (pub.length) {
232
+ const ns = names(pub, (f) => f.slice('public/'.length), TS_RESERVED);
233
+ out.push('/** Files in public/: served at these paths as they are. */', 'export const publicFiles = {', ...pub.map((f) => ` ${ns.get(f)}: '/${f.slice('public/'.length)}',`), '} as const;', '');
234
+ for (const [f, n] of ns)
235
+ nameOf.set(f, n);
236
+ }
237
+ const text = `${out.join('\n').replace(/\n+$/, '')}\n`;
238
+ if (text !== readText(join(root, file))) {
239
+ p.files.set(file, text);
240
+ p.summary.push(`${exists(join(root, file)) ? 'Update' : 'Create'} ${file}: ${[srcAssets.length ? `bundled (${srcAssets.length})` : '', pub.length ? `publicFiles (${pub.length})` : ''].filter(Boolean).join(', ')}`);
241
+ }
242
+ if (pub.length)
243
+ p.summary.push(`Use them: <img src={publicFiles.${nameOf.get(pub[0])}} alt="" />`);
244
+ const sources = ['app', 'src', 'components', 'pages', 'lib'].flatMap((d) => walk(root, d, { exts: ['.ts', '.tsx', '.js', '.jsx', '.css', '.scss'] }));
245
+ const idle = unused(root, [...pub, ...srcAssets], nameOf, sources, file);
246
+ if (idle.length)
247
+ p.warnings.push(`Not used anywhere (${idle.length}): ${idle.slice(0, 10).join(', ')}${idle.length > 10 ? ', …' : ''}`);
248
+ }
249
+ // ---------- Android and iOS: already typed ----------
250
+ function checkAndroid(root, map, p) {
251
+ const res = `${map.conventions.appModule || 'app'}/src/main/res`;
252
+ const files = walk(root, res, { maxFiles: 5000 }).filter((f) => /\/(drawable|mipmap|raw|font)[^/]*\//.test(f));
253
+ const bad = files.filter((f) => !/^[a-z][a-z0-9_]*$/.test(posix.basename(f).replace(/\.9\.png$|\.[^.]+$/, '')));
254
+ p.summary.push(`Android already gives every resource a typed name (R.drawable.logo, R.font.inter); ${files.length} resource file${files.length === 1 ? '' : 's'} checked.`);
255
+ if (bad.length)
256
+ p.warnings.push(`These resource names will break the build (only a-z, 0-9 and _, starting with a letter): ${bad.slice(0, 10).join(', ')}`);
257
+ const kt = walk(root, map.conventions.srcDir || 'app/src/main/java', { exts: ['.kt'] }).map((f) => readText(join(root, f)) ?? '').join('\n');
258
+ const xml = walk(root, res, { exts: ['.xml'] }).map((f) => readText(join(root, f)) ?? '').join('\n') + (readText(join(root, `${map.conventions.appModule || 'app'}/src/main/AndroidManifest.xml`)) ?? '');
259
+ const idle = files.filter((f) => /\/drawable[^/]*\//.test(f)).filter((f) => {
260
+ const n = posix.basename(f).replace(/\.9\.png$|\.[^.]+$/, '');
261
+ return !new RegExp(`R\\.drawable\\.${n}\\b`).test(kt) && !xml.includes(`@drawable/${n}`);
262
+ });
263
+ if (idle.length)
264
+ p.warnings.push(`Drawables not used anywhere (${idle.length}): ${idle.slice(0, 10).join(', ')}`);
265
+ }
266
+ function checkIos(p) {
267
+ p.summary.push('Xcode already generates typed names for asset catalog images and colors (Image(.logo), Color(.brand)) when "Generate Asset Symbols" is on (the default in new projects). Nothing to do.');
268
+ }
269
+ export async function planAssets(root, adapter) {
270
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
271
+ const p = { files: new Map(), summary: [], warnings: [] };
272
+ if (adapter.id === 'flutter')
273
+ planFlutter(root, map, p);
274
+ else if (adapter.id === 'react-native')
275
+ planRn(root, map, p);
276
+ else if (adapter.id === 'nextjs')
277
+ planWeb(root, map, false, p);
278
+ else if (adapter.id === 'react')
279
+ planWeb(root, map, true, p);
280
+ else if (adapter.id === 'android')
281
+ checkAndroid(root, map, p);
282
+ else if (adapter.id === 'ios')
283
+ checkIos(p);
284
+ else
285
+ throw new ToolError('not_supported', `codz assets needs a framework adapter; this project uses the ${adapter.id} adapter.`);
286
+ if (!p.files.size && (adapter.id === 'flutter' || adapter.id === 'react-native' || adapter.id === 'nextjs' || adapter.id === 'react'))
287
+ p.summary.unshift('Asset names are up to date.');
288
+ return buildChangeSet(root, 'assets', { files: p.files, summary: p.summary, warnings: p.warnings, generated: [] });
289
+ }