codz-cli 0.0.0-stage → 0.1.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.
- package/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +525 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +638 -0
- package/dist/skills/createDemo.js +1026 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// doctor: read-only health checks over the project map.
|
|
2
|
+
//
|
|
3
|
+
// locales missing keys (error), extra keys, untranslated texts, unused keys
|
|
4
|
+
// api calls with no error handling, endpoints without a typed response
|
|
5
|
+
// urls hardcoded http(s) URLs outside config / network setup
|
|
6
|
+
// models models nothing uses
|
|
7
|
+
//
|
|
8
|
+
// Exit code 1 when any error is found (or any warning with --strict).
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { matchBrace } from "../adapters/source.js";
|
|
11
|
+
import { loadMap } from "../core/cache.js";
|
|
12
|
+
import { loadConfig } from "../core/config.js";
|
|
13
|
+
import { readText, walk } from "../core/fsutil.js";
|
|
14
|
+
import { readLocales } from "./locale.js";
|
|
15
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
16
|
+
/** Code files with comments blanked (same length, so offsets and lines stay right). */
|
|
17
|
+
function codeFiles(root, map) {
|
|
18
|
+
const exts = map.framework === 'flutter' ? ['.dart'] : map.framework === 'android' ? ['.kt'] : ['.ts', '.tsx', '.js', '.jsx', '.mjs'];
|
|
19
|
+
const start = map.framework === 'flutter' ? 'lib' : '.';
|
|
20
|
+
const out = new Map();
|
|
21
|
+
for (const f of walk(root, start, { exts, extraIgnored: ['.codz', 'test', '__tests__', 'generated', 'build', '.gradle', 'androidTest'] })) {
|
|
22
|
+
if (/\.(g|freezed|d)\.(dart|ts)$/.test(f) || /(^|\/)(next|tailwind|postcss|eslint|vitest|jest)\.config\./.test(f))
|
|
23
|
+
continue;
|
|
24
|
+
out.set(f, stripComments(readText(join(root, f)) ?? ''));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function stripComments(t) {
|
|
29
|
+
let out = '';
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < t.length) {
|
|
32
|
+
const c = t[i];
|
|
33
|
+
const n = t[i + 1];
|
|
34
|
+
if (c === '/' && n === '/') {
|
|
35
|
+
const end = t.indexOf('\n', i);
|
|
36
|
+
const stop = end === -1 ? t.length : end;
|
|
37
|
+
out += ' '.repeat(stop - i);
|
|
38
|
+
i = stop;
|
|
39
|
+
}
|
|
40
|
+
else if (c === '/' && n === '*') {
|
|
41
|
+
const end = t.indexOf('*/', i + 2);
|
|
42
|
+
const stop = end === -1 ? t.length : end + 2;
|
|
43
|
+
out += t.slice(i, stop).replace(/[^\n]/g, ' ');
|
|
44
|
+
i = stop;
|
|
45
|
+
}
|
|
46
|
+
else if (c === "'" || c === '"' || c === '`') {
|
|
47
|
+
let j = i + 1;
|
|
48
|
+
while (j < t.length && t[j] !== c && !(c !== '`' && t[j] === '\n'))
|
|
49
|
+
j += t[j] === '\\' ? 2 : 1;
|
|
50
|
+
out += t.slice(i, j + 1);
|
|
51
|
+
i = j + 1;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
out += c;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
// ---------------- locales ----------------
|
|
61
|
+
function checkLocales(root, map, code, findings) {
|
|
62
|
+
let set;
|
|
63
|
+
try {
|
|
64
|
+
set = readLocales(root, map);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
findings.push({ check: 'locales', severity: 'error', message: e.message });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!set || !set.files.size)
|
|
71
|
+
return;
|
|
72
|
+
const def = set.keys.get(set.defaultLang) ?? new Map();
|
|
73
|
+
for (const [lang, keys] of set.keys) {
|
|
74
|
+
if (lang === set.defaultLang)
|
|
75
|
+
continue;
|
|
76
|
+
const file = set.files.get(lang);
|
|
77
|
+
const missing = [...def.keys()].filter((k) => !keys.has(k));
|
|
78
|
+
const extra = [...keys.keys()].filter((k) => !def.has(k));
|
|
79
|
+
const same = [...keys.entries()].filter(([k, v]) => def.get(k) === v && /[A-Za-z]{3,}/.test(v) && !/^[A-Z0-9_]+$/.test(v));
|
|
80
|
+
if (missing.length) {
|
|
81
|
+
findings.push({ check: 'locales', severity: 'error', file, message: `${lang} is missing ${missing.length} key${missing.length === 1 ? '' : 's'}: ${list(missing)}`, fix: `codz doctor --fix adds them with the ${set.defaultLang} text, or: codz locale key ${missing[0]} "${(def.get(missing[0]) ?? '').replace(/"/g, '\\"')}" --value ${lang}="<${lang} text>" --force`, data: { kind: 'missingKeys', lang, keys: missing } });
|
|
82
|
+
}
|
|
83
|
+
if (extra.length)
|
|
84
|
+
findings.push({ check: 'locales', severity: 'warning', file, message: `${lang} has ${extra.length} key${extra.length === 1 ? '' : 's'} the ${set.defaultLang} file does not: ${list(extra)}` });
|
|
85
|
+
if (same.length)
|
|
86
|
+
findings.push({ check: 'locales', severity: 'info', file, message: `${same.length} ${lang} text${same.length === 1 ? ' is' : 's are'} the same as ${set.defaultLang} (not translated yet?): ${list(same.map(([k]) => k))}` });
|
|
87
|
+
}
|
|
88
|
+
// Unused keys: look for the key where the framework would use it.
|
|
89
|
+
const localeFiles = new Set(set.files.values());
|
|
90
|
+
const all = [...code.entries()].filter(([f]) => !localeFiles.has(f)).map(([, t]) => t).join('\n');
|
|
91
|
+
// next-intl / i18next: useTranslations('home') / getTranslations('home') / useTranslation('home')
|
|
92
|
+
// with t('title') in the same file uses home.title. Collected per file, so namespaces do not mix.
|
|
93
|
+
const usedViaNamespace = new Set();
|
|
94
|
+
for (const [f, t] of code) {
|
|
95
|
+
if (localeFiles.has(f))
|
|
96
|
+
continue;
|
|
97
|
+
const namespaces = [...t.matchAll(/\b(?:useTranslations|getTranslations|useTranslation)\s*\(\s*(?:\{\s*namespace\s*:\s*)?['"`]([\w.-]+)['"`]/g)].map((m) => m[1]);
|
|
98
|
+
const calls = [...t.matchAll(/\bt(?:\.rich|\.markup|\.raw)?\s*\(\s*['"`]([\w.-]+)['"`]/g)].map((m) => m[1]);
|
|
99
|
+
for (const c of calls) {
|
|
100
|
+
usedViaNamespace.add(c);
|
|
101
|
+
for (const ns of namespaces)
|
|
102
|
+
usedViaNamespace.add(`${ns}.${c}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const unused = [...def.keys()].filter((key) => {
|
|
106
|
+
const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
107
|
+
if (set.kind === 'arb')
|
|
108
|
+
return !new RegExp(`\\.${esc}\\b`).test(all);
|
|
109
|
+
if (usedViaNamespace.has(key))
|
|
110
|
+
return false;
|
|
111
|
+
if (new RegExp(`['"\`]${esc}['"\`]`).test(all))
|
|
112
|
+
return false;
|
|
113
|
+
// A whole namespace passed on (t used for a child component's keys) counts for its keys.
|
|
114
|
+
return ![...usedViaNamespace].some((u) => key.startsWith(`${u}.`));
|
|
115
|
+
});
|
|
116
|
+
// A dynamic lookup (t(`x.${y}`), key.tr on a variable) makes "unused" unreliable.
|
|
117
|
+
const dynamic = /\bt\(\s*`[^`]*\$\{|\b\w+\.tr\b(?!['"])/.test(all.replace(/['"][^'"\n]*['"]\.tr\b/g, ''));
|
|
118
|
+
if (unused.length) {
|
|
119
|
+
findings.push({
|
|
120
|
+
check: 'locales',
|
|
121
|
+
severity: 'info',
|
|
122
|
+
file: set.files.get(set.defaultLang),
|
|
123
|
+
message: `${unused.length} key${unused.length === 1 ? ' looks' : 's look'} unused: ${list(unused)}${dynamic ? ' (some keys are looked up dynamically, so check before deleting)' : ''}`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function list(xs, n = 6) {
|
|
128
|
+
return xs.slice(0, n).join(', ') + (xs.length > n ? `, … (${xs.length - n} more)` : '');
|
|
129
|
+
}
|
|
130
|
+
// ---------------- api ----------------
|
|
131
|
+
function insideTry(text, index) {
|
|
132
|
+
for (const m of text.slice(0, index).matchAll(/\btry\s*\{/g)) {
|
|
133
|
+
const open = m.index + m[0].length - 1;
|
|
134
|
+
const close = matchBrace(text, open, 'dart');
|
|
135
|
+
if (close === -1 || close > index)
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
function enclosingFunctionHandles(text, index, framework) {
|
|
141
|
+
// Look at the statement: .catchError / .catch( / onError chained after the call.
|
|
142
|
+
const after = text.slice(index, index + 400);
|
|
143
|
+
const stmtEnd = after.search(/;\s*(\n|$)/);
|
|
144
|
+
const stmt = stmtEnd === -1 ? after : after.slice(0, stmtEnd);
|
|
145
|
+
if (framework === 'android' && /\brunCatching\s*\{/.test(text.slice(Math.max(0, index - 200), index)))
|
|
146
|
+
return true;
|
|
147
|
+
if (/\.catchError\(|\.catch\(|onError\s*:|\.then\([^)]*,\s*\(?\w*\)?\s*=>/.test(stmt))
|
|
148
|
+
return true;
|
|
149
|
+
if (framework === 'flutter') {
|
|
150
|
+
// Riverpod turns errors into AsyncValue.error; AsyncValue.guard catches too.
|
|
151
|
+
const before = text.slice(Math.max(0, index - 1500), index);
|
|
152
|
+
if (/AsyncValue\.guard\s*\(/.test(before.slice(-300)))
|
|
153
|
+
return true;
|
|
154
|
+
if (/\b(FutureProvider|StreamProvider)(\.\w+)*(<[^>]*>)?\s*\(/.test(before) || /extends\s+(_\$\w+|AsyncNotifier|FamilyAsyncNotifier|AutoDisposeAsyncNotifier)\b/.test(text))
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
function checkApi(root, map, code, findings) {
|
|
160
|
+
const next = map.framework === 'nextjs';
|
|
161
|
+
for (const ep of map.endpoints) {
|
|
162
|
+
for (const f of new Set(ep.calledFrom)) {
|
|
163
|
+
const t = code.get(f);
|
|
164
|
+
if (t === undefined || f === ep.file)
|
|
165
|
+
continue;
|
|
166
|
+
// Next.js: server components and route handlers fall back to error.tsx; client code must handle errors.
|
|
167
|
+
if (next && !/^\s*['"]use client['"]/.test(t))
|
|
168
|
+
continue;
|
|
169
|
+
const re = new RegExp(`\\b${ep.name}\\s*\\(`, 'g');
|
|
170
|
+
for (const m of t.matchAll(re)) {
|
|
171
|
+
const lineStart = t.lastIndexOf('\n', m.index) + 1;
|
|
172
|
+
const lineText = t.slice(lineStart, t.indexOf('\n', m.index) === -1 ? t.length : t.indexOf('\n', m.index));
|
|
173
|
+
if (/\b(import|export|function|Future<|async\s+\w+\s*\()/.test(lineText.slice(0, m.index - lineStart)) && !/await|=/.test(lineText))
|
|
174
|
+
continue;
|
|
175
|
+
if (insideTry(t, m.index) || enclosingFunctionHandles(t, m.index, map.framework))
|
|
176
|
+
continue;
|
|
177
|
+
// FutureBuilder / StreamBuilder put the error in snapshot.error; only a builder that ignores it is a problem.
|
|
178
|
+
const fb = /\b(FutureBuilder|StreamBuilder)\b[\s\S]{0,200}$/.exec(t.slice(Math.max(0, m.index - 200), m.index));
|
|
179
|
+
if (fb) {
|
|
180
|
+
const rest = t.slice(m.index, m.index + 1200);
|
|
181
|
+
if (/hasError|\.error\b|ConnectionState/.test(rest))
|
|
182
|
+
continue;
|
|
183
|
+
findings.push({
|
|
184
|
+
check: 'api',
|
|
185
|
+
severity: 'warning',
|
|
186
|
+
file: f,
|
|
187
|
+
line: lineOf(t, m.index),
|
|
188
|
+
message: `${ep.name}() runs in a ${fb[1]} whose builder never checks snapshot.hasError, so a failed request shows as empty data.`,
|
|
189
|
+
fix: 'Handle snapshot.hasError in the builder (show an error message and a retry).',
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// useQuery / useSWR report errors through their own state.
|
|
194
|
+
if (next && /\b(useQuery|useSWR|useMutation|useSuspenseQuery)\s*\(/.test(t.slice(Math.max(0, m.index - 300), m.index)))
|
|
195
|
+
continue;
|
|
196
|
+
findings.push({
|
|
197
|
+
check: 'api',
|
|
198
|
+
severity: 'warning',
|
|
199
|
+
file: f,
|
|
200
|
+
line: lineOf(t, m.index),
|
|
201
|
+
message: `${ep.name}() is called without error handling; a failed request ${map.framework === 'flutter' ? 'throws a DioException' : map.framework === 'android' ? 'throws an HttpException or IOException (and crashes a coroutine without a handler)' : next ? 'throws in the browser' : 'throws'} here.`,
|
|
202
|
+
fix: map.framework === 'flutter' ? 'Wrap the call in try { … } on DioException catch (e) { … } and show an error state.' : map.framework === 'android' ? 'Wrap the call in try { … } catch (e: Exception) { … } (or runCatching) and expose an error state.' : 'Wrap the call in try/catch (or use .catch) and show an error state.',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const r = (ep.response ?? '').replace(/\s/g, '');
|
|
207
|
+
if (!r || /^(dynamic|any|unknown|Object|Object\?|Map<String,dynamic>|Record<string,any>|Record<string,unknown>|Response|Response<dynamic>|Future<dynamic>|Promise<any>|Promise<unknown>)$/.test(r)) {
|
|
208
|
+
findings.push({
|
|
209
|
+
check: 'api',
|
|
210
|
+
severity: 'warning',
|
|
211
|
+
file: ep.file,
|
|
212
|
+
message: `${ep.name}() (${ep.method} ${ep.path}) returns ${r || 'an untyped value'}; callers get no type checking.`,
|
|
213
|
+
fix: `codz api update ${ep.name} --sample response.json builds a typed model from a real response.`,
|
|
214
|
+
data: { kind: 'untyped', endpoint: ep.name },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// ---------------- urls ----------------
|
|
220
|
+
function checkUrls(map, code, findings) {
|
|
221
|
+
const allowedFiles = new Set([map.conventions.networkFile, map.conventions.httpHelperFile, map.conventions.diFile].filter(Boolean));
|
|
222
|
+
for (const [f, t] of code) {
|
|
223
|
+
if (allowedFiles.has(f) || /(^|\/)(env|config|constants?|app_config|environment)s?\.(ts|js|dart)$/.test(f) || /(^|\/)(ApiConfig|Config|Constants|Env)\.kt$/.test(f) || /(^|\/)(config|env)\//.test(f))
|
|
224
|
+
continue;
|
|
225
|
+
for (const m of t.matchAll(/(['"`])(https?:\/\/[^'"`\s]+)\1/g)) {
|
|
226
|
+
const url = m[2];
|
|
227
|
+
if (/^https?:\/\/(www\.)?(w3\.org|schema\.org|json-schema\.org|xmlns\.com)/.test(url))
|
|
228
|
+
continue;
|
|
229
|
+
const isAsset = /\.(png|jpe?g|gif|svg|webp|ico|mp4|woff2?)(\?|$)/i.test(url) || /(fonts\.googleapis|images\.unsplash|picsum\.photos|placehold)/.test(url);
|
|
230
|
+
const lineStart = t.lastIndexOf('\n', m.index) + 1;
|
|
231
|
+
const looksApi = /\/api\b|\/v\d+\b|localhost|127\.0\.0\.1|:\d{4}/.test(url) || /\b(fetch|axios|dio|http|get|post|put|patch|delete|baseUrl|BaseOptions)\b/i.test(t.slice(lineStart, m.index));
|
|
232
|
+
if (isAsset && !looksApi)
|
|
233
|
+
continue;
|
|
234
|
+
findings.push({
|
|
235
|
+
check: 'urls',
|
|
236
|
+
severity: looksApi ? 'warning' : 'info',
|
|
237
|
+
file: f,
|
|
238
|
+
line: lineOf(t, m.index),
|
|
239
|
+
message: `Hardcoded URL ${url}${looksApi ? ' looks like an API address' : ''}.`,
|
|
240
|
+
fix: map.framework === 'flutter' ? 'Read it from the base URL in your network setup (or --dart-define) instead.' : map.framework === 'android' ? 'Keep it in one place (ApiConfig.BASE_URL or a buildConfigField) and use that.' : `Read it from ${map.conventions.baseUrlEnv || 'an env variable such as NEXT_PUBLIC_API_URL'} instead.`,
|
|
241
|
+
data: { kind: 'url', url, index: m.index + 1 },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ---------------- models ----------------
|
|
247
|
+
function checkModels(map, code, findings) {
|
|
248
|
+
for (const model of map.models) {
|
|
249
|
+
const re = new RegExp(`\\b${model.name}\\b`);
|
|
250
|
+
const used = [...code.entries()].some(([f, t]) => {
|
|
251
|
+
if (f !== model.file)
|
|
252
|
+
return re.test(t);
|
|
253
|
+
// In its own file, count uses beyond the declaration (and fromJson/toJson of itself).
|
|
254
|
+
const n = [...t.matchAll(new RegExp(`\\b${model.name}\\b`, 'g'))].length;
|
|
255
|
+
const self = [...t.matchAll(new RegExp(`(class|interface|type|enum)\\s+${model.name}\\b|${model.name}\\.(fromJson|_)|${model.name}\\s*\\(|factory\\s+${model.name}\\b|_\\$${model.name}`, 'g'))].length;
|
|
256
|
+
return n > self;
|
|
257
|
+
});
|
|
258
|
+
if (!used)
|
|
259
|
+
findings.push({ check: 'models', severity: 'info', file: model.file, message: `The model ${model.name} is not used anywhere.`, fix: 'Delete it if it is left over from a removed endpoint (codz doctor --fix does that).', data: { kind: 'unusedModel', name: model.name } });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export async function runDoctor(root, adapter, opts = {}) {
|
|
263
|
+
const { map } = await loadMap(root, adapter, loadConfig(root).conventions, { force: true });
|
|
264
|
+
const code = codeFiles(root, map);
|
|
265
|
+
const findings = [];
|
|
266
|
+
const want = (c) => !opts.only?.length || opts.only.includes(c);
|
|
267
|
+
if (want('locales'))
|
|
268
|
+
checkLocales(root, map, code, findings);
|
|
269
|
+
if (want('api'))
|
|
270
|
+
checkApi(root, map, code, findings);
|
|
271
|
+
if (want('urls'))
|
|
272
|
+
checkUrls(map, code, findings);
|
|
273
|
+
if (want('models'))
|
|
274
|
+
checkModels(map, code, findings);
|
|
275
|
+
const order = { error: 0, warning: 1, info: 2 };
|
|
276
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity] || a.check.localeCompare(b.check) || (a.file ?? '').localeCompare(b.file ?? '') || (a.line ?? 0) - (b.line ?? 0));
|
|
277
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
278
|
+
for (const f of findings)
|
|
279
|
+
counts[f.severity]++;
|
|
280
|
+
return { findings, counts };
|
|
281
|
+
}
|
|
282
|
+
export function renderDoctor(r, color) {
|
|
283
|
+
const paint = (s, t) => (!color ? t : s === 'error' ? `\x1b[31m${t}\x1b[0m` : s === 'warning' ? `\x1b[33m${t}\x1b[0m` : `\x1b[2m${t}\x1b[0m`);
|
|
284
|
+
const icon = { error: '✗', warning: '!', info: 'i' };
|
|
285
|
+
if (!r.findings.length)
|
|
286
|
+
return 'No problems found.';
|
|
287
|
+
const out = [];
|
|
288
|
+
const titles = { locales: 'Locales', api: 'API calls', urls: 'Hardcoded URLs', models: 'Models' };
|
|
289
|
+
for (const check of ['locales', 'api', 'urls', 'models']) {
|
|
290
|
+
const fs = r.findings.filter((f) => f.check === check);
|
|
291
|
+
if (!fs.length)
|
|
292
|
+
continue;
|
|
293
|
+
out.push(titles[check]);
|
|
294
|
+
for (const f of fs) {
|
|
295
|
+
const where = f.file ? `${f.file}${f.line ? `:${f.line}` : ''} ` : '';
|
|
296
|
+
out.push(paint(f.severity, ` ${icon[f.severity]} ${where}${f.message}`));
|
|
297
|
+
if (f.fix)
|
|
298
|
+
out.push(` → ${f.fix}`);
|
|
299
|
+
}
|
|
300
|
+
out.push('');
|
|
301
|
+
}
|
|
302
|
+
out.push(`${r.counts.error} error${r.counts.error === 1 ? '' : 's'}, ${r.counts.warning} warning${r.counts.warning === 1 ? '' : 's'}, ${r.counts.info} note${r.counts.info === 1 ? '' : 's'}`);
|
|
303
|
+
return out.join('\n');
|
|
304
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// doctor --fix: repairs what can be repaired safely, as one change set with a
|
|
2
|
+
// diff (preview, apply, undo as usual). Everything else is listed with the
|
|
3
|
+
// command or edit that fixes it.
|
|
4
|
+
//
|
|
5
|
+
// missing locale keys added to each language with the default language's text
|
|
6
|
+
// hardcoded URLs Next.js: `${process.env.<baseUrlEnv>}/path` when the URL starts
|
|
7
|
+
// with that env's value; Flutter: a relative path inside Dio calls
|
|
8
|
+
// when it starts with the Dio base URL
|
|
9
|
+
// unused models the declaration is removed; a file left empty is deleted, and its
|
|
10
|
+
// barrel export goes with it
|
|
11
|
+
// untyped responses not automatic: needs a real response (api update --sample)
|
|
12
|
+
// unhandled API errors not automatic: needs a decision about the error UI
|
|
13
|
+
import { join, posix } from 'node:path';
|
|
14
|
+
import { findTsBlock } from "../adapters/nextjs.js";
|
|
15
|
+
import { matchBrace } from "../adapters/source.js";
|
|
16
|
+
import { loadMap } from "../core/cache.js";
|
|
17
|
+
import { buildChangeSet } from "../core/changeset.js";
|
|
18
|
+
import { loadConfig } from "../core/config.js";
|
|
19
|
+
import { jsonSet } from "../core/jsonEdit.js";
|
|
20
|
+
import { detectEol, readText, walk, withEol } from "../core/fsutil.js";
|
|
21
|
+
import { runDoctor } from "./doctor.js";
|
|
22
|
+
import { dartString, getxBlocks, readLocales } from "./locale.js";
|
|
23
|
+
const read = (root, files, f) => files.get(f) ?? readText(join(root, f));
|
|
24
|
+
// ---------------- locales ----------------
|
|
25
|
+
function fixMissingKeys(root, map, finding, files, summary) {
|
|
26
|
+
const set = readLocales(root, map);
|
|
27
|
+
if (!set)
|
|
28
|
+
return false;
|
|
29
|
+
const { lang, keys } = finding.data;
|
|
30
|
+
const def = set.keys.get(set.defaultLang);
|
|
31
|
+
const file = set.files.get(lang);
|
|
32
|
+
if (!file)
|
|
33
|
+
return false;
|
|
34
|
+
if (set.kind === 'getx') {
|
|
35
|
+
const block = getxBlocks(root, set.translationsFile).find((b) => b.lang === lang);
|
|
36
|
+
if (!block)
|
|
37
|
+
return false;
|
|
38
|
+
let t = (files.get(block.file) ?? block.text).replace(/\r\n/g, '\n');
|
|
39
|
+
const close = locateBlockClose(t, block.code);
|
|
40
|
+
if (close === -1)
|
|
41
|
+
return false;
|
|
42
|
+
const closeIndent = t.slice(t.lastIndexOf('\n', close) + 1, close).match(/^[ \t]*/)[0];
|
|
43
|
+
const before = t.slice(0, close).replace(/\s*$/, '');
|
|
44
|
+
const lines = keys.map((k) => `${closeIndent} ${dartString(k)}: ${dartString(def.get(k) ?? '')},`).join('\n');
|
|
45
|
+
t = `${before}${before.endsWith('{') || before.endsWith(',') ? '' : ','}\n${lines}\n${closeIndent}${t.slice(close)}`;
|
|
46
|
+
files.set(block.file, withEol(t, detectEol(block.text)));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const raw = read(root, files, file);
|
|
50
|
+
let t = raw;
|
|
51
|
+
for (const k of keys)
|
|
52
|
+
t = jsonSet(t, set.kind === 'arb' ? [k] : k.split('.'), def.get(k) ?? '');
|
|
53
|
+
files.set(file, t);
|
|
54
|
+
}
|
|
55
|
+
summary.push(`Add ${keys.length} missing key${keys.length === 1 ? '' : 's'} to ${file} (${lang}) with the ${set.defaultLang} text: ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ', …' : ''}`);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** The closing brace of a GetX language map ('en_US': { … }) in text. */
|
|
59
|
+
function locateBlockClose(t, code) {
|
|
60
|
+
const m = new RegExp(`['"]${code}['"]\\s*:\\s*\\{`).exec(t);
|
|
61
|
+
if (!m)
|
|
62
|
+
return -1;
|
|
63
|
+
return matchBrace(t, m.index + m[0].length - 1, 'dart');
|
|
64
|
+
}
|
|
65
|
+
// ---------------- URLs ----------------
|
|
66
|
+
function envValue(root, name) {
|
|
67
|
+
for (const f of ['.env.local', '.env.development', '.env', '.env.example', '.env.development.local']) {
|
|
68
|
+
const m = new RegExp(`^\\s*${name}\\s*=\\s*['"]?([^'"\\n#]+)`, 'm').exec(readText(join(root, f)) ?? '');
|
|
69
|
+
if (m && /^https?:\/\//.test(m[1].trim()))
|
|
70
|
+
return m[1].trim().replace(/\/$/, '');
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
function fixUrl(root, map, finding, files, summary) {
|
|
75
|
+
const file = finding.file;
|
|
76
|
+
const { url } = finding.data;
|
|
77
|
+
const t = read(root, files, file);
|
|
78
|
+
const c = map.conventions;
|
|
79
|
+
if (map.framework === 'nextjs') {
|
|
80
|
+
const env = c.baseUrlEnv;
|
|
81
|
+
const base = env ? envValue(root, env) : undefined;
|
|
82
|
+
if (!env || !base || !url.startsWith(base)) {
|
|
83
|
+
finding.fix = `It is not under ${env ? `${env} (${base ?? 'not set in any .env file'})` : 'a configured base URL'}; move it to an env variable by hand.`;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const rest = url.slice(base.length);
|
|
87
|
+
let next = t;
|
|
88
|
+
for (const q of ["'", '"', '`'])
|
|
89
|
+
next = next.split(`${q}${url}${q}`).join(`\`\${process.env.${env}}${rest}\``);
|
|
90
|
+
if (next === t)
|
|
91
|
+
return false;
|
|
92
|
+
files.set(file, next);
|
|
93
|
+
summary.push(`${file}: ${url} → \`\${process.env.${env}}${rest}\``);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (map.framework === 'react-native') {
|
|
97
|
+
finding.fix = `Move it to API_URL in ${c.configFile || 'src/config/env.ts'} and call it through your API client.`;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
// Flutter: only inside Dio calls, where a relative path resolves against BaseOptions.baseUrl.
|
|
101
|
+
const base = (c.baseUrl ?? '').replace(/\/$/, '');
|
|
102
|
+
if (!base || !url.startsWith(base)) {
|
|
103
|
+
finding.fix = `It does not start with the Dio base URL${base ? ` (${base})` : ''}; move it to your network setup by hand.`;
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const rest = url.slice(base.length) || '/';
|
|
107
|
+
let changed = false;
|
|
108
|
+
const next = t.replace(/(\b\w*[dD]io\w*\s*\.\s*(?:get|post|put|patch|delete|head|request|download)\s*(?:<[^>]*>)?\s*\(\s*)(['"])([^'"]+)\2/g, (m, pre, q, u) => {
|
|
109
|
+
if (u !== url)
|
|
110
|
+
return m;
|
|
111
|
+
changed = true;
|
|
112
|
+
return `${pre}${q}${rest}${q}`;
|
|
113
|
+
});
|
|
114
|
+
if (!changed) {
|
|
115
|
+
finding.fix = 'It is not passed straight to a Dio call; move it to your network setup by hand.';
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
files.set(file, next);
|
|
119
|
+
summary.push(`${file}: ${url} → '${rest}' (Dio adds its base URL)`);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
// ---------------- models ----------------
|
|
123
|
+
function dartClassBlock(text, name) {
|
|
124
|
+
const m = new RegExp(`(^|\\n)((?:[ \\t]*(?:///[^\\n]*|@\\w+(?:\\([^)]*\\))?)\\n)*)[ \\t]*(?:@\\w+(?:\\([^)]*\\))?\\s+)*class\\s+${name}\\b[^{]*\\{`).exec(text);
|
|
125
|
+
if (!m)
|
|
126
|
+
return null;
|
|
127
|
+
const close = matchBrace(text, m.index + m[0].length - 1, 'dart');
|
|
128
|
+
return close === -1 ? null : { start: m.index + m[1].length, end: close + 1 };
|
|
129
|
+
}
|
|
130
|
+
function fixUnusedModel(root, map, finding, files, deletes, summary, notes) {
|
|
131
|
+
const model = map.models.find((m) => m.name === finding.data.name);
|
|
132
|
+
if (!model || deletes.has(model.file))
|
|
133
|
+
return false;
|
|
134
|
+
const text = read(root, files, model.file);
|
|
135
|
+
if (text === null)
|
|
136
|
+
return false;
|
|
137
|
+
const lang = model.file.endsWith('.dart') ? 'dart' : 'ts';
|
|
138
|
+
const block = lang === 'dart' ? dartClassBlock(text, model.name) : findTsBlock(text, model.name);
|
|
139
|
+
if (!block) {
|
|
140
|
+
notes.push(`${model.file}: could not find the declaration of ${model.name}; delete it by hand.`);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
let rest = (text.slice(0, block.start) + text.slice(block.end)).replace(/\n{3,}/g, '\n\n');
|
|
144
|
+
// Generated json_serializable glue for this class.
|
|
145
|
+
if (lang === 'dart')
|
|
146
|
+
rest = rest.replace(new RegExp(`^part '[^']*\\.g\\.dart';\\n?`, 'm'), (p) => (/\bclass\s+\w+/.test(rest) ? p : ''));
|
|
147
|
+
const onlyImports = rest.replace(/^\s*(import|export)\b[^\n]*\n?/gm, '').replace(/^\s*part\b[^\n]*\n?/gm, '').trim() === '';
|
|
148
|
+
if (onlyImports) {
|
|
149
|
+
deletes.add(model.file);
|
|
150
|
+
files.delete(model.file);
|
|
151
|
+
summary.push(`Delete ${model.file}: ${model.name} was its only declaration and nothing uses it`);
|
|
152
|
+
removeBarrelExport(root, model.file, files, summary);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
files.set(model.file, rest.replace(/^\n+/, '').replace(/\n*$/, '\n'));
|
|
156
|
+
summary.push(`Remove ${model.name} from ${model.file}: nothing uses it`);
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
function removeBarrelExport(root, deleted, files, summary) {
|
|
161
|
+
const dir = posix.dirname(deleted);
|
|
162
|
+
const base = posix.basename(deleted).replace(/\.(ts|tsx|dart)$/, '');
|
|
163
|
+
const candidates = deleted.endsWith('.dart') ? walk(root, dir, { exts: ['.dart'] }).filter((f) => posix.dirname(f) === dir) : [`${dir}/index.ts`];
|
|
164
|
+
for (const f of candidates) {
|
|
165
|
+
if (f === deleted)
|
|
166
|
+
continue;
|
|
167
|
+
const t = read(root, files, f);
|
|
168
|
+
if (t === null)
|
|
169
|
+
continue;
|
|
170
|
+
const re = deleted.endsWith('.dart') ? new RegExp(`^export\\s+'(?:\\./)?${base}\\.dart';[ \\t]*\\n?`, 'gm') : new RegExp(`^export\\s+(?:\\*|(?:type\\s+)?\\{[^}]*\\})\\s+from\\s+['"]\\./${base}(?:\\.js)?['"];?[ \\t]*\\n?`, 'gm');
|
|
171
|
+
const next = t.replace(re, '');
|
|
172
|
+
if (next !== t) {
|
|
173
|
+
files.set(f, next);
|
|
174
|
+
summary.push(`Update ${f}: drop the export of ${posix.basename(deleted)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ---------------- entry ----------------
|
|
179
|
+
export async function planDoctorFix(root, adapter, opts = {}) {
|
|
180
|
+
const report = await runDoctor(root, adapter, opts);
|
|
181
|
+
const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
|
|
182
|
+
const files = new Map();
|
|
183
|
+
const deletes = new Set();
|
|
184
|
+
const summary = [];
|
|
185
|
+
const notes = [];
|
|
186
|
+
const manual = [];
|
|
187
|
+
let fixed = 0;
|
|
188
|
+
// URLs from the end of each file first, so earlier offsets stay valid (replacements are by value anyway).
|
|
189
|
+
for (const f of report.findings) {
|
|
190
|
+
let ok = false;
|
|
191
|
+
const d = f.data;
|
|
192
|
+
if (d?.kind === 'missingKeys')
|
|
193
|
+
ok = fixMissingKeys(root, map, f, files, summary);
|
|
194
|
+
else if (d?.kind === 'url' && f.severity !== 'info')
|
|
195
|
+
ok = fixUrl(root, map, f, files, summary);
|
|
196
|
+
else if (d?.kind === 'unusedModel')
|
|
197
|
+
ok = fixUnusedModel(root, map, f, files, deletes, summary, notes);
|
|
198
|
+
if (ok)
|
|
199
|
+
fixed++;
|
|
200
|
+
else if (f.severity !== 'info')
|
|
201
|
+
manual.push(f);
|
|
202
|
+
}
|
|
203
|
+
const warnings = [];
|
|
204
|
+
if (fixed && report.findings.some((f) => f.data?.kind === 'missingKeys'))
|
|
205
|
+
warnings.push('The added locale keys hold the default-language text; translate them (doctor lists them as untranslated).');
|
|
206
|
+
warnings.push(...notes);
|
|
207
|
+
if (manual.length) {
|
|
208
|
+
warnings.push(`Still needs you (${manual.length}):\n${manual.map((m) => ` ${m.file ? `${m.file}${m.line ? `:${m.line}` : ''} ` : ''}${m.message}${m.fix ? `\n → ${m.fix}` : ''}`).join('\n')}`);
|
|
209
|
+
}
|
|
210
|
+
if (!summary.length)
|
|
211
|
+
summary.push(report.findings.length ? 'Nothing here can be fixed automatically.' : 'No problems found.');
|
|
212
|
+
const changeSet = buildChangeSet(root, 'doctor fix', { files, deletes, summary, warnings, generated: [] });
|
|
213
|
+
return { changeSet, fixed, manual };
|
|
214
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// explain: prints how the project is put together, from the project map.
|
|
2
|
+
const pad = (s, n) => (s.length >= n ? s + ' ' : s + ' '.repeat(n - s.length));
|
|
3
|
+
export function renderExplain(map, adapter, color) {
|
|
4
|
+
const b = (s) => (color ? `\x1b[1m${s}\x1b[0m` : s);
|
|
5
|
+
const dim = (s) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
6
|
+
const names = { nextjs: 'Next.js', flutter: 'Flutter', generic: 'Generic' };
|
|
7
|
+
const out = [];
|
|
8
|
+
out.push(b(`${names[map.framework] ?? map.framework} project`) + dim(` (adapter ${map.adapter})`));
|
|
9
|
+
out.push(dim(`Supported setup: ${adapter.supportedSetup}`));
|
|
10
|
+
const conv = Object.entries(map.conventions).filter(([, v]) => v !== '');
|
|
11
|
+
if (conv.length) {
|
|
12
|
+
out.push('', b('Conventions'));
|
|
13
|
+
const w = Math.max(...conv.map(([k]) => k.length)) + 2;
|
|
14
|
+
for (const [k, v] of conv)
|
|
15
|
+
out.push(` ${pad(k, w)}${v}`);
|
|
16
|
+
}
|
|
17
|
+
out.push('', b(`Routes (${map.routes.length})`));
|
|
18
|
+
if (!map.routes.length)
|
|
19
|
+
out.push(dim(' none found'));
|
|
20
|
+
const rw = Math.max(0, ...map.routes.map((r) => r.path.length)) + 2;
|
|
21
|
+
for (const r of map.routes)
|
|
22
|
+
out.push(` ${pad(r.path, rw)}${dim(r.file)}`);
|
|
23
|
+
out.push('', b(`Endpoints (${map.endpoints.length})`));
|
|
24
|
+
if (!map.endpoints.length)
|
|
25
|
+
out.push(dim(' none found'));
|
|
26
|
+
const pw = Math.max(0, ...map.endpoints.map((e) => e.path.length)) + 2;
|
|
27
|
+
const nw = Math.max(0, ...map.endpoints.map((e) => e.name.length + (e.response ? e.response.length + 3 : 0))) + 2;
|
|
28
|
+
for (const e of map.endpoints) {
|
|
29
|
+
const used = e.calledFrom.length ? ` used in ${e.calledFrom.join(', ')}` : ' not called anywhere yet';
|
|
30
|
+
out.push(` ${pad(e.method, 7)}${pad(e.path, pw)}${pad(e.name + (e.response ? ` → ${e.response}` : ''), nw)}${dim(e.file + used)}`);
|
|
31
|
+
}
|
|
32
|
+
out.push('', b(`Models (${map.models.length})`));
|
|
33
|
+
if (!map.models.length)
|
|
34
|
+
out.push(dim(' none found'));
|
|
35
|
+
const mw = Math.max(0, ...map.models.map((m) => m.name.length)) + 2;
|
|
36
|
+
for (const m of map.models) {
|
|
37
|
+
const fields = m.fields.map((f) => `${f.name}${f.nullable ? '?' : ''}`).join(', ');
|
|
38
|
+
out.push(` ${pad(m.name, mw)}${fields.length > 70 ? fields.slice(0, 67) + '...' : fields} ${dim(m.file)}`);
|
|
39
|
+
}
|
|
40
|
+
out.push('', b('Locales'));
|
|
41
|
+
if (map.locales.languages.length) {
|
|
42
|
+
out.push(` ${map.locales.languages.map((l) => (l === map.locales.default ? `${l} (default)` : l)).join(', ')}, ${map.locales.keyCount} keys`);
|
|
43
|
+
out.push(dim(` ${map.locales.files.join(', ')}`));
|
|
44
|
+
}
|
|
45
|
+
else
|
|
46
|
+
out.push(dim(' none found'));
|
|
47
|
+
out.push('', b('Base URLs'));
|
|
48
|
+
if (map.baseUrls.length)
|
|
49
|
+
for (const u of map.baseUrls)
|
|
50
|
+
out.push(` ${u.name} = ${u.value} ${dim(u.file)}`);
|
|
51
|
+
else
|
|
52
|
+
out.push(dim(' none found in .env files'));
|
|
53
|
+
const ext = Object.entries(map.stats.byExtension).slice(0, 6).map(([e, n]) => `${e} ${n}`).join(', ');
|
|
54
|
+
out.push('', b('Files') + ` ${map.stats.files} (${ext})`);
|
|
55
|
+
if (map.notes.length) {
|
|
56
|
+
out.push('', b('Notes'));
|
|
57
|
+
for (const n of map.notes)
|
|
58
|
+
out.push(` - ${n}`);
|
|
59
|
+
}
|
|
60
|
+
return out.join('\n');
|
|
61
|
+
}
|