@shortlink-org/portolan 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/app/toast.tsx +11 -7
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +49 -0
- package/src/catalog.ts +6 -3124
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/derive.ts +1 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +101 -0
- package/src/merge.ts +65 -1
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lstatSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { basename, join, posix, relative, resolve, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
const SKIP = new Set([".git", ".portolan", "build", "dist", "node_modules", "target", "vendor"]);
|
|
11
|
+
const MAX_FILES = 12_000;
|
|
12
|
+
const MAX_COMPONENTS = 100;
|
|
13
|
+
const MAX_SOURCE_BYTES = 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
function safeRoot(workspace, input) {
|
|
16
|
+
if (typeof input !== "string" || !input.trim()) throw new Error("Project path is required.");
|
|
17
|
+
const clean = input.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
18
|
+
if (clean.startsWith("/") || clean.split("/").includes("..")) throw new Error("Project path must stay inside this repository.");
|
|
19
|
+
const workspaceReal = realpathSync(workspace);
|
|
20
|
+
const targetReal = realpathSync(resolve(workspaceReal, clean));
|
|
21
|
+
if (targetReal !== workspaceReal && !targetReal.startsWith(`${workspaceReal}${sep}`)) {
|
|
22
|
+
throw new Error("Project path resolves outside this repository.");
|
|
23
|
+
}
|
|
24
|
+
const root = relative(workspaceReal, targetReal).replaceAll(sep, "/") || ".";
|
|
25
|
+
return { root, absolute: targetReal };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A UTF-8 source file inside the served workspace, never outside it. */
|
|
29
|
+
export function readLocalSource(workspace, input) {
|
|
30
|
+
if (typeof input !== "string" || !input.trim()) throw new Error("Source path is required.");
|
|
31
|
+
const clean = input.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
|
32
|
+
if (clean.includes("\0") || clean.startsWith("/") || clean.split("/").includes("..")) {
|
|
33
|
+
throw new Error("Source path must stay inside this repository.");
|
|
34
|
+
}
|
|
35
|
+
const workspaceReal = realpathSync(workspace);
|
|
36
|
+
const targetReal = realpathSync(resolve(workspaceReal, clean));
|
|
37
|
+
if (targetReal !== workspaceReal && !targetReal.startsWith(`${workspaceReal}${sep}`)) {
|
|
38
|
+
throw new Error("Source path resolves outside this repository.");
|
|
39
|
+
}
|
|
40
|
+
const stat = statSync(targetReal);
|
|
41
|
+
if (!stat.isFile()) throw new Error("Source path is not a file.");
|
|
42
|
+
if (stat.size > MAX_SOURCE_BYTES) throw new Error("Source file is larger than the 1 MB preview limit.");
|
|
43
|
+
const bytes = readFileSync(targetReal);
|
|
44
|
+
if (bytes.includes(0)) throw new Error("Source file is binary.");
|
|
45
|
+
let content;
|
|
46
|
+
try {
|
|
47
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error("Source file is not UTF-8 text.");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
path: relative(workspaceReal, targetReal).replaceAll(sep, "/"),
|
|
53
|
+
content,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function walk(root) {
|
|
58
|
+
const files = new Set();
|
|
59
|
+
const pending = [{ absolute: root, relative: "" }];
|
|
60
|
+
while (pending.length && files.size < MAX_FILES) {
|
|
61
|
+
const current = pending.pop();
|
|
62
|
+
for (const entry of readdirSync(current.absolute, { withFileTypes: true })) {
|
|
63
|
+
if (SKIP.has(entry.name) || (entry.isDirectory() && entry.name.startsWith("."))) continue;
|
|
64
|
+
const name = current.relative ? `${current.relative}/${entry.name}` : entry.name;
|
|
65
|
+
const absolute = join(current.absolute, entry.name);
|
|
66
|
+
const stat = lstatSync(absolute);
|
|
67
|
+
if (stat.isSymbolicLink()) continue;
|
|
68
|
+
if (stat.isDirectory()) pending.push({ absolute, relative: name });
|
|
69
|
+
else if (stat.isFile()) files.add(name);
|
|
70
|
+
if (files.size >= MAX_FILES) break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function slug(value) {
|
|
77
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function matches(files, pattern) {
|
|
81
|
+
return [...files].filter((name) => pattern.test(name)).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compactDirectories(paths) {
|
|
85
|
+
const directories = [...new Set(paths.map((name) => posix.dirname(name)))].sort((a, b) => a.length - b.length);
|
|
86
|
+
return directories.filter((dir, index) => !directories.some((parent, other) => other < index && (dir === parent || dir.startsWith(`${parent}/`))));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const COMPONENT_MARKERS = new Map([
|
|
90
|
+
["go.mod", "Go"],
|
|
91
|
+
["package.json", "Node.js"],
|
|
92
|
+
["Cargo.toml", "Rust"],
|
|
93
|
+
["pom.xml", "Java"],
|
|
94
|
+
["build.gradle", "Java"],
|
|
95
|
+
["build.gradle.kts", "Kotlin"],
|
|
96
|
+
["manage.py", "Django"],
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
function componentCandidates(files) {
|
|
100
|
+
const roots = new Map();
|
|
101
|
+
for (const name of files) {
|
|
102
|
+
const marker = posix.basename(name);
|
|
103
|
+
const technology = COMPONENT_MARKERS.get(marker);
|
|
104
|
+
if (!technology) continue;
|
|
105
|
+
const path = posix.dirname(name);
|
|
106
|
+
const key = path === "." ? "." : path;
|
|
107
|
+
if (key.split("/").some((segment) => ["fixture", "fixtures", "test", "tests", "testdata"].includes(segment.toLowerCase()))) continue;
|
|
108
|
+
const found = roots.get(key) ?? { path: key, markers: new Set(), technologies: new Set() };
|
|
109
|
+
found.markers.add(marker);
|
|
110
|
+
found.technologies.add(technology);
|
|
111
|
+
roots.set(key, found);
|
|
112
|
+
}
|
|
113
|
+
return [...roots.values()]
|
|
114
|
+
.sort((a, b) => a.path === "." ? -1 : b.path === "." ? 1 : a.path.localeCompare(b.path))
|
|
115
|
+
.slice(0, MAX_COMPONENTS)
|
|
116
|
+
.map((candidate) => {
|
|
117
|
+
const base = candidate.path === "." ? "repository root" : posix.basename(candidate.path);
|
|
118
|
+
return {
|
|
119
|
+
path: candidate.path,
|
|
120
|
+
name: base.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ") || base,
|
|
121
|
+
markers: [...candidate.markers].sort(),
|
|
122
|
+
technologies: [...candidate.technologies].sort(),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function titleFromSlug(value) {
|
|
128
|
+
const acronyms = new Set(["api", "cli", "grpc", "http"]);
|
|
129
|
+
return value.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => acronyms.has(part.toLowerCase()) ? part.toUpperCase() : part[0]?.toUpperCase() + part.slice(1)).join(" ") || value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// cmd/* is a convention, not an architectural boundary by itself: many Go
|
|
133
|
+
// repositories keep migrations and administrative tools there. A runnable is
|
|
134
|
+
// promoted to a deployable only when build/deployment evidence independently
|
|
135
|
+
// names the same entrypoint.
|
|
136
|
+
function goDeployables(root, files) {
|
|
137
|
+
if (!files.has("go.mod")) return [];
|
|
138
|
+
const mains = new Map();
|
|
139
|
+
for (const name of matches(files, /^cmd\/[^/]+\/[^/]+\.go$/)) {
|
|
140
|
+
let source = "";
|
|
141
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
142
|
+
if (!/^\s*package\s+main\b/m.test(source) || !/\bfunc\s+main\s*\(/.test(source)) continue;
|
|
143
|
+
const component = name.split("/")[1];
|
|
144
|
+
const found = mains.get(component) ?? [];
|
|
145
|
+
found.push(name);
|
|
146
|
+
mains.set(component, found);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const buildFiles = matches(files, /(^|\/)(?:Dockerfile|[^/]+\.Dockerfile|Makefile|[^/]*compose[^/]*\.ya?ml|[^/]+\.ya?ml)$/i);
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const [component, entrypoints] of [...mains].sort(([a], [b]) => a.localeCompare(b))) {
|
|
152
|
+
const escaped = component.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
153
|
+
const target = new RegExp(`(?:^|[\\s"'=])(?:\\./)?cmd/${escaped}(?=$|[\\s"'])`, "m");
|
|
154
|
+
const corroboration = [];
|
|
155
|
+
for (const name of buildFiles) {
|
|
156
|
+
let source = "";
|
|
157
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
158
|
+
if (target.test(source)) corroboration.push(name);
|
|
159
|
+
}
|
|
160
|
+
out.push({
|
|
161
|
+
slug: slug(component),
|
|
162
|
+
name: titleFromSlug(component),
|
|
163
|
+
path: `cmd/${component}`,
|
|
164
|
+
kind: "service",
|
|
165
|
+
confidence: corroboration.length ? "high" : "medium",
|
|
166
|
+
evidence: [...entrypoints, ...corroboration],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true) {
|
|
173
|
+
if (!candidates.length) return null;
|
|
174
|
+
return {
|
|
175
|
+
plugin,
|
|
176
|
+
confidence: ambiguous && candidates.length > 1 ? "medium" : "high",
|
|
177
|
+
evidence: label,
|
|
178
|
+
candidates,
|
|
179
|
+
options,
|
|
180
|
+
selected,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function compatibleAdrs(root, candidates) {
|
|
185
|
+
return candidates.filter((name) => {
|
|
186
|
+
let source = "";
|
|
187
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { return false; }
|
|
188
|
+
return /^#\s+[^\n]+\.\d{4}\s+[—-]/m.test(source) && /^-\s+\*\*Status:\*\*/mi.test(source) && /^-\s+\*\*Date:\*\*/mi.test(source);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function goDomainEvidence(root, files) {
|
|
193
|
+
const layout = /^internal\/(?:domain\/([^/]+)|([^/]+)\/domain)\/[^/]+\.go$/i;
|
|
194
|
+
const candidates = matches(files, layout);
|
|
195
|
+
for (const name of candidates) {
|
|
196
|
+
const match = layout.exec(name);
|
|
197
|
+
if (!match) continue;
|
|
198
|
+
const aggregate = match[1] ?? match[2];
|
|
199
|
+
const packageName = aggregate.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
200
|
+
const rootName = aggregate
|
|
201
|
+
.split(/[^a-zA-Z0-9]+|_/)
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
204
|
+
.join("");
|
|
205
|
+
let source = "";
|
|
206
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
207
|
+
const packagePattern = new RegExp(`\\bpackage\\s+(?:${packageName}|domain)\\b`);
|
|
208
|
+
const rootPattern = new RegExp(`\\btype\\s+${rootName}\\s+struct\\s*\\{`);
|
|
209
|
+
if (packagePattern.test(source) && rootPattern.test(source)) return name;
|
|
210
|
+
}
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function laidOutDomainEvidence(root, files, language) {
|
|
215
|
+
const extension = language === "typescript" ? "ts" : language === "rust" ? "rs" : "java";
|
|
216
|
+
const prefix = language === "java" ? /(?:^|\/)domain\/([^/]+)\/[^/]+\.java$/i : /^src\/domain\/([^/]+)\/[^/]+\.(?:ts|rs)$/i;
|
|
217
|
+
for (const name of matches(files, prefix)) {
|
|
218
|
+
if (!name.endsWith(`.${extension}`)) continue;
|
|
219
|
+
const match = prefix.exec(name);
|
|
220
|
+
if (!match) continue;
|
|
221
|
+
const rootName = match[1].split(/[^a-zA-Z0-9]+|_/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join("");
|
|
222
|
+
let source = "";
|
|
223
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
224
|
+
const claim = language === "typescript"
|
|
225
|
+
? new RegExp(`\\b(?:export\\s+)?class\\s+${rootName}\\b`)
|
|
226
|
+
: language === "rust"
|
|
227
|
+
? new RegExp(`\\bpub\\s+struct\\s+${rootName}\\b`)
|
|
228
|
+
: /@AggregateRoot\b/.test(source) || new RegExp(`\\bclass\\s+${rootName}\\b`).test(source);
|
|
229
|
+
if (claim instanceof RegExp ? claim.test(source) : claim) return name;
|
|
230
|
+
}
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function goHTTPClientEvidence(root, files) {
|
|
235
|
+
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
236
|
+
let source = "";
|
|
237
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
238
|
+
if (
|
|
239
|
+
/\bhttp\.(?:NewRequest(?:WithContext)?|Get|Post|PostForm|Head)\s*\(/.test(source)
|
|
240
|
+
|| /ClientWithResponses(?:Interface)?\b/.test(source)
|
|
241
|
+
|| /github\.com\/hooklift\/gowsdl\/soap/.test(source)
|
|
242
|
+
) return name;
|
|
243
|
+
}
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function goSOAPClientEvidence(root, files) {
|
|
248
|
+
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
249
|
+
let source = "";
|
|
250
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
251
|
+
if (
|
|
252
|
+
/github\.com\/hooklift\/gowsdl\/soap/.test(source)
|
|
253
|
+
|| /\bCallContext\s*\([^,]+,\s*[^,]*(?:soap)?action/i.test(source)
|
|
254
|
+
|| /(?:Header\.)?(?:Add|Set)\s*\(\s*["'](?:SOAPAction|Content-Type)["']/i.test(source)
|
|
255
|
+
) return name;
|
|
256
|
+
}
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function goRedisEvidence(root, files) {
|
|
261
|
+
const redisImport = /github\.com\/(?:redis\/go-redis(?:\/v\d+)?|go-redis\/redis(?:\/v\d+)?|redis\/rueidis|gomodule\/redigo\/redis)(?=\")/g;
|
|
262
|
+
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
263
|
+
let source = "";
|
|
264
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
265
|
+
const imports = [...source.matchAll(redisImport)].map((match) => match[0]);
|
|
266
|
+
if (!imports.length) continue;
|
|
267
|
+
|
|
268
|
+
const aliases = new Set();
|
|
269
|
+
for (const importPath of imports) {
|
|
270
|
+
const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
271
|
+
const declaration = new RegExp(`(?:^|\\n)\\s*(?:import\\s+)?(?:([A-Za-z_][A-Za-z0-9_]*)\\s+)?\"${escaped}\"`, "m").exec(source);
|
|
272
|
+
const alias = declaration?.[1];
|
|
273
|
+
if (alias && alias !== "_" && alias !== ".") aliases.add(alias);
|
|
274
|
+
else aliases.add(importPath.includes("rueidis") ? "rueidis" : "redis");
|
|
275
|
+
}
|
|
276
|
+
if ([...aliases].some((alias) => new RegExp(`\\b${alias}\\.(?:NewClient|NewClusterClient|NewFailoverClient|NewFailoverClusterClient|Dial|DialURL)\\s*\\(`).test(source))) {
|
|
277
|
+
return name;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return "";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function detectionsFor(root, files) {
|
|
284
|
+
let goMod = "";
|
|
285
|
+
if (files.has("go.mod")) {
|
|
286
|
+
try { goMod = readFileSync(join(root, "go.mod"), "utf8"); } catch {}
|
|
287
|
+
}
|
|
288
|
+
const openapi = matches(files, /(^|\/)(openapi|swagger)[^/]*\.(ya?ml|json)$/i);
|
|
289
|
+
const wsdls = matches(files, /\.wsdl$/i);
|
|
290
|
+
const asyncapi = matches(files, /(^|\/)asyncapi[^/]*\.(ya?ml|json)$/i);
|
|
291
|
+
const graphql = matches(files, /\.graphqls?$/i);
|
|
292
|
+
const protos = matches(files, /\.proto$/i);
|
|
293
|
+
const sql = matches(files, /(^|\/)(migrations?|repository)(\/|.*\/).*\.sql$/i);
|
|
294
|
+
const adrs = matches(files, /(^|\/)(docs\/adr|adr)\/.*\.md$/i);
|
|
295
|
+
const supportedAdrs = compatibleAdrs(root, adrs);
|
|
296
|
+
const glossaries = matches(files, /(^|\/)glossary\.md$/i);
|
|
297
|
+
// The app module is the one file a Celery project always has; the tasks
|
|
298
|
+
// and the calls that enqueue them are found from there.
|
|
299
|
+
const celery = matches(files, /(^|\/)celery\.py$/);
|
|
300
|
+
const sqlRoots = [...new Set(sql.map((name) => {
|
|
301
|
+
const segments = name.split("/");
|
|
302
|
+
const repository = segments.findIndex((part) => /^(repository|repositories)$/i.test(part));
|
|
303
|
+
return repository >= 0 ? segments.slice(0, repository + 1).join("/") : "";
|
|
304
|
+
}).filter(Boolean))];
|
|
305
|
+
const featureSql = sqlRoots.some((name) => /^internal\/[^/]+\/infrastructure\/repository$/i.test(name));
|
|
306
|
+
const sqlRoot = sqlRoots.length === 1 && !featureSql ? sqlRoots[0] : "";
|
|
307
|
+
const graphqlDirs = compactDirectories(graphql);
|
|
308
|
+
const protoDirs = compactDirectories(protos);
|
|
309
|
+
const projectMarkers = ["go.mod", "package.json", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "manage.py", "Dockerfile", "README.md"].filter((name) => files.has(name));
|
|
310
|
+
const projectEvidence = projectMarkers.length ? projectMarkers : [[...files].sort()[0]].filter(Boolean);
|
|
311
|
+
const goDomain = files.has("go.mod") ? goDomainEvidence(root, files) : "";
|
|
312
|
+
const goHTTPClient = files.has("go.mod") ? goHTTPClientEvidence(root, files) : "";
|
|
313
|
+
const goSOAPClient = files.has("go.mod") ? goSOAPClientEvidence(root, files) : "";
|
|
314
|
+
const goRedis = files.has("go.mod") ? goRedisEvidence(root, files) : "";
|
|
315
|
+
const tsDomain = files.has("package.json") ? laidOutDomainEvidence(root, files, "typescript") : "";
|
|
316
|
+
const rustDomain = files.has("Cargo.toml") ? laidOutDomainEvidence(root, files, "rust") : "";
|
|
317
|
+
const javaDomain = ["pom.xml", "build.gradle", "build.gradle.kts"].some((name) => files.has(name)) ? laidOutDomainEvidence(root, files, "java") : "";
|
|
318
|
+
return [
|
|
319
|
+
detected("project", projectEvidence, {}, projectEvidence.join(", ")),
|
|
320
|
+
detected("go-domain", goDomain ? [goDomain] : [], {}, goDomain),
|
|
321
|
+
detected("ts-domain", tsDomain ? [tsDomain] : [], {}, tsDomain),
|
|
322
|
+
detected("rust-domain", rustDomain ? [rustDomain] : [], {}, rustDomain),
|
|
323
|
+
detected("java-domain", javaDomain ? [javaDomain] : [], {}, javaDomain),
|
|
324
|
+
detected("django-domain", files.has("manage.py") && matches(files, /(^|\/)models(?:\/[^/]+)?\.py$/i).length ? ["manage.py"] : []),
|
|
325
|
+
detected("celery", celery, {}, celery[0]),
|
|
326
|
+
detected("openapi", openapi, openapi[0] ? { spec: openapi[0] } : {}, openapi[0], true),
|
|
327
|
+
detected(
|
|
328
|
+
"wsdl",
|
|
329
|
+
wsdls,
|
|
330
|
+
{
|
|
331
|
+
...(wsdls.length === 1 ? { spec: wsdls[0] } : {}),
|
|
332
|
+
...(goSOAPClient ? { mode: "external" } : {}),
|
|
333
|
+
},
|
|
334
|
+
wsdls.length === 1 ? wsdls[0] : `${wsdls.length} WSDL documents`,
|
|
335
|
+
true,
|
|
336
|
+
),
|
|
337
|
+
detected("http-clients", goHTTPClient ? [goHTTPClient] : [], {}, goHTTPClient),
|
|
338
|
+
detected("redis", goRedis ? [goRedis] : [], {}, goRedis),
|
|
339
|
+
detected("river", goMod.includes("github.com/riverqueue/river") ? ["go.mod"] : [], {}, "go.mod · github.com/riverqueue/river"),
|
|
340
|
+
detected("watermill", goMod.includes("github.com/ThreeDotsLabs/watermill") ? ["go.mod"] : [], {}, "go.mod · github.com/ThreeDotsLabs/watermill"),
|
|
341
|
+
detected("asyncapi", asyncapi, asyncapi[0] ? { spec: asyncapi[0] } : {}, asyncapi[0], true),
|
|
342
|
+
detected("graphql", graphql, graphqlDirs[0] ? { schema: graphqlDirs.length === 1 ? graphqlDirs[0] : graphql[0] } : {}, graphqlDirs.length === 1 ? graphqlDirs[0] : graphql[0]),
|
|
343
|
+
detected("proto", protos, protoDirs.length ? { paths: protoDirs } : {}, protoDirs.join(", ")),
|
|
344
|
+
detected("sql", sql, sqlRoot ? { repositories: sqlRoot } : {}, sqlRoot || (sqlRoots.length > 1 ? `${sqlRoots.length} repository packages` : sql[0])),
|
|
345
|
+
detected(
|
|
346
|
+
"adr",
|
|
347
|
+
adrs,
|
|
348
|
+
supportedAdrs[0] ? { files: [`${posix.dirname(supportedAdrs[0])}/*.md`] } : {},
|
|
349
|
+
supportedAdrs[0] ? `${posix.dirname(supportedAdrs[0])}/*.md` : `${posix.dirname(adrs[0] ?? "docs/adr/x.md")}/*.md (format not recognized)`,
|
|
350
|
+
!supportedAdrs.length,
|
|
351
|
+
supportedAdrs.length > 0,
|
|
352
|
+
),
|
|
353
|
+
detected("glossary", glossaries, glossaries.length ? { files: glossaries } : {}, glossaries.join(", ")),
|
|
354
|
+
].filter(Boolean);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function discoverProject(workspace, input) {
|
|
358
|
+
const { root, absolute } = safeRoot(workspace, input);
|
|
359
|
+
const files = walk(absolute);
|
|
360
|
+
const detections = detectionsFor(absolute, files);
|
|
361
|
+
const components = componentCandidates(files);
|
|
362
|
+
const deployables = goDeployables(absolute, files);
|
|
363
|
+
return {
|
|
364
|
+
root,
|
|
365
|
+
filesScanned: files.size,
|
|
366
|
+
truncated: files.size >= MAX_FILES,
|
|
367
|
+
components,
|
|
368
|
+
componentsTruncated: components.length >= MAX_COMPONENTS,
|
|
369
|
+
deployables,
|
|
370
|
+
defaults: projectDefaults(basename(absolute)),
|
|
371
|
+
detections,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function projectDefaults(value) {
|
|
376
|
+
const id = slug(value) || "service";
|
|
377
|
+
return { id, name: id.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "), group: id, component: id, context: id, service: id };
|
|
378
|
+
}
|
package/scripts/manifest.mjs
CHANGED
|
@@ -25,7 +25,19 @@ const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.
|
|
|
25
25
|
* that has not run `npm run schema` yet should still be able to generate.
|
|
26
26
|
*/
|
|
27
27
|
export function loadManifest(path = "portolan.json") {
|
|
28
|
-
|
|
28
|
+
return parseManifest(readFileSync(path, "utf8"), path);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parses and validates manifest text. This is for manifests that do not live
|
|
33
|
+
* in the working tree, such as the copy read from another git revision.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @param {string} path
|
|
37
|
+
* @returns {{manifest: object, problems: string[]}}
|
|
38
|
+
*/
|
|
39
|
+
export function parseManifest(text, path = "portolan.json") {
|
|
40
|
+
const manifest = JSON.parse(text);
|
|
29
41
|
|
|
30
42
|
let schema;
|
|
31
43
|
try {
|
|
@@ -42,6 +54,35 @@ export function loadManifest(path = "portolan.json") {
|
|
|
42
54
|
return { manifest, problems: explain(validate.errors ?? [], manifest, schema, path) };
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The runtime entry point: a caller either gets a schema-valid manifest or a
|
|
59
|
+
* single actionable error. Code that wants to present every problem itself
|
|
60
|
+
* can keep using loadManifest/parseManifest.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} path
|
|
63
|
+
* @returns {object}
|
|
64
|
+
*/
|
|
65
|
+
export function readManifest(path = "portolan.json") {
|
|
66
|
+
return requireValidManifest(loadManifest(path));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {string} text @param {string} [path] */
|
|
70
|
+
export function readManifestText(text, path = "portolan.json") {
|
|
71
|
+
return requireValidManifest(parseManifest(text, path));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {{manifest: object, problems: string[]}} loaded */
|
|
75
|
+
export function requireValidManifest(loaded) {
|
|
76
|
+
if (loaded.problems.length > 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`portolan.json does not match schema/portolan.schema.json:\n${loaded.problems
|
|
79
|
+
.map((problem) => ` - ${problem}`)
|
|
80
|
+
.join("\n")}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return loaded.manifest;
|
|
84
|
+
}
|
|
85
|
+
|
|
45
86
|
/**
|
|
46
87
|
* Turns ajv's errors into lines somebody can act on.
|
|
47
88
|
*
|
|
@@ -8,7 +8,14 @@ import { join } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, it } from "vitest";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
loadManifest,
|
|
13
|
+
parseManifest,
|
|
14
|
+
readManifest,
|
|
15
|
+
readManifestText,
|
|
16
|
+
requireValidManifest,
|
|
17
|
+
stepKeys,
|
|
18
|
+
} from "./manifest.mjs";
|
|
12
19
|
|
|
13
20
|
const dir = mkdtempSync(join(tmpdir(), "portolan-manifest-"));
|
|
14
21
|
|
|
@@ -108,6 +115,22 @@ describe("the manifest schema", () => {
|
|
|
108
115
|
expect(problems).toHaveLength(1);
|
|
109
116
|
expect(problems[0]).toContain('extract/0: "out" is missing');
|
|
110
117
|
});
|
|
118
|
+
|
|
119
|
+
it("parses manifest text through the same validator", () => {
|
|
120
|
+
expect(parseManifest(JSON.stringify(good), "from-git:portolan.json").problems).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("gives runtime callers only a valid manifest", () => {
|
|
124
|
+
const path = join(dir, "good.json");
|
|
125
|
+
writeFileSync(path, JSON.stringify(good));
|
|
126
|
+
expect(readManifest(path)).toEqual(good);
|
|
127
|
+
expect(readManifestText(JSON.stringify(good), "memory:portolan.json")).toEqual(good);
|
|
128
|
+
|
|
129
|
+
expect(() => requireValidManifest({
|
|
130
|
+
manifest: {},
|
|
131
|
+
problems: ['portolan.json: "sources" is missing'],
|
|
132
|
+
})).toThrow('portolan.json: "sources" is missing');
|
|
133
|
+
});
|
|
111
134
|
});
|
|
112
135
|
|
|
113
136
|
describe("stepKeys", () => {
|
package/scripts/plugin-host.mjs
CHANGED
|
@@ -41,7 +41,7 @@ const HOST_LIMITS = Object.freeze({
|
|
|
41
41
|
* gets no filesystem at all, which is what a generator and a describe
|
|
42
42
|
* request should get. A process plugin is unaffected: it already has the
|
|
43
43
|
* whole machine.
|
|
44
|
-
* @returns {Promise<{files: {name: string, contents: string}[], describe?: object}>}
|
|
44
|
+
* @returns {Promise<{files: {name: string, contents: string, encoding?: "base64"}[], describe?: object}>}
|
|
45
45
|
*/
|
|
46
46
|
export async function runPlugin(plugin, request, requestedLimits = {}, access = {}) {
|
|
47
47
|
const limits = lowerLimits(requestedLimits);
|
|
@@ -158,7 +158,7 @@ export function validateResponse(name, response) {
|
|
|
158
158
|
throw new Error(`plugin ${name}: files[${index}] is not an object`);
|
|
159
159
|
}
|
|
160
160
|
const keys = Object.keys(file);
|
|
161
|
-
if (keys.some((key) => !["name", "contents"].includes(key))) {
|
|
161
|
+
if (keys.some((key) => !["name", "contents", "encoding"].includes(key))) {
|
|
162
162
|
throw new Error(`plugin ${name}: files[${index}] has unsupported properties`);
|
|
163
163
|
}
|
|
164
164
|
if (typeof file.name !== "string" || !safeFileName(file.name)) {
|
|
@@ -167,17 +167,28 @@ export function validateResponse(name, response) {
|
|
|
167
167
|
if (typeof file.contents !== "string") {
|
|
168
168
|
throw new Error(`plugin ${name}: files[${index}].contents is not a string`);
|
|
169
169
|
}
|
|
170
|
+
if (file.encoding !== undefined && file.encoding !== "base64") {
|
|
171
|
+
throw new Error(`plugin ${name}: files[${index}].encoding is not supported`);
|
|
172
|
+
}
|
|
173
|
+
if (file.encoding === "base64" && !validBase64(file.contents)) {
|
|
174
|
+
throw new Error(`plugin ${name}: files[${index}].contents is not valid base64`);
|
|
175
|
+
}
|
|
170
176
|
if (seen.has(file.name)) {
|
|
171
177
|
throw new Error(`plugin ${name}: duplicate output file ${file.name}`);
|
|
172
178
|
}
|
|
173
179
|
seen.add(file.name);
|
|
174
|
-
return { name: file.name, contents: file.contents };
|
|
180
|
+
return { name: file.name, contents: file.contents, ...(file.encoding ? { encoding: file.encoding } : {}) };
|
|
175
181
|
});
|
|
176
182
|
|
|
177
183
|
if (response.describe !== undefined) validateDescriptor(name, response.describe);
|
|
178
184
|
return { files, ...(response.describe === undefined ? {} : { describe: response.describe }) };
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
function validBase64(value) {
|
|
188
|
+
return value.length % 4 === 0
|
|
189
|
+
&& /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
|
|
190
|
+
}
|
|
191
|
+
|
|
181
192
|
function safeFileName(name) {
|
|
182
193
|
if (!name || name.includes("\0") || isAbsolute(name) || /^[A-Za-z]:[\\/]/.test(name)) return false;
|
|
183
194
|
const parts = name.split(/[\\/]/);
|
|
@@ -13,6 +13,14 @@ describe("plugin response validation", () => {
|
|
|
13
13
|
});
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
it("accepts canonical base64 files and rejects malformed binary output", () => {
|
|
17
|
+
expect(validateResponse("fixture", { files: [{ name: "image.png", contents: "iVBORw==", encoding: "base64" }] })).toEqual({
|
|
18
|
+
files: [{ name: "image.png", contents: "iVBORw==", encoding: "base64" }],
|
|
19
|
+
});
|
|
20
|
+
expect(() => validateResponse("fixture", { files: [{ name: "image.png", contents: "not base64", encoding: "base64" }] })).toThrow("not valid base64");
|
|
21
|
+
expect(() => validateResponse("fixture", { files: [{ name: "image.png", contents: "x", encoding: "binary" }] })).toThrow("encoding is not supported");
|
|
22
|
+
});
|
|
23
|
+
|
|
16
24
|
it.each(["../secret", "/tmp/result", "C:\\tmp\\result", "a/../../secret", "./result"])(
|
|
17
25
|
"rejects unsafe output name %s",
|
|
18
26
|
(name) => {
|
package/scripts/run-builtin.mjs
CHANGED
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
// host (portolan.0008).
|
|
8
8
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { existsSync
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
11
|
import { dirname, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
14
|
+
import { readManifest } from "./manifest.mjs";
|
|
15
|
+
|
|
14
16
|
const installRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
17
|
const workspace = process.cwd();
|
|
16
18
|
const name = process.argv[2];
|
|
17
|
-
const manifest =
|
|
19
|
+
const manifest = readManifest(resolve(installRoot, "portolan.json"));
|
|
18
20
|
const plugin = (manifest.plugins ?? []).find((candidate) => candidate.name === name);
|
|
19
21
|
|
|
20
22
|
if (!plugin?.process) {
|
package/scripts/schema.mjs
CHANGED
|
@@ -23,6 +23,10 @@ import { describePlugin } from "./plugin-host.mjs";
|
|
|
23
23
|
const OUT = "schema/portolan.schema.json";
|
|
24
24
|
|
|
25
25
|
const check = process.argv.includes("--check");
|
|
26
|
+
// Bootstrap exception: this command produces the schema that loadManifest
|
|
27
|
+
// validates against, so requiring the previous schema here would make a new
|
|
28
|
+
// manifest field impossible to introduce. Every consumer of the composed
|
|
29
|
+
// schema goes through scripts/manifest.mjs.
|
|
26
30
|
const manifest = JSON.parse(readFileSync("portolan.json", "utf8"));
|
|
27
31
|
|
|
28
32
|
const described = new Map();
|
|
@@ -207,6 +211,13 @@ function compose() {
|
|
|
207
211
|
minLength: 1,
|
|
208
212
|
description: "Component slug inside the group.",
|
|
209
213
|
},
|
|
214
|
+
components: {
|
|
215
|
+
type: "array",
|
|
216
|
+
minItems: 2,
|
|
217
|
+
uniqueItems: true,
|
|
218
|
+
items: { type: "string", minLength: 1 },
|
|
219
|
+
description: "Deployable component slugs discovered inside one repository-backed project.",
|
|
220
|
+
},
|
|
210
221
|
groupKind: {
|
|
211
222
|
enum: ["bounded-context", "system", "product", "team", "namespace"],
|
|
212
223
|
description: "Semantic role of the project's top-level group.",
|
package/scripts/site-docs.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { cpSync, existsSync, readFileSync, statSync, writeFileSync } from "node:
|
|
|
26
26
|
import { extname, isAbsolute, join, normalize, posix, resolve, sep } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
|
|
29
|
-
import { loadManifest } from "./manifest.mjs";
|
|
29
|
+
import { loadManifest, readManifest } from "./manifest.mjs";
|
|
30
30
|
|
|
31
31
|
const LLMS = "llms.txt";
|
|
32
32
|
const LLMS_FULL = "llms-full.txt";
|
|
@@ -261,7 +261,7 @@ export function siteDocsPlugin(workspace = process.cwd()) {
|
|
|
261
261
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
262
262
|
let manifest;
|
|
263
263
|
try {
|
|
264
|
-
manifest =
|
|
264
|
+
manifest = readManifest(join(workspace, "portolan.json"));
|
|
265
265
|
} catch {
|
|
266
266
|
return next();
|
|
267
267
|
}
|
package/src/app/CatalogApp.tsx
CHANGED
|
@@ -98,7 +98,13 @@ function ChatLoading() {
|
|
|
98
98
|
* way out the router already says the next address, and the page leaving
|
|
99
99
|
* must keep drawing the one it was.
|
|
100
100
|
*/
|
|
101
|
-
function AppRoutes({
|
|
101
|
+
function AppRoutes({
|
|
102
|
+
location,
|
|
103
|
+
onOpenSearch,
|
|
104
|
+
}: {
|
|
105
|
+
location: Location;
|
|
106
|
+
onOpenSearch: () => void;
|
|
107
|
+
}) {
|
|
102
108
|
return (
|
|
103
109
|
<Routes location={location}>
|
|
104
110
|
<Route path="/" element={<Overview />} />
|
|
@@ -200,7 +206,10 @@ function AppRoutes({ location }: { location: Location }) {
|
|
|
200
206
|
}
|
|
201
207
|
/>
|
|
202
208
|
<Route path="/index.html" element={<Navigate to="/" replace />} />
|
|
203
|
-
<Route
|
|
209
|
+
<Route
|
|
210
|
+
path="*"
|
|
211
|
+
element={<NotFoundPage onOpenSearch={onOpenSearch} />}
|
|
212
|
+
/>
|
|
204
213
|
</Routes>
|
|
205
214
|
);
|
|
206
215
|
}
|
|
@@ -346,15 +355,18 @@ function Shell() {
|
|
|
346
355
|
{narrow ? (
|
|
347
356
|
<>
|
|
348
357
|
{/* `key` on the route content is what makes the page transition
|
|
349
|
-
fire: a new pathname is a new element.
|
|
350
|
-
|
|
351
|
-
<AnimatePresence mode="
|
|
358
|
+
fire: a new pathname is a new element. `popLayout` lets the old
|
|
359
|
+
page lift away while the new one rises, without a blank frame. */}
|
|
360
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
352
361
|
<m.main
|
|
353
362
|
key={pathname}
|
|
354
363
|
{...page}
|
|
355
364
|
className="min-h-0 flex-1 overflow-hidden"
|
|
356
365
|
>
|
|
357
|
-
<AppRoutes
|
|
366
|
+
<AppRoutes
|
|
367
|
+
location={location}
|
|
368
|
+
onOpenSearch={() => setPalette(true)}
|
|
369
|
+
/>
|
|
358
370
|
</m.main>
|
|
359
371
|
</AnimatePresence>
|
|
360
372
|
<SidebarDrawer />
|
|
@@ -386,13 +398,16 @@ function Shell() {
|
|
|
386
398
|
<Panel id="main" className="h-full min-w-0" onResize={settle}>
|
|
387
399
|
{/* The detail rail rides along with every page that draws a
|
|
388
400
|
diagram, so a selection made anywhere has somewhere to be read. */}
|
|
389
|
-
<AnimatePresence mode="
|
|
401
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
390
402
|
<m.main
|
|
391
403
|
key={pathname}
|
|
392
404
|
{...page}
|
|
393
405
|
className="h-full overflow-hidden"
|
|
394
406
|
>
|
|
395
|
-
<AppRoutes
|
|
407
|
+
<AppRoutes
|
|
408
|
+
location={location}
|
|
409
|
+
onOpenSearch={() => setPalette(true)}
|
|
410
|
+
/>
|
|
396
411
|
</m.main>
|
|
397
412
|
</AnimatePresence>
|
|
398
413
|
</Panel>
|