@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
package/README.md
CHANGED
|
@@ -201,8 +201,13 @@ for against what is on `PATH`. The Docker image contains all of them.
|
|
|
201
201
|
|
|
202
202
|
While `portolan dev` is running, open **Settings → Delivery presets**. Portolan
|
|
203
203
|
detects GitHub or GitLab from the repository's `origin`, previews the exact CI
|
|
204
|
-
changes, and
|
|
205
|
-
|
|
204
|
+
changes, and generates only the jobs selected there. Architecture checks and
|
|
205
|
+
static catalog publishing are selected by default; pull-request architecture
|
|
206
|
+
diffs and GitHub SARIF annotations are opt-in. Check, review, and publication
|
|
207
|
+
live in separate GitHub workflows, while SARIF augments the review workflow, so
|
|
208
|
+
comment and code-scanning permissions are granted only when those capabilities
|
|
209
|
+
are enabled. Existing unmanaged workflow files are never overwritten, and
|
|
210
|
+
disabling a capability removes only files managed by Portolan.
|
|
206
211
|
|
|
207
212
|
### Run without installing Node or language toolchains
|
|
208
213
|
|
package/cli/init.mjs
CHANGED
|
@@ -75,13 +75,14 @@ export async function init(workspace, { version, ask = defaultAnswers, log = con
|
|
|
75
75
|
writeManifest(manifestPath, manifest);
|
|
76
76
|
log("created portolan.json");
|
|
77
77
|
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
78
|
+
const addScripts = projectPackage ? await ask.scripts(Object.keys(SCRIPTS).filter((name) => !(name in (projectPackage.scripts ?? {})))) : false;
|
|
79
|
+
if (addScripts) {
|
|
80
80
|
const merged = { ...(projectPackage.scripts ?? {}) };
|
|
81
81
|
for (const [name, command] of Object.entries(SCRIPTS)) if (!(name in merged)) merged[name] = command;
|
|
82
82
|
writeFileSync(resolve(workspace, "package.json"), `${JSON.stringify({ ...projectPackage, scripts: merged }, null, 2)}\n`);
|
|
83
83
|
log("added architecture scripts to package.json");
|
|
84
84
|
}
|
|
85
|
+
const scriptsReady = Boolean(projectPackage) && Object.keys(SCRIPTS).every((name) => addScripts || name in (projectPackage.scripts ?? {}));
|
|
85
86
|
|
|
86
87
|
const ignorePath = resolve(workspace, ".gitignore");
|
|
87
88
|
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
|
|
@@ -90,10 +91,17 @@ export async function init(workspace, { version, ask = defaultAnswers, log = con
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
const generate = await ask.generate();
|
|
93
|
-
ask.finish(generate);
|
|
94
|
+
ask.finish(generate, scriptsReady);
|
|
94
95
|
return { manifest, projects, generate };
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
export function nextStep(generate, scriptsReady) {
|
|
99
|
+
const command = scriptsReady
|
|
100
|
+
? generate ? "npm run architecture" : "npm run architecture:gen && npm run architecture"
|
|
101
|
+
: generate ? "npx @shortlink-org/portolan dev" : "npx @shortlink-org/portolan generate && npx @shortlink-org/portolan dev";
|
|
102
|
+
return `${generate ? "Generating; then" : "Next"}: ${command}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
/** Every question answered with its default: the `--yes` and CI path. */
|
|
98
106
|
export const defaultAnswers = {
|
|
99
107
|
roots: (choices) => choices.filter((choice) => choice.selected).map((choice) => choice.path),
|
|
@@ -103,7 +111,7 @@ export const defaultAnswers = {
|
|
|
103
111
|
write: () => true,
|
|
104
112
|
scripts: (names) => names.length > 0,
|
|
105
113
|
generate: () => false,
|
|
106
|
-
finish: (generate) => { if (!generate) console.log(
|
|
114
|
+
finish: (generate, scriptsReady) => { if (!generate) console.log(nextStep(generate, scriptsReady)); },
|
|
107
115
|
};
|
|
108
116
|
|
|
109
117
|
/** The same questions, asked in a terminal with @clack/prompts. */
|
|
@@ -163,8 +171,8 @@ export function promptAnswers(version) {
|
|
|
163
171
|
async generate() {
|
|
164
172
|
return answer(await p.confirm({ message: "Run portolan generate now?", initialValue: true }));
|
|
165
173
|
},
|
|
166
|
-
finish(generate) {
|
|
167
|
-
p.outro(generate
|
|
174
|
+
finish(generate, scriptsReady) {
|
|
175
|
+
p.outro(nextStep(generate, scriptsReady));
|
|
168
176
|
},
|
|
169
177
|
};
|
|
170
178
|
}
|
package/cli/init.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
6
6
|
|
|
7
|
-
import { InitError, defaultAnswers, init } from "./init.mjs";
|
|
7
|
+
import { InitError, defaultAnswers, init, nextStep } from "./init.mjs";
|
|
8
8
|
|
|
9
9
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
10
|
const created = [];
|
|
@@ -36,6 +36,13 @@ const goService = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
describe("init with defaults", () => {
|
|
39
|
+
it("prints runnable next commands with and without package scripts", () => {
|
|
40
|
+
expect(nextStep(false, true)).toBe("Next: npm run architecture:gen && npm run architecture");
|
|
41
|
+
expect(nextStep(true, true)).toBe("Generating; then: npm run architecture");
|
|
42
|
+
expect(nextStep(false, false)).toBe("Next: npx @shortlink-org/portolan generate && npx @shortlink-org/portolan dev");
|
|
43
|
+
expect(nextStep(true, false)).toBe("Generating; then: npx @shortlink-org/portolan dev");
|
|
44
|
+
});
|
|
45
|
+
|
|
39
46
|
it("reads what the repository contains into one project at the root", async () => {
|
|
40
47
|
const dir = workspace({
|
|
41
48
|
"package.json": '{"name":"@acme/order-service","scripts":{"test":"vitest"}}\n',
|
package/cli/portolan.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
|
|
21
21
|
import { InitError, commandWorks, init as runInit, isInteractive, promptAnswers, toolchainFor } from "./init.mjs";
|
|
22
|
+
import { loadManifest, readManifest } from "../scripts/manifest.mjs";
|
|
22
23
|
|
|
23
24
|
const installRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
25
|
const packageJson = JSON.parse(readFileSync(resolve(installRoot, "package.json"), "utf8"));
|
|
@@ -54,7 +55,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
54
55
|
return dev(workspace, parsed);
|
|
55
56
|
case "diff":
|
|
56
57
|
if (!parsed.positionals[0]) fail("diff requires a base branch, tag, or commit");
|
|
57
|
-
return runScript("scripts/diff.mjs", parsed
|
|
58
|
+
return runScript("scripts/diff.mjs", diffArgs(parsed), workspace);
|
|
59
|
+
case "comment":
|
|
60
|
+
if (!parsed.positionals[0]) fail("comment requires a markdown file");
|
|
61
|
+
return runScript("scripts/forge-comment.mjs", [parsed.positionals[0]], workspace);
|
|
58
62
|
case "doctor":
|
|
59
63
|
return doctor(workspace);
|
|
60
64
|
default:
|
|
@@ -68,7 +72,7 @@ function parse(argv) {
|
|
|
68
72
|
const arg = argv[index];
|
|
69
73
|
if (arg === "--help" || arg === "-h") value.help = true;
|
|
70
74
|
else if (arg === "--version" || arg === "-v") value.version = true;
|
|
71
|
-
else if (["--cwd", "--output", "--base", "--host", "--port"].includes(arg)) {
|
|
75
|
+
else if (["--cwd", "--output", "--base", "--host", "--port", "--format", "--site", "--head"].includes(arg)) {
|
|
72
76
|
const next = argv[++index];
|
|
73
77
|
if (!next) fail(`${arg} requires a value`);
|
|
74
78
|
value[arg.slice(2)] = next;
|
|
@@ -80,6 +84,14 @@ function parse(argv) {
|
|
|
80
84
|
return value;
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
function diffArgs(parsed) {
|
|
88
|
+
const args = [parsed.positionals[0]];
|
|
89
|
+
for (const name of ["format", "output", "site", "head"]) {
|
|
90
|
+
if (parsed[name]) args.push(`--${name}`, parsed[name]);
|
|
91
|
+
}
|
|
92
|
+
return args;
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
function help() {
|
|
84
96
|
console.log(`Portolan ${VERSION}
|
|
85
97
|
|
|
@@ -92,6 +104,7 @@ Commands:
|
|
|
92
104
|
check fail when committed generated files are out of date
|
|
93
105
|
build build the static site
|
|
94
106
|
diff BASE describe architecture changes against BASE
|
|
107
|
+
comment FILE post or update a pull-request comment from Markdown
|
|
95
108
|
doctor check the local runtime and project configuration
|
|
96
109
|
version print the CLI version
|
|
97
110
|
|
|
@@ -99,6 +112,9 @@ Options:
|
|
|
99
112
|
--cwd DIR project directory (default: current directory)
|
|
100
113
|
--output DIR build output (default: dist)
|
|
101
114
|
--base PATH deployed URL base (default: /)
|
|
115
|
+
--format TYPE diff format: markdown, json, or sarif
|
|
116
|
+
--site URL catalog URL linked from an architecture diff
|
|
117
|
+
--head BRANCH head branch linked from an architecture diff
|
|
102
118
|
--host HOST dev server host (default: 127.0.0.1)
|
|
103
119
|
--port PORT dev server port
|
|
104
120
|
--yes, -y init without questions: take every detected default`);
|
|
@@ -118,10 +134,24 @@ async function init(workspace, options) {
|
|
|
118
134
|
|
|
119
135
|
function doctor(workspace) {
|
|
120
136
|
const manifestPath = resolve(workspace, "portolan.json");
|
|
121
|
-
|
|
137
|
+
let manifest = null;
|
|
138
|
+
let manifestProblems = [];
|
|
139
|
+
if (existsSync(manifestPath)) {
|
|
140
|
+
try {
|
|
141
|
+
const loaded = loadManifest(manifestPath);
|
|
142
|
+
manifest = loaded.manifest;
|
|
143
|
+
manifestProblems = loaded.problems;
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
manifestProblems = [cause instanceof Error ? cause.message : String(cause)];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
122
148
|
const checks = [
|
|
123
149
|
["Node.js >= 24", Number(process.versions.node.split(".")[0]) >= 24, process.version],
|
|
124
|
-
[
|
|
150
|
+
[
|
|
151
|
+
"portolan.json",
|
|
152
|
+
Boolean(manifest) && manifestProblems.length === 0,
|
|
153
|
+
manifestProblems.length > 0 ? manifestProblems.join("; ") : "required",
|
|
154
|
+
],
|
|
125
155
|
["Git", commandWorks("git", ["--version"]), "used for deterministic source stamps"],
|
|
126
156
|
];
|
|
127
157
|
|
|
@@ -157,7 +187,7 @@ async function build(workspace, options) {
|
|
|
157
187
|
runNode(packageBin("vite", "bin/vite.js"), ["build", stage, "--config", resolve(stage, "vite.config.ts"), "--outDir", output, "--emptyOutDir"], workspace, env);
|
|
158
188
|
|
|
159
189
|
const { siteDocs } = await import(resolve(installRoot, "scripts/site-docs.mjs"));
|
|
160
|
-
const manifest =
|
|
190
|
+
const manifest = readManifest(resolve(workspace, "portolan.json"));
|
|
161
191
|
const written = siteDocs({ manifest, dist: output });
|
|
162
192
|
copyFileSync(resolve(output, "index.html"), resolve(output, "404.html"));
|
|
163
193
|
console.log(`site: ${relative(workspace, output) || "."}${written.length ? `; mounted ${written.join(", ")}` : ""}`);
|
|
@@ -175,10 +205,10 @@ async function dev(workspace, options) {
|
|
|
175
205
|
});
|
|
176
206
|
}
|
|
177
207
|
|
|
178
|
-
async function prepareSite(workspace) {
|
|
208
|
+
export async function prepareSite(workspace) {
|
|
179
209
|
const manifestPath = resolve(workspace, "portolan.json");
|
|
180
210
|
if (!existsSync(manifestPath)) fail("portolan.json is missing; run portolan init first");
|
|
181
|
-
const manifest =
|
|
211
|
+
const manifest = readManifest(manifestPath);
|
|
182
212
|
const stage = resolve(workspace, ".portolan", "site");
|
|
183
213
|
rmSync(stage, { recursive: true, force: true });
|
|
184
214
|
mkdirSync(stage, { recursive: true });
|
|
@@ -189,14 +219,18 @@ async function prepareSite(workspace) {
|
|
|
189
219
|
for (const file of ["index.html", "vite.config.ts", "tsconfig.json"]) {
|
|
190
220
|
copyFileSync(resolve(installRoot, file), resolve(stage, file));
|
|
191
221
|
}
|
|
192
|
-
writeFileSync(resolve(stage, "package.json"),
|
|
222
|
+
writeFileSync(resolve(stage, "package.json"), `${JSON.stringify({ private: true, type: "module", version: VERSION })}\n`);
|
|
193
223
|
|
|
194
224
|
const patternSets = [manifest.sources ?? [], ...(manifest.catalogs ?? []).map((profile) => profile.sources ?? [])];
|
|
195
225
|
const allSources = new Set();
|
|
196
226
|
for (const patterns of patternSets) {
|
|
197
227
|
for (const path of await matchedFiles(workspace, patterns)) allSources.add(path);
|
|
198
228
|
}
|
|
199
|
-
|
|
229
|
+
const emptyWorkspace = Array.isArray(manifest.projects)
|
|
230
|
+
&& manifest.projects.length === 0
|
|
231
|
+
&& (manifest.extract ?? []).length === 0
|
|
232
|
+
&& (manifest.verify ?? []).length === 0;
|
|
233
|
+
if (allSources.size === 0 && !emptyWorkspace) fail("no catalog source matches portolan.json; run portolan generate first");
|
|
200
234
|
|
|
201
235
|
const flattened = new Map();
|
|
202
236
|
let ordinal = 0;
|
|
@@ -204,16 +238,27 @@ async function prepareSite(workspace) {
|
|
|
204
238
|
const name = `portolan/source-${String(++ordinal).padStart(4, "0")}.json`;
|
|
205
239
|
flattened.set(source, name);
|
|
206
240
|
copyIntoStage(workspace, stage, source, name);
|
|
207
|
-
|
|
241
|
+
const catalog = JSON.parse(readFileSync(resolve(workspace, source), "utf8"));
|
|
242
|
+
copyReferencedFiles(workspace, stage, catalog);
|
|
243
|
+
copyReadmeAssets(workspace, stage, catalog);
|
|
244
|
+
}
|
|
245
|
+
let stagedSources = [...flattened.values()];
|
|
246
|
+
if (allSources.size === 0) {
|
|
247
|
+
const name = "portolan/source-0000.json";
|
|
248
|
+
mkdirSync(dirname(resolve(stage, name)), { recursive: true });
|
|
249
|
+
writeFileSync(resolve(stage, name), `${JSON.stringify({ generatedAt: "1970-01-01T00:00:00Z", commit: "empty", contexts: [], defs: {}, flows: [], adrs: [] }, null, 2)}\n`);
|
|
250
|
+
stagedSources = [name];
|
|
208
251
|
}
|
|
209
252
|
|
|
210
253
|
const stagedManifest = {
|
|
211
254
|
...manifest,
|
|
212
|
-
sources:
|
|
255
|
+
sources: stagedSources,
|
|
213
256
|
...(manifest.catalogs ? {
|
|
214
257
|
catalogs: await Promise.all(manifest.catalogs.map(async (profile) => ({
|
|
215
258
|
...profile,
|
|
216
|
-
sources:
|
|
259
|
+
sources: allSources.size === 0
|
|
260
|
+
? stagedSources
|
|
261
|
+
: (await matchedFiles(workspace, profile.sources ?? [])).map((path) => flattened.get(path)).filter(Boolean),
|
|
217
262
|
}))),
|
|
218
263
|
} : {}),
|
|
219
264
|
};
|
|
@@ -258,6 +303,40 @@ function copyReferencedFiles(workspace, stage, value) {
|
|
|
258
303
|
visit(value);
|
|
259
304
|
}
|
|
260
305
|
|
|
306
|
+
function copyReadmeAssets(workspace, stage, catalog) {
|
|
307
|
+
for (const context of catalog.contexts ?? []) {
|
|
308
|
+
for (const service of context.services ?? []) {
|
|
309
|
+
if (typeof service.path !== "string" || typeof service.readme !== "string") continue;
|
|
310
|
+
const root = resolve(workspace, service.path);
|
|
311
|
+
if (!inside(workspace, root)) continue;
|
|
312
|
+
for (const target of markdownTargets(service.readme)) {
|
|
313
|
+
const source = resolveMarkdownTarget(root, target);
|
|
314
|
+
if (!source || !inside(root, source) || !existsSync(source) || !statSync(source).isFile()) continue;
|
|
315
|
+
const relativeSource = relative(workspace, source).split(sep).join("/");
|
|
316
|
+
copyIntoStage(workspace, stage, relativeSource, `public/portolan-assets/${relativeSource}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function markdownTargets(markdown) {
|
|
323
|
+
const targets = [];
|
|
324
|
+
const links = /!?\[[^\]]*\]\(\s*(?:<([^>\n]+)>|([^\s)\n]+))(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
325
|
+
for (const match of markdown.matchAll(links)) targets.push(match[1] ?? match[2]);
|
|
326
|
+
const html = /<(?:img|source)\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
|
327
|
+
for (const match of markdown.matchAll(html)) targets.push(match[1]);
|
|
328
|
+
return targets.filter(Boolean);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function resolveMarkdownTarget(root, target) {
|
|
332
|
+
const value = String(target).trim();
|
|
333
|
+
if (!value || value.startsWith("#") || value.startsWith("/") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) return null;
|
|
334
|
+
const pathname = value.split(/[?#]/, 1)[0];
|
|
335
|
+
if (!pathname) return null;
|
|
336
|
+
try { return resolve(root, decodeURIComponent(pathname)); }
|
|
337
|
+
catch { return null; }
|
|
338
|
+
}
|
|
339
|
+
|
|
261
340
|
function copyIntoStage(workspace, stage, source, target) {
|
|
262
341
|
const from = resolve(workspace, source);
|
|
263
342
|
const to = resolve(stage, target);
|
|
@@ -295,7 +374,7 @@ function prepareHost(workspace) {
|
|
|
295
374
|
mkdirSync(host, { recursive: true });
|
|
296
375
|
cpSync(resolve(installRoot, "scripts"), resolve(host, "scripts"), { recursive: true });
|
|
297
376
|
cpSync(resolve(installRoot, "src"), resolve(host, "src"), { recursive: true });
|
|
298
|
-
writeFileSync(resolve(host, "package.json"),
|
|
377
|
+
writeFileSync(resolve(host, "package.json"), `${JSON.stringify({ private: true, type: "module", version: VERSION })}\n`);
|
|
299
378
|
symlinkSync(dependencyRoot(), resolve(host, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
300
379
|
return host;
|
|
301
380
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { prepareSite, VERSION } from "./portolan.mjs";
|
|
7
|
+
|
|
8
|
+
const roots = [];
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("site staging", () => {
|
|
14
|
+
it("stages a deterministic source for an intentional empty workspace", async () => {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), "portolan-empty-site-"));
|
|
16
|
+
roots.push(root);
|
|
17
|
+
writeFileSync(join(root, "portolan.json"), `${JSON.stringify({ sources: ["portolan/*.json"], projects: [], extract: [], verify: [], generate: [] }, null, 2)}\n`);
|
|
18
|
+
|
|
19
|
+
const stage = await prepareSite(root);
|
|
20
|
+
const stagedPackage = JSON.parse(readFileSync(join(stage, "package.json"), "utf8"));
|
|
21
|
+
const stagedManifest = JSON.parse(readFileSync(join(stage, "portolan.json"), "utf8"));
|
|
22
|
+
expect(stagedPackage.version).toBe(VERSION);
|
|
23
|
+
expect(stagedManifest.sources).toEqual(["portolan/source-0000.json"]);
|
|
24
|
+
expect(existsSync(join(stage, "portolan/source-0000.json"))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("stages binary files referenced by a service README as public assets", async () => {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), "portolan-readme-assets-"));
|
|
29
|
+
roots.push(root);
|
|
30
|
+
const project = "vendor/repos/acme/shop";
|
|
31
|
+
mkdirSync(join(root, project, "portolan"), { recursive: true });
|
|
32
|
+
mkdirSync(join(root, project, "docs"), { recursive: true });
|
|
33
|
+
const image = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0x00]);
|
|
34
|
+
writeFileSync(join(root, project, "docs/example.png"), image);
|
|
35
|
+
writeFileSync(join(root, project, "README.md"), "# Shop\n");
|
|
36
|
+
writeFileSync(join(root, project, "portolan/project.json"), `${JSON.stringify({
|
|
37
|
+
generatedAt: "2026-09-09T00:00:00Z",
|
|
38
|
+
commit: "abc1234",
|
|
39
|
+
contexts: [{ id: "shop", slug: "shop", name: "Shop", services: [{ id: "shop.api", slug: "api", name: "API", repo: "github.com/acme/shop", path: project, readme: "", provides: [], consumes: [], aggregates: [] }] }],
|
|
40
|
+
defs: {}, flows: [], adrs: [],
|
|
41
|
+
}, null, 2)}\n`);
|
|
42
|
+
writeFileSync(join(root, "portolan.json"), `${JSON.stringify({ sources: [`${project}/portolan/*.json`] }, null, 2)}\n`);
|
|
43
|
+
|
|
44
|
+
const stage = await prepareSite(root);
|
|
45
|
+
expect(readFileSync(join(stage, "public/portolan-assets", project, "docs/example.png"))).toEqual(image);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@shortlink-org/portolan",
|
|
3
3
|
"description": "Generate a navigable architecture catalog from code and specifications.",
|
|
4
4
|
"private": false,
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.3",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"homepage": "https://github.com/shortlink-org/portolan#readme",
|
|
@@ -67,19 +67,20 @@
|
|
|
67
67
|
"prepack": "npm run plugins:build"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@
|
|
71
|
-
"@
|
|
72
|
-
"@ai-sdk/openai-compatible": "3.0.44",
|
|
73
|
-
"@ai-sdk/react": "4.0.96",
|
|
70
|
+
"@ai-sdk/openai-compatible": "3.0.45",
|
|
71
|
+
"@ai-sdk/react": "4.0.98",
|
|
74
72
|
"@asyncapi/react-component": "^3.1.8",
|
|
75
73
|
"@clack/prompts": "^1.8.0",
|
|
74
|
+
"@floating-ui/react": "^0.27.20",
|
|
76
75
|
"@headlessui/react": "^2.2.10",
|
|
77
76
|
"@scalar/api-reference-react": "^0.9.66",
|
|
77
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
78
78
|
"@tanstack/react-query": "^5.102.8",
|
|
79
79
|
"@tanstack/react-table": "^9.2.4",
|
|
80
80
|
"@tanstack/react-virtual": "^3.14.10",
|
|
81
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
81
82
|
"@xyflow/react": "^12.11.5",
|
|
82
|
-
"ai": "7.0.
|
|
83
|
+
"ai": "7.0.95",
|
|
83
84
|
"ajv": "^8.20.0",
|
|
84
85
|
"elkjs": "^0.12.0",
|
|
85
86
|
"html-to-image": "^1.11.13",
|
|
@@ -244,7 +244,7 @@ without it the wrapper is shown as an operation inside the current service.
|
|
|
244
244
|
vendored from. The code names the verb and the route —
|
|
245
245
|
`self._http.post("/v1/quotes")`, httpx or requests alike — and the document
|
|
246
246
|
says which operation answers there, so the call is recorded under the id the
|
|
247
|
-
callee's own extractor gives it: `pricing.v1
|
|
247
|
+
callee's own extractor gives it: `pricing.v1/createQuote`, spelled by
|
|
248
248
|
the same rules as `plugins/openapi`. The peer is the manifest's `peers` entry
|
|
249
249
|
for that api id; without one the lane is `unknown` and the step unresolved.
|
|
250
250
|
Without a document there is no id to share, and the call is recorded against
|
|
@@ -265,7 +265,7 @@ class Reading(unittest.TestCase):
|
|
|
265
265
|
self.assertEqual(wire["InvoiceIssued"], {"name": "billing.InvoiceIssued", "channel": "shop.billing.invoice"})
|
|
266
266
|
|
|
267
267
|
def test_a_client_call_is_the_id_the_callee_would_give_it(self):
|
|
268
|
-
self.assertEqual([c["id"] for c in self.service["consumes"]], ["pricing.v1
|
|
268
|
+
self.assertEqual([c["id"] for c in self.service["consumes"]], ["pricing.v1/createQuote"])
|
|
269
269
|
self.assertEqual(self.service["consumes"][0]["peer"], "shop.pricing")
|
|
270
270
|
|
|
271
271
|
def test_the_http_contract_points_at_the_generated_openapi_document(self):
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The same rules as `plugins/openapi/ids.go`, spelled a second time so that a
|
|
4
4
|
Django caller and the service that answers spell one call the same way:
|
|
5
|
-
`auth.v1
|
|
5
|
+
`auth.v1/validateSession` on both sides, or the call would never
|
|
6
6
|
resolve to the method.
|
|
7
7
|
|
|
8
8
|
The reader underneath is a small one. A document is a mapping of mappings with
|
|
@@ -16,7 +16,6 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
18
|
import os
|
|
19
|
-
import re
|
|
20
19
|
from dataclasses import dataclass, field
|
|
21
20
|
from typing import Any, Dict, List, Optional
|
|
22
21
|
|
|
@@ -30,18 +29,9 @@ def api_id(title: str, version: str) -> str:
|
|
|
30
29
|
return name + ".v" + major if major else name
|
|
31
30
|
|
|
32
31
|
|
|
33
|
-
def
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
out = ""
|
|
37
|
-
for word in re.split(r"[_\-\s]+", name):
|
|
38
|
-
if word:
|
|
39
|
-
out += word[0].upper() + word[1:]
|
|
40
|
-
return out
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def interface_id(api: str, tag: str) -> str:
|
|
44
|
-
return api + "." + title_of(tag) if tag else api
|
|
32
|
+
def interface_id(api: str, _tag: str) -> str:
|
|
33
|
+
"""Tags organise operations inside a contract; they do not create interfaces."""
|
|
34
|
+
return api
|
|
45
35
|
|
|
46
36
|
|
|
47
37
|
@dataclass
|
|
@@ -20,9 +20,9 @@ class Ids(unittest.TestCase):
|
|
|
20
20
|
self.assertEqual(openapi.api_id("Price List", "2.1.0"), "price-list.v2")
|
|
21
21
|
self.assertEqual(openapi.api_id("", ""), "api")
|
|
22
22
|
|
|
23
|
-
def
|
|
24
|
-
self.assertEqual(openapi.interface_id("auth.v1", "sessions"), "auth.v1
|
|
25
|
-
self.assertEqual(openapi.interface_id("auth.v1", "price_list"), "auth.v1
|
|
23
|
+
def test_tags_stay_inside_the_document_interface(self):
|
|
24
|
+
self.assertEqual(openapi.interface_id("auth.v1", "sessions"), "auth.v1")
|
|
25
|
+
self.assertEqual(openapi.interface_id("auth.v1", "price_list"), "auth.v1")
|
|
26
26
|
self.assertEqual(openapi.interface_id("auth.v1", ""), "auth.v1")
|
|
27
27
|
|
|
28
28
|
def test_a_parameter_is_compared_by_position(self):
|
|
@@ -39,7 +39,7 @@ class Read(unittest.TestCase):
|
|
|
39
39
|
self.assertEqual([o.id for o in spec.operations], ["createQuote"])
|
|
40
40
|
found = spec.find("POST", "/v1/quotes")
|
|
41
41
|
self.assertIsNotNone(found)
|
|
42
|
-
self.assertEqual(found.call_id(spec.api), "pricing.v1
|
|
42
|
+
self.assertEqual(found.call_id(spec.api), "pricing.v1/createQuote")
|
|
43
43
|
|
|
44
44
|
def test_a_route_the_document_does_not_declare_finds_nothing(self):
|
|
45
45
|
spec = openapi.read(DOCUMENT)
|
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
"type": "string",
|
|
30
30
|
"description": "What the service is called on the page. Defaults to the title of its README, and failing that to the slug, titled."
|
|
31
31
|
},
|
|
32
|
+
"scope": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Deployable name in a shared Go module. Limits owned source to internal/<scope> while allowing calls to imported application packages to be followed.",
|
|
35
|
+
"examples": ["api", "billing"]
|
|
36
|
+
},
|
|
32
37
|
"repo": {
|
|
33
38
|
"type": "string",
|
|
34
39
|
"description": "Where the source lives. Defaults to the module path in go.mod.",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"enum": ["bounded-context", "system", "product", "team", "namespace"],
|
|
19
19
|
"description": "Semantic role of the top-level group. Defaults to system for this neutral extractor."
|
|
20
20
|
},
|
|
21
|
+
"classification": {
|
|
22
|
+
"enum": ["core", "supporting", "generic"],
|
|
23
|
+
"description": "DDD classification of a bounded context when the group represents one."
|
|
24
|
+
},
|
|
21
25
|
"component": {
|
|
22
26
|
"type": "string",
|
|
23
27
|
"description": "Slug of the component. Defaults to the input directory name."
|
|
@@ -39,6 +43,23 @@
|
|
|
39
43
|
"type": "string",
|
|
40
44
|
"description": "Source repository identity. Defaults to a Go module, package.json repository, Cargo package, or Maven coordinates when present."
|
|
41
45
|
},
|
|
46
|
+
"components": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"minItems": 1,
|
|
49
|
+
"items": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"additionalProperties": false,
|
|
52
|
+
"required": ["slug"],
|
|
53
|
+
"properties": {
|
|
54
|
+
"slug": { "type": "string", "minLength": 1 },
|
|
55
|
+
"name": { "type": "string", "minLength": 1 },
|
|
56
|
+
"kind": {
|
|
57
|
+
"enum": ["service", "application", "webapp", "worker", "job", "function", "cli", "library", "data-pipeline"]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"description": "Independently deployable runtime components found inside one repository. When present, the singular component fields are ignored."
|
|
62
|
+
},
|
|
42
63
|
"out": {
|
|
43
64
|
"type": "string",
|
|
44
65
|
"description": "Name of the fragment file inside the step output directory.",
|
|
@@ -221,7 +221,7 @@ bus; a field read off one is not.
|
|
|
221
221
|
the path in the code - `client.GET("/v1/sessions/current", …)` - and the
|
|
222
222
|
document vendored beside `gen/types.ts` says which operation answers on that
|
|
223
223
|
route and which interface it belongs to, spelled by `plugins/openapi` so the
|
|
224
|
-
call and the method on the other side share one id: `auth.v1
|
|
224
|
+
call and the method on the other side share one id: `auth.v1/validateSession`.
|
|
225
225
|
**gRPC peer.** A Connect-ES client is created from a service descriptor whose
|
|
226
226
|
`typeName` is the proto service, `shop.v1.Pricing`, and a call on it is the
|
|
227
227
|
method in the descriptor's own case: `client.getQuote(…)` is
|
|
@@ -18,7 +18,7 @@ import type { WarningSink } from "./domain.ts";
|
|
|
18
18
|
|
|
19
19
|
/** One call the adapter makes, in the catalog's terms. */
|
|
20
20
|
export interface RpcHop {
|
|
21
|
-
/** "auth.v1
|
|
21
|
+
/** "auth.v1/validateSession" or "shop.v1.Pricing/GetQuote" */
|
|
22
22
|
id: string;
|
|
23
23
|
/** What the manifest's peers map is keyed by: the api id or the proto package. */
|
|
24
24
|
pkg: string;
|
|
@@ -114,7 +114,7 @@ describe("the service", () => {
|
|
|
114
114
|
|
|
115
115
|
it("records what it calls, named the way the callee names the method", () => {
|
|
116
116
|
expect(svc.consumes.map((c: { id: string; peer: string; status: string }) => `${c.id}→${c.peer}:${c.status}`)).toEqual([
|
|
117
|
-
"auth.v1
|
|
117
|
+
"auth.v1/validateSession→auth.auth:declared",
|
|
118
118
|
"shop.v1.Pricing/GetQuote→shop.pricing:declared",
|
|
119
119
|
]);
|
|
120
120
|
});
|
|
@@ -146,7 +146,7 @@ describe("the flows", () => {
|
|
|
146
146
|
]);
|
|
147
147
|
expect(checkout.participants.map((p) => `${p.id}/${p.kind}`)).toContain("auth.auth/service");
|
|
148
148
|
const rpc = checkout.steps[1] as Step;
|
|
149
|
-
expect(rpc.ref).toBe("auth.v1
|
|
149
|
+
expect(rpc.ref).toBe("auth.v1/validateSession");
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
it("opens a policy on the bus and draws its choices", () => {
|
|
@@ -45,13 +45,13 @@ describe("a service read through its resolvers", () => {
|
|
|
45
45
|
it("follows the port on the context to the peer behind it", () => {
|
|
46
46
|
const [, second] = flow("bff-query-viewer").steps as Step[];
|
|
47
47
|
|
|
48
|
-
expect(second).toMatchObject({ from: "storefront.bff", to: "auth.auth", ref: "auth.v1
|
|
48
|
+
expect(second).toMatchObject({ from: "storefront.bff", to: "auth.auth", ref: "auth.v1/validateSession" });
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
it("names the call among what the service consumes", () => {
|
|
52
52
|
const { consumes } = fragment().contexts[0]!.services[0]!;
|
|
53
53
|
|
|
54
|
-
expect(consumes).toEqual([{ id: "auth.v1
|
|
54
|
+
expect(consumes).toEqual([{ id: "auth.v1/validateSession", peer: "auth.auth", status: "declared", source: `${ROOT}/src/infrastructure/auth/gen/openapi.yaml` }]);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
// A resolver that answers out of its arguments reaches nothing, and the flow
|
|
@@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { apiID, callID, documentApiID, findOperation, interfaceID, readSpec
|
|
5
|
+
import { apiID, callID, documentApiID, findOperation, interfaceID, readSpec } from "./openapi.ts";
|
|
6
6
|
|
|
7
7
|
// The same cases plugins/openapi/ids_test.go holds the Go side to.
|
|
8
8
|
describe("openapi ids", () => {
|
|
@@ -14,9 +14,8 @@ describe("openapi ids", () => {
|
|
|
14
14
|
expect(documentApiID(" stripe.v1 ", "Stripe API", "2026-08-26.dahlia")).toBe("stripe.v1");
|
|
15
15
|
expect(documentApiID("", "Stripe API", "2026-08-26.dahlia")).toBe("stripe-api.v2026-08-26");
|
|
16
16
|
});
|
|
17
|
-
it("
|
|
18
|
-
expect(
|
|
19
|
-
expect(interfaceID("auth.v1", "sessions")).toBe("auth.v1.Sessions");
|
|
17
|
+
it("keeps tags inside the document's interface", () => {
|
|
18
|
+
expect(interfaceID("auth.v1", "sessions")).toBe("auth.v1");
|
|
20
19
|
expect(interfaceID("auth.v1", "")).toBe("auth.v1");
|
|
21
20
|
});
|
|
22
21
|
});
|
|
@@ -55,9 +54,9 @@ describe("readSpec and findOperation", () => {
|
|
|
55
54
|
});
|
|
56
55
|
it("finds a route however the parameter is spelled", () => {
|
|
57
56
|
const login = findOperation(spec, "post", "/v1/sessions")!;
|
|
58
|
-
expect(callID(spec, login)).toBe("auth.v1
|
|
59
|
-
expect(callID(spec, findOperation(spec, "GET", "/v1/users/{userId}")!)).toBe("auth.v1
|
|
60
|
-
expect(callID(spec, findOperation(spec, "GET", "/v1/users/${id}")!)).toBe("auth.v1
|
|
57
|
+
expect(callID(spec, login)).toBe("auth.v1/login");
|
|
58
|
+
expect(callID(spec, findOperation(spec, "GET", "/v1/users/{userId}")!)).toBe("auth.v1/getUser");
|
|
59
|
+
expect(callID(spec, findOperation(spec, "GET", "/v1/users/${id}")!)).toBe("auth.v1/getUser");
|
|
61
60
|
expect(callID(spec, findOperation(spec, "GET", "/v1/health")!)).toBe("auth.v1/GET /v1/health");
|
|
62
61
|
expect(findOperation(spec, "DELETE", "/v1/sessions")).toBeUndefined();
|
|
63
62
|
});
|
|
@@ -25,16 +25,9 @@ export function documentApiID(declared: string, title: string, version: string):
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** users → Users, price_list → PriceList. */
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.filter(Boolean)
|
|
32
|
-
.map((w) => (w[0]! >= "a" && w[0]! <= "z" ? w[0]!.toUpperCase() + w.slice(1) : w))
|
|
33
|
-
.join("");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function interfaceID(api: string, tag: string): string {
|
|
37
|
-
return tag ? `${api}.${tagTitle(tag)}` : api;
|
|
28
|
+
/** Tags organise operations inside an OpenAPI contract; they do not create interfaces. */
|
|
29
|
+
export function interfaceID(api: string, _tag: string): string {
|
|
30
|
+
return api;
|
|
38
31
|
}
|
|
39
32
|
|
|
40
33
|
export const VERBS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
package/plugins/portolan-go.wasm
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|