@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.
@@ -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 };