@arc-e-tect/api-only-publisher 0.0.0
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/LICENSE +21 -0
- package/README.adoc +498 -0
- package/package.json +56 -0
- package/src/aggregate.js +118 -0
- package/src/changed.js +100 -0
- package/src/channels.js +284 -0
- package/src/cli.js +257 -0
- package/src/closure.js +128 -0
- package/src/config.js +157 -0
- package/src/index.js +22 -0
- package/src/init.js +183 -0
- package/src/pack.js +125 -0
- package/src/pipeline.js +193 -0
- package/src/placeholders.js +117 -0
- package/src/split.js +176 -0
- package/src/version-policy.js +67 -0
- package/src/version.js +69 -0
package/src/pipeline.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// The build pipeline: stage, substitute, bundle, stamp, lint, distribute.
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const { execFileSync } = require("child_process");
|
|
8
|
+
|
|
9
|
+
const { substituteFile } = require("./placeholders");
|
|
10
|
+
const { stampFile } = require("./version");
|
|
11
|
+
const { generateAsyncApi, isAggregate } = require("./aggregate");
|
|
12
|
+
|
|
13
|
+
class BuildError extends Error {}
|
|
14
|
+
|
|
15
|
+
function run(command, args, { quiet }) {
|
|
16
|
+
try {
|
|
17
|
+
const out = execFileSync(command, args, { encoding: "utf8", stdio: quiet ? "pipe" : "inherit" });
|
|
18
|
+
return out;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new BuildError(
|
|
21
|
+
`${command} ${args.join(" ")} failed` + (error.stdout ? `\n${error.stdout}` : "") +
|
|
22
|
+
(error.stderr ? `\n${error.stderr}` : "")
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Copy the whole source root to the staging directory for one specification
|
|
29
|
+
* type.
|
|
30
|
+
*
|
|
31
|
+
* The *whole* root, not just that type's subtree: the trees $ref each other --
|
|
32
|
+
* the AsyncAPI event schemas reuse the OpenAPI common schemas -- so a partial
|
|
33
|
+
* copy breaks those references. Its own staging root per type, so that building
|
|
34
|
+
* one type never invalidates the other's staged tree.
|
|
35
|
+
*/
|
|
36
|
+
function stage(config, kind, log) {
|
|
37
|
+
const from = config.sourceRoot();
|
|
38
|
+
const to = config.stagingRoot(kind);
|
|
39
|
+
log(`-- Staging ${path.relative(config.root, from)} -> ${path.relative(config.root, to)}`);
|
|
40
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
41
|
+
fs.mkdirSync(to, { recursive: true });
|
|
42
|
+
fs.cpSync(from, to, { recursive: true });
|
|
43
|
+
return to;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Substitute placeholders across the staged tree, in place.
|
|
48
|
+
*
|
|
49
|
+
* Every YAML file is visited rather than only the bundle roots and info.yaml.
|
|
50
|
+
* The tool this replaces read only the one file it was handed, which is why the
|
|
51
|
+
* shared info block had to be preprocessed as a separate up-front step and why
|
|
52
|
+
* bundles had to $ref a generated merged_info.yaml instead of the file they
|
|
53
|
+
* meant. Visiting the staged tree removes that special case: a placeholder works
|
|
54
|
+
* wherever it is written.
|
|
55
|
+
*/
|
|
56
|
+
function substituteTree(config, kind, log) {
|
|
57
|
+
const stagingRoot = config.stagingRoot(kind);
|
|
58
|
+
const placeholders = config.defaults.placeholders || {};
|
|
59
|
+
const strict = placeholders.strict !== false;
|
|
60
|
+
|
|
61
|
+
let files = 0;
|
|
62
|
+
let tokens = 0;
|
|
63
|
+
const walk = (dir) => {
|
|
64
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
65
|
+
const full = path.join(dir, entry.name);
|
|
66
|
+
if (entry.isDirectory()) walk(full);
|
|
67
|
+
else if (entry.isFile() && /\.ya?ml$/.test(entry.name)) {
|
|
68
|
+
const result = substituteFile(full, { searchRoot: stagingRoot, strict });
|
|
69
|
+
if (result.resolved.length > 0) {
|
|
70
|
+
files += 1;
|
|
71
|
+
tokens += result.resolved.length;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
walk(stagingRoot);
|
|
77
|
+
log(`-- Substituted ${tokens} placeholder(s) across ${files} file(s)`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function bundle(config, target, kind, outFile, log) {
|
|
81
|
+
const source = config.bundlePath(target, kind);
|
|
82
|
+
if (!fs.existsSync(source)) {
|
|
83
|
+
throw new BuildError(`target '${target}': bundle root not found at ${source}`);
|
|
84
|
+
}
|
|
85
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
86
|
+
log(`-- Bundling ${path.basename(source)}`);
|
|
87
|
+
const tool = kind === "openapi" ? config.tool("redocly") : config.tool("asyncapi");
|
|
88
|
+
run("npx", ["--yes", tool, "bundle", source, "--output", outFile], { quiet: true });
|
|
89
|
+
if (!fs.existsSync(outFile)) {
|
|
90
|
+
throw new BuildError(`target '${target}': ${tool} produced no output at ${outFile}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function lint(config, kind, file, log) {
|
|
95
|
+
const tool = kind === "openapi" ? config.tool("redocly") : config.tool("asyncapi");
|
|
96
|
+
const args = kind === "openapi"
|
|
97
|
+
? ["--yes", tool, "lint"].concat(config.lintConfig("openapi") ? ["--config", config.lintConfig("openapi")] : []).concat([file])
|
|
98
|
+
: ["--yes", tool, "validate", file];
|
|
99
|
+
log(`-- Validating ${path.basename(file)}`);
|
|
100
|
+
run("npx", args, { quiet: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Copy a built document to the project that implements the target.
|
|
105
|
+
*
|
|
106
|
+
* Transitional. A producer has no business knowing its consumers' directory
|
|
107
|
+
* layouts; this exists only so that the build stays verifiable against the
|
|
108
|
+
* recorded fixtures while the Subscriber is not yet in place.
|
|
109
|
+
*/
|
|
110
|
+
function distribute(config, target, kind, builtFile, log) {
|
|
111
|
+
const destDir = config.destinationDir(target);
|
|
112
|
+
if (!destDir) return null;
|
|
113
|
+
if (!fs.existsSync(destDir)) {
|
|
114
|
+
throw new BuildError(`target '${target}': destination directory does not exist: ${destDir}`);
|
|
115
|
+
}
|
|
116
|
+
const destFile = path.join(destDir, config.outputName(kind));
|
|
117
|
+
fs.copyFileSync(builtFile, destFile);
|
|
118
|
+
log(`-- Distributed to ${path.relative(config.root, destFile)}`);
|
|
119
|
+
return destFile;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Stage, substitute and generate, without bundling.
|
|
124
|
+
*
|
|
125
|
+
* Everything that reads the fragment graph -- closures, `changed`, `split` --
|
|
126
|
+
* needs the tree in the state the bundler will see it: substituted, and with
|
|
127
|
+
* generated aggregate bundle roots present. Doing it in one place is what stops
|
|
128
|
+
* those commands from quietly disagreeing with `build` about what the library
|
|
129
|
+
* contains.
|
|
130
|
+
*/
|
|
131
|
+
function prepare(config, { kinds = ["openapi", "asyncapi"], log = () => {} } = {}) {
|
|
132
|
+
for (const kind of kinds) {
|
|
133
|
+
const targets = config.targetsFor(kind);
|
|
134
|
+
if (targets.length === 0) continue;
|
|
135
|
+
stage(config, kind, log);
|
|
136
|
+
substituteTree(config, kind, log);
|
|
137
|
+
for (const target of targets) {
|
|
138
|
+
if (isAggregate(config, target, kind) && kind === "asyncapi") {
|
|
139
|
+
generateAsyncApi(config, target, { log });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build every requested target.
|
|
147
|
+
*
|
|
148
|
+
* @returns {Array<{target, kind, file, distributed}>}
|
|
149
|
+
*/
|
|
150
|
+
function build(config, { targets, version, kinds = ["openapi", "asyncapi"], log = () => {} } = {}) {
|
|
151
|
+
const results = [];
|
|
152
|
+
for (const kind of kinds) {
|
|
153
|
+
const all = config.targetsFor(kind).filter((t) => !targets || targets.includes(t));
|
|
154
|
+
if (all.length === 0) continue;
|
|
155
|
+
|
|
156
|
+
log(`=== ${kind} ===`);
|
|
157
|
+
stage(config, kind, log);
|
|
158
|
+
substituteTree(config, kind, log);
|
|
159
|
+
|
|
160
|
+
for (const target of all) {
|
|
161
|
+
log(`\n=== ${target} (${kind}) ===`);
|
|
162
|
+
// An aggregate has no hand-written bundle root; it is synthesised from
|
|
163
|
+
// its members into the staged tree, so it can never fall behind them.
|
|
164
|
+
if (isAggregate(config, target, kind)) {
|
|
165
|
+
if (kind !== "asyncapi") {
|
|
166
|
+
throw new BuildError(
|
|
167
|
+
`target '${target}': aggregate is only supported for asyncapi; ` +
|
|
168
|
+
`an OpenAPI portfolio is a hand-written table of contents of $refs`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
generateAsyncApi(config, target, { log });
|
|
172
|
+
}
|
|
173
|
+
const outFile = path.join(config.distDir(target), config.outputName(kind));
|
|
174
|
+
bundle(config, target, kind, outFile, log);
|
|
175
|
+
if (version) {
|
|
176
|
+
log(`-- Stamping version '${version}'`);
|
|
177
|
+
stampFile(outFile, version);
|
|
178
|
+
}
|
|
179
|
+
lint(config, kind, outFile, log);
|
|
180
|
+
|
|
181
|
+
let distributed = null;
|
|
182
|
+
if (config.isPublished(target)) {
|
|
183
|
+
distributed = distribute(config, target, kind, outFile, log);
|
|
184
|
+
} else {
|
|
185
|
+
log("-- Not distributed (publish: false)");
|
|
186
|
+
}
|
|
187
|
+
results.push({ target, kind, file: outFile, distributed });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return results;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { build, prepare, stage, substituteTree, bundle, lint, distribute, BuildError };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Placeholder substitution.
|
|
4
|
+
//
|
|
5
|
+
// Absorbed from sedr_utils/openapi/prep_openapi's preprocess_openapi.js, with
|
|
6
|
+
// three defects fixed rather than inherited:
|
|
7
|
+
//
|
|
8
|
+
// 1. Search scope. The original searched for <placeholder>.md recursively
|
|
9
|
+
// downward from the *input file's own directory*, never from the -d
|
|
10
|
+
// argument the caller passed. The workaround was to move the Markdown
|
|
11
|
+
// files next to whatever referenced them, and the gotcha had to be
|
|
12
|
+
// documented. The search root is now a parameter, defaulting to the source
|
|
13
|
+
// root the caller actually named.
|
|
14
|
+
// 2. Failure mode. A missing Markdown file produced a warning and the literal
|
|
15
|
+
// string *MISSING CONTENT* in the output, so a broken document shipped from
|
|
16
|
+
// a green build. Unresolved placeholders are now an error by default.
|
|
17
|
+
// 3. Token grammar. The pattern \{\{(\w+)\}\} silently excluded '-' and '.'
|
|
18
|
+
// from placeholder names, so {{status-codes}} was left in the output rather
|
|
19
|
+
// than reported. The grammar now admits them.
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
|
|
24
|
+
// {{name}} where name may contain letters, digits, underscore, dash or dot.
|
|
25
|
+
// Deliberately no whitespace tolerance inside the braces: that would make a
|
|
26
|
+
// stray "{{ " in prose look like a placeholder.
|
|
27
|
+
const TOKEN = /\{\{([A-Za-z0-9_.-]+)\}\}/g;
|
|
28
|
+
|
|
29
|
+
class PlaceholderError extends Error {}
|
|
30
|
+
|
|
31
|
+
// Depth-first search for `fileName` under `startDir`. Directory entries are
|
|
32
|
+
// sorted so the result cannot depend on filesystem ordering, which would make
|
|
33
|
+
// builds non-reproducible across machines.
|
|
34
|
+
function findFile(startDir, fileName) {
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = fs.readdirSync(startDir, { withFileTypes: true });
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.isFile() && entry.name === fileName) return path.join(startDir, entry.name);
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
const found = findFile(path.join(startDir, entry.name), fileName);
|
|
49
|
+
if (found) return found;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Keep the YAML indentation the placeholder sits at: the first line continues
|
|
56
|
+
// the indentation already present before the token, and every subsequent
|
|
57
|
+
// non-blank line gets that same indent prefixed, so multi-line Markdown stays
|
|
58
|
+
// valid inside an indented YAML scalar.
|
|
59
|
+
function indentReplacement(content, fullString, offset) {
|
|
60
|
+
const lineStart = fullString.lastIndexOf("\n", offset - 1) + 1;
|
|
61
|
+
const indent = fullString.slice(lineStart, offset).match(/^\s*/)[0];
|
|
62
|
+
return content
|
|
63
|
+
.replace(/\n$/, "")
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((line, i) => (i === 0 || line.length === 0 ? line : indent + line))
|
|
66
|
+
.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Substitute {{token}} placeholders in `text`.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} text the content to substitute into
|
|
73
|
+
* @param {object} options
|
|
74
|
+
* @param {string} options.searchRoot directory to search for <token>.md
|
|
75
|
+
* @param {boolean} [options.strict] throw on an unresolved token (default true)
|
|
76
|
+
* @param {string} [options.describeAs] label used in error messages
|
|
77
|
+
* @returns {{content: string, resolved: string[], unresolved: string[]}}
|
|
78
|
+
*/
|
|
79
|
+
function substitute(text, { searchRoot, strict = true, describeAs = "input" } = {}) {
|
|
80
|
+
if (!searchRoot) throw new PlaceholderError("substitute() requires a searchRoot");
|
|
81
|
+
|
|
82
|
+
const resolved = [];
|
|
83
|
+
const unresolved = [];
|
|
84
|
+
|
|
85
|
+
const content = text.replace(TOKEN, (match, token, offset, fullString) => {
|
|
86
|
+
const mdFile = findFile(searchRoot, `${token}.md`);
|
|
87
|
+
if (!mdFile) {
|
|
88
|
+
unresolved.push(token);
|
|
89
|
+
return match; // leave it visible; strict mode turns it into an error
|
|
90
|
+
}
|
|
91
|
+
resolved.push(token);
|
|
92
|
+
return indentReplacement(fs.readFileSync(mdFile, "utf8"), fullString, offset);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (strict && unresolved.length > 0) {
|
|
96
|
+
throw new PlaceholderError(
|
|
97
|
+
`${describeAs}: no Markdown file found for ${unresolved.map((t) => `{{${t}}}`).join(", ")}. ` +
|
|
98
|
+
`Searched for ${unresolved.map((t) => `${t}.md`).join(", ")} under ${searchRoot}.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { content, resolved, unresolved };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Substitute a file in place. Unlike the tool this replaces, nothing named
|
|
107
|
+
* merged_* is left behind: the file is rewritten where it stands, which is safe
|
|
108
|
+
* because it is always a staged copy.
|
|
109
|
+
*/
|
|
110
|
+
function substituteFile(file, options) {
|
|
111
|
+
const before = fs.readFileSync(file, "utf8");
|
|
112
|
+
const result = substitute(before, { ...options, describeAs: file });
|
|
113
|
+
if (result.content !== before) fs.writeFileSync(file, result.content);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { substitute, substituteFile, findFile, PlaceholderError, TOKEN };
|
package/src/split.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// `split` -- make a fragment library safe to break into separate repositories.
|
|
4
|
+
//
|
|
5
|
+
// The problem it solves is real and easy to miss. A fragment library is one $ref
|
|
6
|
+
// graph, and that graph does not respect the directory boundaries a repository
|
|
7
|
+
// split would follow. In this library the AsyncAPI event schemas reuse
|
|
8
|
+
// `openapi/components/common/schemas/UsernameV1.yaml`, so a username means the
|
|
9
|
+
// same thing over Kafka as over HTTP. Move the two trees into separate
|
|
10
|
+
// repositories as they stand and every one of those references dangles.
|
|
11
|
+
//
|
|
12
|
+
// `split` resolves each part's dependency closure and materialises it whole:
|
|
13
|
+
// each part gets its own subtree *plus* a copy of every foreign file it reaches,
|
|
14
|
+
// so that after the split each side has a complete set of definitions and builds
|
|
15
|
+
// on its own.
|
|
16
|
+
//
|
|
17
|
+
// The copies keep their original path relative to the shared source root, which
|
|
18
|
+
// is what makes this safe: every relative $ref keeps resolving exactly as it did,
|
|
19
|
+
// so nothing has to be rewritten and no reference can be broken by a rewrite
|
|
20
|
+
// getting the depth wrong.
|
|
21
|
+
//
|
|
22
|
+
// What this cannot do is keep the copies in step afterwards. Duplicating a schema
|
|
23
|
+
// is the price of independent repositories, not a way of avoiding it -- see the
|
|
24
|
+
// note written into each part.
|
|
25
|
+
|
|
26
|
+
const fs = require("fs");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
|
|
29
|
+
const { forTargets, resolve } = require("./closure");
|
|
30
|
+
|
|
31
|
+
class SplitError extends Error {}
|
|
32
|
+
|
|
33
|
+
const NOTE = `= Imported shared fragments
|
|
34
|
+
|
|
35
|
+
The files under this directory are **copies**, taken from another part of the
|
|
36
|
+
specification library this repository was split out of.
|
|
37
|
+
|
|
38
|
+
They are here because this repository's own fragments \`$ref\` them: at the point
|
|
39
|
+
of the split, the library was a single \`$ref\` graph that did not follow the
|
|
40
|
+
directory boundary the split was made along. Copying them is what makes this
|
|
41
|
+
repository self-contained and buildable on its own.
|
|
42
|
+
|
|
43
|
+
They keep the path they had before the split, relative to the old shared source
|
|
44
|
+
root, so every existing relative \`$ref\` still resolves and nothing had to be
|
|
45
|
+
rewritten.
|
|
46
|
+
|
|
47
|
+
== What this costs
|
|
48
|
+
|
|
49
|
+
These copies can now drift from the originals, and nothing here will notice.
|
|
50
|
+
A shared schema that two repositories both define is two schemas that happen to
|
|
51
|
+
agree today.
|
|
52
|
+
|
|
53
|
+
If that matters for a given fragment -- and for something like a username or a
|
|
54
|
+
problem-response shape it usually does -- promote it to a contract of its own,
|
|
55
|
+
published and consumed like any other, rather than copied. That is what the
|
|
56
|
+
API-Only Subscriber is for.
|
|
57
|
+
|
|
58
|
+
== Imported files
|
|
59
|
+
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Group targets into parts.
|
|
64
|
+
*
|
|
65
|
+
* `kind` splits along the specification types, which is the boundary a
|
|
66
|
+
* repository split usually follows. `target` gives every target its own
|
|
67
|
+
* self-contained tree, which is what you want before splitting into
|
|
68
|
+
* per-service repositories.
|
|
69
|
+
*/
|
|
70
|
+
function partition(config, by) {
|
|
71
|
+
const parts = new Map();
|
|
72
|
+
|
|
73
|
+
if (by === "kind") {
|
|
74
|
+
for (const kind of ["openapi", "asyncapi"]) {
|
|
75
|
+
const targets = config.targetsFor(kind);
|
|
76
|
+
if (targets.length > 0) parts.set(kind, targets.map((t) => ({ target: t, kind })));
|
|
77
|
+
}
|
|
78
|
+
return parts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (by === "target") {
|
|
82
|
+
for (const target of Object.keys(config.targets)) {
|
|
83
|
+
const kinds = ["openapi", "asyncapi"].filter((k) => config.targets[target][k]);
|
|
84
|
+
parts.set(target, kinds.map((k) => ({ target, kind: k })));
|
|
85
|
+
}
|
|
86
|
+
return parts;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
throw new SplitError(`unknown --by '${by}'; expected 'kind' or 'target'`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Which subtree of the shared source root a file naturally belongs to.
|
|
94
|
+
*
|
|
95
|
+
* The first path segment under the root: `openapi/...` or `asyncapi/...`.
|
|
96
|
+
*/
|
|
97
|
+
function homeSubtree(relPath) {
|
|
98
|
+
return relPath.split("/")[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @returns {Array<{part, dir, own: string[], imported: string[]}>}
|
|
103
|
+
*/
|
|
104
|
+
function split(config, { by = "kind", outDir, log = () => {} } = {}) {
|
|
105
|
+
if (!outDir) throw new SplitError("split requires an output directory");
|
|
106
|
+
|
|
107
|
+
const parts = partition(config, by);
|
|
108
|
+
const results = [];
|
|
109
|
+
|
|
110
|
+
for (const [partName, members] of parts) {
|
|
111
|
+
// Union of every member's closure.
|
|
112
|
+
const files = new Set();
|
|
113
|
+
for (const { target, kind } of members) {
|
|
114
|
+
const entry = config.bundlePath(target, kind);
|
|
115
|
+
if (!fs.existsSync(entry)) {
|
|
116
|
+
throw new SplitError(
|
|
117
|
+
`target '${target}': ${kind} bundle root not found at ${entry}; build first so the tree is staged`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
for (const file of resolve(entry).files) files.add(file);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const partDir = path.join(outDir, partName);
|
|
124
|
+
fs.rmSync(partDir, { recursive: true, force: true });
|
|
125
|
+
|
|
126
|
+
const own = [];
|
|
127
|
+
const imported = [];
|
|
128
|
+
|
|
129
|
+
for (const file of [...files].sort()) {
|
|
130
|
+
// Every closure file lives under one of the staging roots; map it back
|
|
131
|
+
// to its path relative to the shared source root.
|
|
132
|
+
let rel = null;
|
|
133
|
+
for (const kind of ["openapi", "asyncapi"]) {
|
|
134
|
+
const candidate = path.relative(config.stagingRoot(kind), file);
|
|
135
|
+
if (!candidate.startsWith("..") && !path.isAbsolute(candidate)) {
|
|
136
|
+
rel = candidate.split(path.sep).join("/");
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (rel === null) {
|
|
141
|
+
throw new SplitError(`${file} is outside every staging root; cannot place it in a part`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Preserving the relative path is the whole trick: the copy sits where
|
|
145
|
+
// the $refs already expect to find it.
|
|
146
|
+
const dest = path.join(partDir, rel);
|
|
147
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
148
|
+
fs.copyFileSync(file, dest);
|
|
149
|
+
|
|
150
|
+
// A file is "imported" when it comes from a subtree this part does not
|
|
151
|
+
// own. With --by target every part owns both subtrees, so nothing is
|
|
152
|
+
// imported and the split is clean by construction.
|
|
153
|
+
const foreign = by === "kind" && homeSubtree(rel) !== config.sources[partName];
|
|
154
|
+
(foreign ? imported : own).push(rel);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (imported.length > 0) {
|
|
158
|
+
fs.writeFileSync(
|
|
159
|
+
path.join(partDir, "IMPORTED.adoc"),
|
|
160
|
+
NOTE + imported.map((r) => `* \`${r}\`\n`).join("")
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
log(
|
|
165
|
+
`${partName.padEnd(14)} ${String(own.length).padStart(3)} own` +
|
|
166
|
+
(imported.length > 0 ? `, ${imported.length} imported` : "")
|
|
167
|
+
);
|
|
168
|
+
for (const rel of imported) log(` imported: ${rel}`);
|
|
169
|
+
|
|
170
|
+
results.push({ part: partName, dir: partDir, own, imported });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { split, partition, SplitError };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// What counts as a pre-release, and what that permits.
|
|
4
|
+
//
|
|
5
|
+
// API-Only design means implementation starts against a contract that is not
|
|
6
|
+
// finished. If the only way to obtain a bundle were a final release, teams would
|
|
7
|
+
// work around the tool by cloning the specification repository -- which is
|
|
8
|
+
// exactly the broad read access that publishing artifacts exists to avoid, with
|
|
9
|
+
// none of the guarantees.
|
|
10
|
+
//
|
|
11
|
+
// So pre-releases are first-class. The hard rule is the other half: a
|
|
12
|
+
// pre-release must never quietly satisfy a production build.
|
|
13
|
+
|
|
14
|
+
// SemVer, with the pre-release part being what follows a '-' before any '+'.
|
|
15
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
|
16
|
+
|
|
17
|
+
class VersionError extends Error {}
|
|
18
|
+
|
|
19
|
+
function parse(version) {
|
|
20
|
+
const match = SEMVER.exec(String(version));
|
|
21
|
+
if (!match) {
|
|
22
|
+
throw new VersionError(
|
|
23
|
+
`'${version}' is not a semantic version. Contracts are versioned with semver so that ` +
|
|
24
|
+
`consumers can reason about what a bump means.`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
major: Number(match[1]),
|
|
29
|
+
minor: Number(match[2]),
|
|
30
|
+
patch: Number(match[3]),
|
|
31
|
+
prerelease: match[4] || null,
|
|
32
|
+
build: match[5] || null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPrerelease(version) {
|
|
37
|
+
// -SNAPSHOT is not semver-legal as Maven spells it, but it is what Maven
|
|
38
|
+
// consumers expect, so it is recognised rather than rejected.
|
|
39
|
+
if (/-SNAPSHOT$/.test(String(version))) return true;
|
|
40
|
+
return parse(version).prerelease !== null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The npm dist-tag a version should be published under.
|
|
45
|
+
*
|
|
46
|
+
* A pre-release goes to `next`, never `latest`, so that `npm install <pkg>`
|
|
47
|
+
* cannot pick one up by accident.
|
|
48
|
+
*/
|
|
49
|
+
function npmDistTag(version) {
|
|
50
|
+
return isPrerelease(version) ? "next" : "latest";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a pre-release identifier is one we recognise, so that a typo in a
|
|
55
|
+
* release job surfaces immediately instead of publishing something odd.
|
|
56
|
+
*/
|
|
57
|
+
function describe(version) {
|
|
58
|
+
if (/-SNAPSHOT$/.test(String(version))) {
|
|
59
|
+
return { prerelease: true, kind: "snapshot" };
|
|
60
|
+
}
|
|
61
|
+
const parsed = parse(version);
|
|
62
|
+
if (!parsed.prerelease) return { prerelease: false, kind: "release" };
|
|
63
|
+
if (/^rc\.\d+$/.test(parsed.prerelease)) return { prerelease: true, kind: "rc" };
|
|
64
|
+
return { prerelease: true, kind: "other" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { parse, isPrerelease, npmDistTag, describe, VersionError, SEMVER };
|
package/src/version.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Setting info.version on a bundled document.
|
|
4
|
+
//
|
|
5
|
+
// This replaces two different sed expressions, which between them differed per
|
|
6
|
+
// specification type (OpenAPI quoted the version, AsyncAPI did not), did nothing
|
|
7
|
+
// at all when the pattern missed, and could match a `version:` field elsewhere in
|
|
8
|
+
// the document.
|
|
9
|
+
//
|
|
10
|
+
// The field is located structurally and only that one scalar is spliced. It is
|
|
11
|
+
// deliberately not a re-serialisation: re-emitting the document reformats
|
|
12
|
+
// everything around the edit, because the AsyncAPI CLI wraps long descriptions at
|
|
13
|
+
// a width no YAML emitter reproduces. Splicing keeps every other byte exactly as
|
|
14
|
+
// the bundler wrote it.
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const YAML = require("yaml");
|
|
18
|
+
|
|
19
|
+
class VersionError extends Error {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @returns {string} the document with info.version set to `version`
|
|
23
|
+
*/
|
|
24
|
+
function stamp(source, version, describeAs = "document") {
|
|
25
|
+
let doc;
|
|
26
|
+
try {
|
|
27
|
+
doc = YAML.parseDocument(source);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new VersionError(`${describeAs} is not valid YAML: ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
if (doc.errors && doc.errors.length > 0) {
|
|
32
|
+
throw new VersionError(`${describeAs} is not valid YAML: ${doc.errors[0].message}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!doc.get("info", true)) {
|
|
36
|
+
throw new VersionError(`${describeAs} has no top-level 'info' block to stamp a version into`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const node = doc.getIn(["info", "version"], true);
|
|
40
|
+
if (!node || !Array.isArray(node.range)) {
|
|
41
|
+
throw new VersionError(`${describeAs} has an 'info' block with no 'version' key`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [start, valueEnd] = node.range;
|
|
45
|
+
const original = source.slice(start, valueEnd);
|
|
46
|
+
// Keep whatever quoting the bundler chose, so stamping changes the version
|
|
47
|
+
// and nothing else about the line.
|
|
48
|
+
const quote = /^['"]/.test(original) ? original[0] : "";
|
|
49
|
+
return source.slice(0, start) + quote + version + quote + source.slice(valueEnd);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stampFile(file, version) {
|
|
53
|
+
const before = fs.readFileSync(file, "utf8");
|
|
54
|
+
const after = stamp(before, version, file);
|
|
55
|
+
|
|
56
|
+
// Prove the edit landed and left the document parseable, rather than
|
|
57
|
+
// trusting a substitution to have done what it looked like it did.
|
|
58
|
+
const check = YAML.parse(after);
|
|
59
|
+
if (!check || !check.info || String(check.info.version) !== String(version)) {
|
|
60
|
+
throw new VersionError(
|
|
61
|
+
`${file} still reports info.version ` +
|
|
62
|
+
`'${check && check.info ? check.info.version : "<none>"}' after stamping '${version}'`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
fs.writeFileSync(file, after);
|
|
66
|
+
return after;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { stamp, stampFile, VersionError };
|