@arc-e-tect/api-only-publisher 0.0.2 → 0.2.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/README.adoc +154 -17
- package/package.json +1 -1
- package/src/bundle-version.js +80 -0
- package/src/channels.js +6 -0
- package/src/cli.js +94 -17
- package/src/closure.js +5 -2
- package/src/config.js +35 -0
- package/src/index.js +5 -3
- package/src/init.js +7 -0
- package/src/pack.js +2 -1
- package/src/pipeline.js +18 -4
- package/src/unreferenced.js +53 -0
package/README.adoc
CHANGED
|
@@ -11,6 +11,16 @@ image:https://img.shields.io/github/v/release/Arc-E-Tect/SoftwareEngineeringDone
|
|
|
11
11
|
image:https://img.shields.io/badge/Node-22%2B-339933?logo=node.js[Node 22+,link=https://nodejs.org]
|
|
12
12
|
image:https://img.shields.io/badge/License-MIT-blue[MIT,link=LICENSE]
|
|
13
13
|
|
|
14
|
+
Links say where they go: § jumps to a section of this page, → opens another file in this repository, and ↗ leaves it.
|
|
15
|
+
|
|
16
|
+
// Remove this notice when 1.0.0 is released.
|
|
17
|
+
[IMPORTANT]
|
|
18
|
+
====
|
|
19
|
+
Until 1.0.0 is released, any release may change the command line, `apionly.yaml` or the published artifacts incompatibly, without a major version bump.
|
|
20
|
+
There are no users to break yet, and the interface is still being settled; 1.0.0 is the first release whose interface is kept stable.
|
|
21
|
+
Pin an exact version, and read the changelog before upgrading.
|
|
22
|
+
====
|
|
23
|
+
|
|
14
24
|
== What it is
|
|
15
25
|
|
|
16
26
|
`api-only-publisher` turns a library of small, reusable API description
|
|
@@ -53,23 +63,65 @@ pins, so they need no installation, and the only runtime dependency is `yaml`.
|
|
|
53
63
|
|
|
54
64
|
== Installation
|
|
55
65
|
|
|
56
|
-
|
|
66
|
+
The Publisher behaves the same however it is started.
|
|
67
|
+
What differs is where its version is pinned, and what checks that pin.
|
|
68
|
+
|
|
69
|
+
=== As a development dependency
|
|
70
|
+
|
|
71
|
+
The better fit for a specification library, and for any build that runs the Publisher more than once.
|
|
72
|
+
|
|
73
|
+
[source,console]
|
|
74
|
+
----
|
|
75
|
+
npm install --save-dev --save-exact @arc-e-tect/api-only-publisher
|
|
76
|
+
----
|
|
77
|
+
|
|
78
|
+
.package.json
|
|
79
|
+
[source,json]
|
|
80
|
+
----
|
|
81
|
+
{
|
|
82
|
+
"private": true,
|
|
83
|
+
"scripts": {
|
|
84
|
+
"apionly": "api-only-publisher"
|
|
85
|
+
},
|
|
86
|
+
"devDependencies": {
|
|
87
|
+
"@arc-e-tect/api-only-publisher": "<version>"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
----
|
|
57
91
|
|
|
58
92
|
[source,console]
|
|
59
93
|
----
|
|
60
|
-
|
|
94
|
+
npm ci
|
|
95
|
+
npm run apionly -- build
|
|
96
|
+
npm run apionly -- publish --target user-account
|
|
61
97
|
----
|
|
62
98
|
|
|
63
|
-
|
|
99
|
+
* The version is pinned in one place, `package.json`, and every command and every CI job runs that one.
|
|
100
|
+
* `package-lock.json` records the integrity hash of what was installed, so `npm ci` installs exactly those bytes or fails.
|
|
101
|
+
* A CI job can cache the installation, keyed on `package-lock.json`, instead of resolving the tool on every run.
|
|
102
|
+
* Renovate and Dependabot see the dependency, so an upgrade arrives as a pull request that moves the pin.
|
|
103
|
+
* The cost is a `package.json` and a `package-lock.json` in the library, and an `npm ci` before the first command.
|
|
104
|
+
|
|
105
|
+
=== With `npx`, installing nothing
|
|
106
|
+
|
|
107
|
+
The better fit for trying the tool, for scaffolding a library with `init`, and for a one-off command.
|
|
64
108
|
|
|
65
109
|
[source,console]
|
|
66
110
|
----
|
|
67
|
-
|
|
111
|
+
npx @arc-e-tect/api-only-publisher@<version> init my-api-library
|
|
112
|
+
npx @arc-e-tect/api-only-publisher@<version> build -C my-api-library
|
|
68
113
|
----
|
|
69
114
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
115
|
+
* Nothing is added to the repository.
|
|
116
|
+
* The version is pinned in every command instead, so each script and CI job that runs the Publisher has to name the same version, and nothing checks that they do.
|
|
117
|
+
* Without a version, `npx` runs whatever it resolves at that moment, which is not reproducible.
|
|
118
|
+
* Nothing records an integrity hash, and nothing notices when a newer version is released.
|
|
119
|
+
|
|
120
|
+
Either way, pin an exact version rather than a range.
|
|
121
|
+
This tool's output is expected to be reproducible — a specification library's golden fixtures assert it byte for byte — and a floating version quietly breaks that guarantee.
|
|
122
|
+
|
|
123
|
+
The bundlers are a separate matter.
|
|
124
|
+
However the Publisher itself is started, it fetches them with `npx`, at the versions `toolchain` in `apionly.yaml` pins.
|
|
73
125
|
|
|
74
126
|
== Commands
|
|
75
127
|
|
|
@@ -82,10 +134,11 @@ reproducible — a specification library's golden fixtures assert it byte for by
|
|
|
82
134
|
stands. This is how the *conventions* become reusable, as opposed to the code.
|
|
83
135
|
|
|
84
136
|
|`build`
|
|
85
|
-
|Stage, substitute placeholders, bundle, stamp
|
|
137
|
+
|Stage, substitute placeholders, bundle, stamp each published target's version, lint, and distribute.
|
|
86
138
|
|
|
87
139
|
|`lint`
|
|
88
140
|
|Lint what is already built, without rebuilding, for fast local feedback.
|
|
141
|
+
Without `--target`, it also fails on a fragment no target reaches, as <<unreferenced,A fragment no target reaches fails the lint §>> describes.
|
|
89
142
|
|
|
90
143
|
|`targets`
|
|
91
144
|
|List the declared targets, what each one builds, and whether it is distributed.
|
|
@@ -99,10 +152,10 @@ of their content.
|
|
|
99
152
|
drives a release: a target whose closure is untouched is not released, however
|
|
100
153
|
much else in the repository moved.
|
|
101
154
|
|
|
102
|
-
|`pack
|
|
103
|
-
|Archive each built target with a `manifest.json
|
|
155
|
+
|`pack`
|
|
156
|
+
|Archive each built, published target with a `manifest.json`, at the version its version file declares.
|
|
104
157
|
|
|
105
|
-
|`publish
|
|
158
|
+
|`publish`
|
|
106
159
|
|Pack once, then ship those same bytes to every configured channel.
|
|
107
160
|
|
|
108
161
|
|`split --out <dir>`
|
|
@@ -112,13 +165,15 @@ much else in the repository moved.
|
|
|
112
165
|
[source,console]
|
|
113
166
|
----
|
|
114
167
|
api-only-publisher init my-api-library
|
|
115
|
-
api-only-publisher build -C my-api-library
|
|
168
|
+
api-only-publisher build -C my-api-library
|
|
116
169
|
api-only-publisher build --target user-account --openapi
|
|
170
|
+
api-only-publisher publish --target user-account --pre-release rc.1
|
|
117
171
|
api-only-publisher targets
|
|
118
172
|
----
|
|
119
173
|
|
|
120
174
|
`--target` may be repeated. `-C <dir>` runs as if started in `<dir>`; without it
|
|
121
175
|
the configuration is found by walking up from the current directory.
|
|
176
|
+
`--pre-release <ids>` appends pre-release identifiers to each target's version, as <<versions,Versions §>> describes.
|
|
122
177
|
|
|
123
178
|
== What `build` actually does
|
|
124
179
|
|
|
@@ -139,13 +194,48 @@ indented YAML scalar.
|
|
|
139
194
|
. **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every
|
|
140
195
|
`$ref` into one flat, self-contained document under `dist/<target>/`.
|
|
141
196
|
|
|
142
|
-
. **Stamp the version
|
|
197
|
+
. **Stamp the version** of each published target on its *finished* document: the version its version file declares, with any `--pre-release` identifiers appended.
|
|
198
|
+
A `publish: false` target keeps the version its source declares.
|
|
143
199
|
|
|
144
200
|
. **Lint** the finished document, so a broken fragment surfaces against the
|
|
145
201
|
target it actually affects rather than at deploy time.
|
|
146
202
|
|
|
147
203
|
. **Distribute**, unless the target is `publish: false`.
|
|
148
204
|
|
|
205
|
+
[[versions]]
|
|
206
|
+
== Versions
|
|
207
|
+
|
|
208
|
+
Every published target has a version of its own, and it is kept in the library, beside the fragments it describes.
|
|
209
|
+
|
|
210
|
+
.specs/openapi/bundles/user-account.bundle.properties
|
|
211
|
+
[source,properties]
|
|
212
|
+
----
|
|
213
|
+
# The version of the user-account contract, for every document it builds.
|
|
214
|
+
# Semantic: major for a breaking change, minor for an additive one, patch for
|
|
215
|
+
# anything else. Change it in the same commit as the fragments it describes.
|
|
216
|
+
version=2.1.0
|
|
217
|
+
----
|
|
218
|
+
|
|
219
|
+
By convention the file is `<target>.bundle.properties`, beside the target's first bundle root: its OpenAPI one, or its AsyncAPI one when it has no OpenAPI document.
|
|
220
|
+
A target that keeps its version elsewhere names the file with `versionFile`, relative to `apionly.yaml`.
|
|
221
|
+
The syntax is that of a Java properties file, so a JVM build can read the same file.
|
|
222
|
+
|
|
223
|
+
`build`, `pack` and `publish` read the version of every target they act on before they act on any of them.
|
|
224
|
+
A missing file, a file without a `version`, or a version that is not semantic stops the command before anything has been built or shipped.
|
|
225
|
+
A `publish: false` target is never shipped, so it needs no version file.
|
|
226
|
+
|
|
227
|
+
The version is not a command-line argument, and that is deliberate.
|
|
228
|
+
A version on the command line lets two builds of the same commit publish different versions, and lets through a version nobody reviewed.
|
|
229
|
+
A version in the library changes in the same commit as the fragments it describes, is reviewed with them, and is the same whoever builds that commit.
|
|
230
|
+
|
|
231
|
+
The file holds a release version.
|
|
232
|
+
A pre-release is that version with identifiers appended when it is built: `publish --pre-release rc.1` publishes `2.1.0-rc.1`.
|
|
233
|
+
Cutting one never means editing the file, and then remembering to edit it back, which is why a version file that declares a pre-release is refused.
|
|
234
|
+
|
|
235
|
+
A version names one set of documents.
|
|
236
|
+
Publishing different documents under a version that was published before, after editing a fragment without changing the version file, is refused by the API-Only Subscriber in every project that locked the earlier ones.
|
|
237
|
+
Change the version in the same commit as the fragments instead.
|
|
238
|
+
|
|
149
239
|
== Independent versioning, and the dependency closure
|
|
150
240
|
|
|
151
241
|
Giving each target its own version has one non-obvious consequence, and the
|
|
@@ -245,9 +335,9 @@ rather than a silent overwrite.
|
|
|
245
335
|
|Channel |What it is for
|
|
246
336
|
|
|
247
337
|
|`file`
|
|
248
|
-
|A local directory
|
|
249
|
-
testable end to end with no infrastructure, and as a local-iteration escape
|
|
250
|
-
|
|
338
|
+
|A local directory, laid out as `<directory>/<target>/<version>/`.
|
|
339
|
+
Not a distribution mechanism between repositories: it exists so the pipeline is testable end to end with no infrastructure, so a library can share one build with the projects that implement it, and as a local-iteration escape hatch.
|
|
340
|
+
Publishing a version again replaces that version and keeps the others; with `clean: true` only the version being published is kept, for a directory that is a build output rather than a record of releases.
|
|
251
341
|
|
|
252
342
|
|`maven`
|
|
253
343
|
|A path publishes into a repository layout on disk; an `http(s)` URL deploys to a
|
|
@@ -290,6 +380,7 @@ guarantees.
|
|
|
290
380
|
|
|
291
381
|
So a pre-release version is first-class: `2.1.0-rc.1`, or Maven's `-SNAPSHOT`
|
|
292
382
|
spelling, which is not semver-legal but is what Maven consumers expect.
|
|
383
|
+
It is cut from the version in the target's version file: `publish --pre-release rc.1` publishes `2.1.0-rc.1` when the file declares `2.1.0`.
|
|
293
384
|
|
|
294
385
|
The hard rule is the other half of it. A pre-release must never quietly satisfy a
|
|
295
386
|
production build:
|
|
@@ -328,13 +419,24 @@ defaults:
|
|
|
328
419
|
build:
|
|
329
420
|
staging: build/staging
|
|
330
421
|
dist: dist
|
|
422
|
+
reports:
|
|
423
|
+
lint: build/reports/lint
|
|
424
|
+
lint:
|
|
425
|
+
unreferenced: error # a fragment no target reaches: error, warn or off
|
|
331
426
|
|
|
332
427
|
toolchain:
|
|
333
428
|
redocly: "@redocly/cli@2.52.0"
|
|
334
429
|
asyncapi: "@asyncapi/cli@6.0.2"
|
|
335
430
|
|
|
431
|
+
channels:
|
|
432
|
+
file:
|
|
433
|
+
directory: build/publish
|
|
434
|
+
clean: true # keep only the version being published
|
|
435
|
+
|
|
336
436
|
targets:
|
|
337
437
|
user-account:
|
|
438
|
+
# versionFile: versions/user-account.properties
|
|
439
|
+
# default: user-account.bundle.properties beside the first bundle root
|
|
338
440
|
openapi:
|
|
339
441
|
bundle: bundles/user-account_openapi_structure.yaml
|
|
340
442
|
asyncapi:
|
|
@@ -374,6 +476,21 @@ from the directory it was told to use, so a snippet one level up was invisible
|
|
|
374
476
|
and the workaround was to move the Markdown files. The search now starts at the
|
|
375
477
|
staged source root.
|
|
376
478
|
|
|
479
|
+
[[unreferenced]]
|
|
480
|
+
=== A fragment no target reaches fails the lint
|
|
481
|
+
|
|
482
|
+
A linter checks documents, and a fragment reaches a document only through a `$ref`.
|
|
483
|
+
A fragment that no target references is therefore never linted, however wrong it is, until the day a target starts to use it.
|
|
484
|
+
|
|
485
|
+
So `lint`, run without `--target`, also lists every YAML file under the source root that no target's closure reaches, in `build/reports/lint/unreferenced.txt`, and fails when there is one.
|
|
486
|
+
Every target counts, `publish: false` ones included, because they are linted too.
|
|
487
|
+
That reports a fragment that does not comply before anything uses it, and finds a definition left behind by a change that stopped using it.
|
|
488
|
+
|
|
489
|
+
`lint.unreferenced` in `apionly.yaml` sets what happens: `error` by default, `warn` to report without failing, or `off` not to look.
|
|
490
|
+
`lint --target` does not look, because an unreferenced fragment belongs to no target.
|
|
491
|
+
|
|
492
|
+
`lint` lints every document even when one of them fails, and names every failure at the end, so a single run reports all of them.
|
|
493
|
+
|
|
377
494
|
=== Version stamping edits one scalar
|
|
378
495
|
|
|
379
496
|
`info.version` is located structurally and that one scalar is spliced. It is
|
|
@@ -411,11 +528,17 @@ layout. In order of what usually needs changing:
|
|
|
411
528
|
|The lint rules
|
|
412
529
|
|`defaults.openapi.lint`
|
|
413
530
|
|
|
531
|
+
|Where lint reports go
|
|
532
|
+
|`reports.lint`, defaulting to `build/reports/lint/<target>/<kind>.txt`
|
|
533
|
+
|
|
414
534
|
|Where built documents go
|
|
415
535
|
|`build.dist`, and `distribution` while it still exists
|
|
416
536
|
|
|
417
537
|
|Where artifacts are published
|
|
418
538
|
|`channels`
|
|
539
|
+
|
|
540
|
+
|Where a target's version is kept
|
|
541
|
+
|Its version file, or `targets.<target>.versionFile`
|
|
419
542
|
|===
|
|
420
543
|
|
|
421
544
|
Beyond that: the `common/` versus `<product>/<target>/` split is one convention
|
|
@@ -435,6 +558,8 @@ commit altered, and every manifest records the closure hash of what was
|
|
|
435
558
|
published, so the hooks a diff gate would need already exist -- but the gate
|
|
436
559
|
itself is deliberately not built.
|
|
437
560
|
|
|
561
|
+
A breaking change is declared where it is made: the major version in the target's version file changes in the same commit as the fragments it breaks.
|
|
562
|
+
|
|
438
563
|
A specification library should write down its own policy. The one this repository
|
|
439
564
|
uses is at `usable-suspects/docs/breaking-changes.adoc`, and is a reasonable
|
|
440
565
|
starting point to copy.
|
|
@@ -480,7 +605,7 @@ npm test
|
|
|
480
605
|
----
|
|
481
606
|
|
|
482
607
|
Covers placeholder substitution (including each of the three defects fixed on
|
|
483
|
-
the way in), version stamping, configuration parsing, and the scaffold.
|
|
608
|
+
the way in), version stamping, version files, configuration parsing, every command, and the scaffold.
|
|
484
609
|
|
|
485
610
|
The end-to-end guarantee lives elsewhere, in the specification library's own
|
|
486
611
|
golden fixtures: they assert that the documents this tool produces are unchanged,
|
|
@@ -493,6 +618,18 @@ fetches and verifies. They share one seam — an archive plus a `manifest.json`
|
|
|
493
618
|
and nothing else, which is why they can be versioned independently while living
|
|
494
619
|
in one repository.
|
|
495
620
|
|
|
621
|
+
This document is a reference for this half alone. For how the two are put
|
|
622
|
+
together in a real project, see link:../docs/README.adoc[the manuals], which are
|
|
623
|
+
organised by where the API descriptions live relative to the code that implements
|
|
624
|
+
them: link:../docs/in-the-implementation-project.adoc[in the implementation
|
|
625
|
+
project], link:../docs/elsewhere-in-the-repository.adoc[elsewhere in the same
|
|
626
|
+
repository], link:../docs/in-its-own-repository.adoc[in a repository of their
|
|
627
|
+
own]. The first uses only `build` and reads the result directly; the second
|
|
628
|
+
publishes to the `file` channel, and the third to a registry. If you are
|
|
629
|
+
replacing a script that copies a document into `src/main/resources`, start at
|
|
630
|
+
link:../docs/migrating-a-checked-in-document.adoc[Migrating a checked-in
|
|
631
|
+
document].
|
|
632
|
+
|
|
496
633
|
== License
|
|
497
634
|
|
|
498
635
|
MIT. See link:LICENSE[LICENSE].
|
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// A target's version.
|
|
4
|
+
//
|
|
5
|
+
// It is read from a version file kept with the target's specification, rather
|
|
6
|
+
// than passed in by whatever runs the build. A version given on the command line
|
|
7
|
+
// lets two builds of one commit publish different versions, and lets through a
|
|
8
|
+
// version nobody reviewed. A version in the library changes in the same commit as
|
|
9
|
+
// the fragments it describes, and is reviewed with them.
|
|
10
|
+
//
|
|
11
|
+
// The file holds a release version. A pre-release is that version with
|
|
12
|
+
// identifiers appended when it is built, so cutting one never means editing the
|
|
13
|
+
// file -- and then remembering to edit it back.
|
|
14
|
+
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
|
|
17
|
+
const { parse, isPrerelease } = require("./version-policy");
|
|
18
|
+
|
|
19
|
+
class BundleVersionError extends Error {}
|
|
20
|
+
|
|
21
|
+
// Java-properties style -- `key=value` or `key: value`, with `#` and `!` comments
|
|
22
|
+
// -- so a JVM build can read the same file with java.util.Properties. Only what a
|
|
23
|
+
// version file needs: no escapes, no continuation lines.
|
|
24
|
+
function readProperties(file) {
|
|
25
|
+
const properties = {};
|
|
26
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
27
|
+
const line = raw.trim();
|
|
28
|
+
if (line === "" || line.startsWith("#") || line.startsWith("!")) continue;
|
|
29
|
+
const separator = line.search(/[=:]/);
|
|
30
|
+
if (separator < 0) continue;
|
|
31
|
+
properties[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
32
|
+
}
|
|
33
|
+
return properties;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The version `target` is built, packed and published as.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} config a loaded configuration
|
|
40
|
+
* @param {string} target the target whose version to read
|
|
41
|
+
* @param {{preRelease?: string|null}} options pre-release identifiers to append, such as `rc.1`
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function versionOf(config, target, { preRelease = null } = {}) {
|
|
45
|
+
const file = config.versionFile(target);
|
|
46
|
+
if (!fs.existsSync(file)) {
|
|
47
|
+
throw new BundleVersionError(
|
|
48
|
+
`target '${target}': no version file at ${file}. Create it with a line such as 'version=1.0.0', ` +
|
|
49
|
+
`or point targets.${target}.versionFile at the file that holds its version.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const declared = readProperties(file).version;
|
|
53
|
+
if (!declared) {
|
|
54
|
+
throw new BundleVersionError(`target '${target}': ${file} declares no version`);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
parse(declared);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new BundleVersionError(`target '${target}': ${file}: ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (isPrerelease(declared)) {
|
|
62
|
+
throw new BundleVersionError(
|
|
63
|
+
`target '${target}': ${file} declares '${declared}', but a version file holds a release version. ` +
|
|
64
|
+
`Declare the release it leads to, and cut the pre-release with --pre-release <identifiers>.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (!preRelease) return declared;
|
|
68
|
+
|
|
69
|
+
const version = `${declared}-${preRelease}`;
|
|
70
|
+
try {
|
|
71
|
+
parse(version);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new BundleVersionError(
|
|
74
|
+
`--pre-release '${preRelease}' does not make a semantic version of ${declared}: ${error.message}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return version;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { versionOf, readProperties, BundleVersionError };
|
package/src/channels.js
CHANGED
|
@@ -27,6 +27,12 @@ function publishFile(archive, manifest, options, log) {
|
|
|
27
27
|
// Relative to the library root, not to wherever the CLI happened to be run.
|
|
28
28
|
const dir = path.resolve(options.baseDir || ".", options.directory || "publish");
|
|
29
29
|
const targetDir = path.join(dir, manifest.target, manifest.version);
|
|
30
|
+
// `clean: true` keeps only the version being published. Used where the
|
|
31
|
+
// directory is a build output rather than a record of releases: an older
|
|
32
|
+
// version is published again from the commit that declared it.
|
|
33
|
+
if (options.clean === true) {
|
|
34
|
+
fs.rmSync(path.join(dir, manifest.target), { recursive: true, force: true });
|
|
35
|
+
}
|
|
30
36
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
31
37
|
|
|
32
38
|
const archiveDest = path.join(targetDir, path.basename(archive));
|
package/src/cli.js
CHANGED
|
@@ -14,23 +14,27 @@ const { changedSince, ChangedError } = require("./changed");
|
|
|
14
14
|
const { split, SplitError } = require("./split");
|
|
15
15
|
const { publish, ChannelError } = require("./channels");
|
|
16
16
|
const { VersionError: PolicyError, describe } = require("./version-policy");
|
|
17
|
+
const { versionOf, BundleVersionError } = require("./bundle-version");
|
|
18
|
+
const { unreferenced } = require("./unreferenced");
|
|
17
19
|
|
|
18
20
|
const USAGE = `api-only-publisher -- build and distribute API description documents
|
|
19
21
|
|
|
20
22
|
Usage:
|
|
21
23
|
api-only-publisher init [dir] [--force]
|
|
22
|
-
api-only-publisher build [--target <name>]... [--
|
|
24
|
+
api-only-publisher build [--target <name>]... [--pre-release <ids>] [--openapi|--asyncapi]
|
|
23
25
|
api-only-publisher lint [--target <name>]...
|
|
24
26
|
api-only-publisher targets
|
|
25
27
|
api-only-publisher closure [--target <name>]...
|
|
26
28
|
api-only-publisher changed --since <ref>
|
|
27
|
-
api-only-publisher pack --
|
|
28
|
-
api-only-publisher publish --
|
|
29
|
+
api-only-publisher pack [--pre-release <ids>] [--target <name>]... [--out <dir>]
|
|
30
|
+
api-only-publisher publish [--pre-release <ids>] [--target <name>]... [--channel <name>]... [--out <dir>]
|
|
29
31
|
api-only-publisher split --out <dir> [--by kind|target]
|
|
30
32
|
|
|
31
33
|
Options:
|
|
32
34
|
--target <name> Restrict to one target; repeat for several. Default: all.
|
|
33
|
-
--
|
|
35
|
+
--pre-release <ids>
|
|
36
|
+
build/pack/publish: append pre-release identifiers, such as
|
|
37
|
+
rc.1, to each target's version.
|
|
34
38
|
--openapi Only build OpenAPI documents.
|
|
35
39
|
--asyncapi Only build AsyncAPI documents.
|
|
36
40
|
--force init only: overwrite files that already exist.
|
|
@@ -43,11 +47,13 @@ Options:
|
|
|
43
47
|
-h, --help Show this help.
|
|
44
48
|
|
|
45
49
|
What gets built, and where each document goes, is declared in apionly.yaml.
|
|
50
|
+
A published target's version is read from its version file:
|
|
51
|
+
<target>.bundle.properties beside its bundle root, or the file its versionFile names.
|
|
46
52
|
`;
|
|
47
53
|
|
|
48
54
|
function parseArgs(argv) {
|
|
49
55
|
const options = {
|
|
50
|
-
targets: [], kinds: null,
|
|
56
|
+
targets: [], kinds: null, preRelease: null, quiet: false, force: false,
|
|
51
57
|
dir: process.cwd(), since: null, out: null, channels: null, by: "kind",
|
|
52
58
|
};
|
|
53
59
|
const positional = [];
|
|
@@ -60,7 +66,13 @@ function parseArgs(argv) {
|
|
|
60
66
|
};
|
|
61
67
|
switch (arg) {
|
|
62
68
|
case "--target": options.targets.push(next()); break;
|
|
63
|
-
case "--
|
|
69
|
+
case "--pre-release": options.preRelease = next(); break;
|
|
70
|
+
case "--version":
|
|
71
|
+
throw new ConfigError(
|
|
72
|
+
"--version is no longer accepted: each published target's version is read from its version " +
|
|
73
|
+
"file, <target>.bundle.properties beside its bundle root. Pass --pre-release <ids> to cut a " +
|
|
74
|
+
"pre-release of it."
|
|
75
|
+
);
|
|
64
76
|
case "--openapi": options.kinds = (options.kinds || []).concat("openapi"); break;
|
|
65
77
|
case "--asyncapi": options.kinds = (options.kinds || []).concat("asyncapi"); break;
|
|
66
78
|
case "--force": options.force = true; break;
|
|
@@ -79,6 +91,21 @@ function parseArgs(argv) {
|
|
|
79
91
|
return { options, positional };
|
|
80
92
|
}
|
|
81
93
|
|
|
94
|
+
// The targets pack and publish write an archive for: those selected, less any
|
|
95
|
+
// that are never published. Only these need a closure, so only these need to have
|
|
96
|
+
// been staged -- building one target must not depend on every other being staged.
|
|
97
|
+
function shippedTargets(config, targets) {
|
|
98
|
+
return Object.keys(config.targets)
|
|
99
|
+
.filter((target) => (!targets || targets.includes(target)) && config.isPublished(target));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The version of each target in `names`, keyed by target. Every one is resolved
|
|
103
|
+
// before any target is acted on, so a missing or malformed version file stops the
|
|
104
|
+
// command before it has built or shipped anything.
|
|
105
|
+
function versionsOf(config, names, preRelease) {
|
|
106
|
+
return new Map(names.map((target) => [target, versionOf(config, target, { preRelease })]));
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
async function main(argv) {
|
|
83
110
|
const { options, positional } = parseArgs(argv);
|
|
84
111
|
const command = positional[0];
|
|
@@ -121,29 +148,76 @@ async function main(argv) {
|
|
|
121
148
|
return 0;
|
|
122
149
|
}
|
|
123
150
|
case "build": {
|
|
124
|
-
|
|
151
|
+
// A target is stamped only if it is published: a documentation view has no
|
|
152
|
+
// version of its own, and needs no version file.
|
|
153
|
+
const kinds = options.kinds || ["openapi", "asyncapi"];
|
|
154
|
+
const stamped = Object.keys(config.targets).filter((target) =>
|
|
155
|
+
(!targets || targets.includes(target)) && config.isPublished(target) &&
|
|
156
|
+
kinds.some((kind) => config.targets[target][kind]));
|
|
157
|
+
const versions = versionsOf(config, stamped, options.preRelease);
|
|
158
|
+
const results = build(config, {
|
|
159
|
+
targets, kinds, log, versionOf: (target) => versions.get(target) || null,
|
|
160
|
+
});
|
|
125
161
|
log(`\nBuilt ${results.length} document(s).`);
|
|
126
162
|
return 0;
|
|
127
163
|
}
|
|
128
164
|
case "lint": {
|
|
129
165
|
// Lint without rebuilding, for fast local feedback on what is already
|
|
130
|
-
// in dist/.
|
|
166
|
+
// in dist/. Every selected document is linted even when one fails, so
|
|
167
|
+
// one run reports every failure rather than only the first.
|
|
131
168
|
const fs = require("fs");
|
|
132
169
|
const { lint } = require("./pipeline");
|
|
170
|
+
const failures = [];
|
|
133
171
|
let linted = 0;
|
|
134
172
|
for (const kind of ["openapi", "asyncapi"]) {
|
|
135
173
|
for (const target of config.targetsFor(kind)) {
|
|
136
174
|
if (targets && !targets.includes(target)) continue;
|
|
137
175
|
const file = path.join(config.distDir(target), config.outputName(kind));
|
|
138
176
|
if (!fs.existsSync(file)) {
|
|
139
|
-
|
|
177
|
+
failures.push(`${target} (${kind}): ${file} does not exist; run 'build' first`);
|
|
178
|
+
continue;
|
|
140
179
|
}
|
|
141
180
|
log(`=== ${target} (${kind}) ===`);
|
|
142
|
-
|
|
181
|
+
try {
|
|
182
|
+
lint(config, kind, file, log, {
|
|
183
|
+
report: true,
|
|
184
|
+
reportFile: config.lintReport(target, kind),
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!(error instanceof BuildError)) throw error;
|
|
188
|
+
console.error(error.message);
|
|
189
|
+
failures.push(`${target} (${kind})`);
|
|
190
|
+
}
|
|
143
191
|
linted += 1;
|
|
144
192
|
}
|
|
145
193
|
}
|
|
146
194
|
log(`\nLinted ${linted} document(s).`);
|
|
195
|
+
|
|
196
|
+
// A fragment no target reaches is never linted, so it is looked for
|
|
197
|
+
// here -- only when every target is linted, since it belongs to none.
|
|
198
|
+
const mode = config.lintUnreferenced();
|
|
199
|
+
let orphaned = null;
|
|
200
|
+
if (!targets && mode !== "off") {
|
|
201
|
+
prepare(config);
|
|
202
|
+
const orphans = unreferenced(config);
|
|
203
|
+
const reportFile = config.unreferencedReport();
|
|
204
|
+
fs.mkdirSync(path.dirname(reportFile), { recursive: true });
|
|
205
|
+
fs.writeFileSync(reportFile, orphans.map((file) => `${file}\n`).join(""));
|
|
206
|
+
if (orphans.length > 0) {
|
|
207
|
+
orphaned =
|
|
208
|
+
`${orphans.length} fragment(s) not reachable from any target, so nothing lints them:\n` +
|
|
209
|
+
orphans.map((file) => ` ${file}`).join("\n") +
|
|
210
|
+
"\nReference each one from a target's bundle, or delete it.";
|
|
211
|
+
if (mode === "warn") log(orphaned);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const problems = [];
|
|
216
|
+
if (failures.length > 0) {
|
|
217
|
+
problems.push(`lint failed for ${failures.length} document(s): ${failures.join("; ")}`);
|
|
218
|
+
}
|
|
219
|
+
if (orphaned && mode === "error") problems.push(orphaned);
|
|
220
|
+
if (problems.length > 0) throw new BuildError(problems.join("\n\n"));
|
|
147
221
|
return 0;
|
|
148
222
|
}
|
|
149
223
|
case "closure": {
|
|
@@ -167,9 +241,10 @@ async function main(argv) {
|
|
|
167
241
|
return 0;
|
|
168
242
|
}
|
|
169
243
|
case "pack": {
|
|
170
|
-
if (!options.version) throw new ConfigError("pack requires --version <v>");
|
|
171
244
|
const outDir = path.resolve(options.dir, options.out || "build/packages");
|
|
172
|
-
const
|
|
245
|
+
const shipped = shippedTargets(config, targets);
|
|
246
|
+
const versions = versionsOf(config, shipped, options.preRelease);
|
|
247
|
+
const closures = forTargets(config, undefined, shipped);
|
|
173
248
|
let packed = 0;
|
|
174
249
|
for (const target of Object.keys(config.targets)) {
|
|
175
250
|
if (targets && !targets.includes(target)) continue;
|
|
@@ -179,7 +254,7 @@ async function main(argv) {
|
|
|
179
254
|
}
|
|
180
255
|
const closure = closures.get(target);
|
|
181
256
|
pack(config, target, {
|
|
182
|
-
version:
|
|
257
|
+
version: versions.get(target),
|
|
183
258
|
closureSha256: closure ? closure.sha256 : null,
|
|
184
259
|
outDir, log,
|
|
185
260
|
});
|
|
@@ -189,14 +264,15 @@ async function main(argv) {
|
|
|
189
264
|
return 0;
|
|
190
265
|
}
|
|
191
266
|
case "publish": {
|
|
192
|
-
if (!options.version) throw new ConfigError("publish requires --version <v>");
|
|
193
267
|
const outDir = path.resolve(options.dir, options.out || "build/packages");
|
|
194
268
|
const configured = config.channels || {};
|
|
195
269
|
const names = options.channels || Object.keys(configured);
|
|
196
270
|
if (names.length === 0) {
|
|
197
271
|
throw new ConfigError("no channels configured; add a `channels:` block or pass --channel");
|
|
198
272
|
}
|
|
199
|
-
const
|
|
273
|
+
const shipped = shippedTargets(config, targets);
|
|
274
|
+
const versions = versionsOf(config, shipped, options.preRelease);
|
|
275
|
+
const closures = forTargets(config, undefined, shipped);
|
|
200
276
|
let published = 0;
|
|
201
277
|
for (const target of Object.keys(config.targets)) {
|
|
202
278
|
if (targets && !targets.includes(target)) continue;
|
|
@@ -204,7 +280,7 @@ async function main(argv) {
|
|
|
204
280
|
const closure = closures.get(target);
|
|
205
281
|
// Packed once, then shipped unchanged to every channel.
|
|
206
282
|
const { archive, manifest } = pack(config, target, {
|
|
207
|
-
version:
|
|
283
|
+
version: versions.get(target),
|
|
208
284
|
closureSha256: closure ? closure.sha256 : null,
|
|
209
285
|
outDir, log,
|
|
210
286
|
});
|
|
@@ -239,7 +315,8 @@ function report(error) {
|
|
|
239
315
|
error instanceof PlaceholderError || error instanceof VersionError ||
|
|
240
316
|
error instanceof ClosureError || error instanceof PackError ||
|
|
241
317
|
error instanceof ChangedError || error instanceof SplitError ||
|
|
242
|
-
error instanceof ChannelError || error instanceof PolicyError
|
|
318
|
+
error instanceof ChannelError || error instanceof PolicyError ||
|
|
319
|
+
error instanceof BundleVersionError) {
|
|
243
320
|
console.error(`Error: ${error.message}`);
|
|
244
321
|
process.exitCode = 1;
|
|
245
322
|
} else {
|
package/src/closure.js
CHANGED
|
@@ -92,15 +92,18 @@ function hash(files, root) {
|
|
|
92
92
|
* Closures for every target that declares a bundle of the given kinds.
|
|
93
93
|
*
|
|
94
94
|
* The staged tree must already exist; callers stage first so that the closure
|
|
95
|
-
* covers substituted content.
|
|
95
|
+
* covers substituted content. Pass `only` to compute closures for those targets
|
|
96
|
+
* alone: a target outside it is never read, so its bundle root need not be staged.
|
|
96
97
|
*
|
|
98
|
+
* @param {string[]|null} only the targets to compute closures for; every target when null
|
|
97
99
|
* @returns {Map<string, {files: string[], sha256: string, byKind: object}>}
|
|
98
100
|
*/
|
|
99
|
-
function forTargets(config, kinds = ["openapi", "asyncapi"]) {
|
|
101
|
+
function forTargets(config, kinds = ["openapi", "asyncapi"], only = null) {
|
|
100
102
|
const result = new Map();
|
|
101
103
|
|
|
102
104
|
for (const kind of kinds) {
|
|
103
105
|
for (const target of config.targetsFor(kind)) {
|
|
106
|
+
if (only && !only.includes(target)) continue;
|
|
104
107
|
const entry = config.bundlePath(target, kind);
|
|
105
108
|
if (!fs.existsSync(entry)) {
|
|
106
109
|
throw new ClosureError(
|
package/src/config.js
CHANGED
|
@@ -92,6 +92,8 @@ function load(configPath) {
|
|
|
92
92
|
defaults: parsed.defaults || {},
|
|
93
93
|
toolchain: parsed.toolchain || {},
|
|
94
94
|
build: parsed.build || {},
|
|
95
|
+
reports: parsed.reports || {},
|
|
96
|
+
lint: parsed.lint || {},
|
|
95
97
|
distribution: parsed.distribution || null,
|
|
96
98
|
channels: parsed.channels || {},
|
|
97
99
|
targets,
|
|
@@ -121,6 +123,25 @@ function load(configPath) {
|
|
|
121
123
|
const lint = (this.defaults[kind] || {}).lint;
|
|
122
124
|
return lint ? path.resolve(this.root, lint) : null;
|
|
123
125
|
},
|
|
126
|
+
lintReport(target, kind) {
|
|
127
|
+
const reports = this.reports.lint || "build/reports/lint";
|
|
128
|
+
return path.resolve(this.root, reports, target, `${kind}.txt`);
|
|
129
|
+
},
|
|
130
|
+
// Where lint lists the fragments no target reaches.
|
|
131
|
+
unreferencedReport() {
|
|
132
|
+
const reports = this.reports.lint || "build/reports/lint";
|
|
133
|
+
return path.resolve(this.root, reports, "unreferenced.txt");
|
|
134
|
+
},
|
|
135
|
+
// What lint does about a fragment no target reaches: `error`, the default,
|
|
136
|
+
// fails the lint; `warn` reports it; `off` does not look.
|
|
137
|
+
lintUnreferenced() {
|
|
138
|
+
const configured = this.lint.unreferenced;
|
|
139
|
+
const mode = configured === undefined ? "error" : configured === false ? "off" : configured;
|
|
140
|
+
if (!["error", "warn", "off"].includes(mode)) {
|
|
141
|
+
throw new ConfigError(`lint.unreferenced must be error, warn or off, not ${JSON.stringify(configured)}`);
|
|
142
|
+
}
|
|
143
|
+
return mode;
|
|
144
|
+
},
|
|
124
145
|
tool(name) {
|
|
125
146
|
return requireString(this.toolchain[name], `toolchain.${name}`);
|
|
126
147
|
},
|
|
@@ -136,6 +157,20 @@ function load(configPath) {
|
|
|
136
157
|
bundlePath(target, kind) {
|
|
137
158
|
return path.join(this.stagingDir(kind), this.targets[target][kind].bundle);
|
|
138
159
|
},
|
|
160
|
+
// The file a target's version is read from: the target's own `versionFile`,
|
|
161
|
+
// relative to this configuration, or else <target>.bundle.properties beside
|
|
162
|
+
// the target's first bundle root in the hand-authored tree -- so a version
|
|
163
|
+
// sits with the fragments it describes, and changes in the same commit.
|
|
164
|
+
versionFile(target) {
|
|
165
|
+
const spec = this.targets[target];
|
|
166
|
+
if (spec.versionFile !== undefined) {
|
|
167
|
+
return path.resolve(this.root, requireString(spec.versionFile, `targets.${target}.versionFile`));
|
|
168
|
+
}
|
|
169
|
+
const kind = ["openapi", "asyncapi"].find((k) => spec[k]);
|
|
170
|
+
const bundleRoot = path.join(
|
|
171
|
+
this.sourceRoot(), requireString(this.sources[kind], `sources.${kind}`), spec[kind].bundle);
|
|
172
|
+
return path.join(path.dirname(bundleRoot), `${target}.bundle.properties`);
|
|
173
|
+
},
|
|
139
174
|
// Where a distributed document is copied to, from the transitional
|
|
140
175
|
// `distribution` block. Null once that block is gone.
|
|
141
176
|
destinationDir(target) {
|
package/src/index.js
CHANGED
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
// The programmatic surface.
|
|
4
4
|
//
|
|
5
5
|
// The CLI is the usual way in, but a release job that wants to decide something
|
|
6
|
-
// for itself -- which targets changed, what a closure hashes to,
|
|
7
|
-
// version is a pre-release -- should not have to parse
|
|
8
|
-
// out. Everything the CLI does is available here directly.
|
|
6
|
+
// for itself -- which targets changed, what a closure hashes to, which version a
|
|
7
|
+
// target is at, whether a version is a pre-release -- should not have to parse
|
|
8
|
+
// console output to find out. Everything the CLI does is available here directly.
|
|
9
9
|
|
|
10
10
|
module.exports = {
|
|
11
11
|
...require("./config"),
|
|
12
12
|
...require("./placeholders"),
|
|
13
13
|
...require("./version"),
|
|
14
14
|
...require("./version-policy"),
|
|
15
|
+
...require("./bundle-version"),
|
|
15
16
|
...require("./closure"),
|
|
17
|
+
...require("./unreferenced"),
|
|
16
18
|
...require("./aggregate"),
|
|
17
19
|
...require("./pipeline"),
|
|
18
20
|
...require("./pack"),
|
package/src/init.js
CHANGED
|
@@ -147,6 +147,12 @@ dist/
|
|
|
147
147
|
node_modules/
|
|
148
148
|
`;
|
|
149
149
|
|
|
150
|
+
const VERSION = `# The version of the example-service contract, for every document it builds.
|
|
151
|
+
# Semantic: major for a breaking change, minor for an additive one, patch for
|
|
152
|
+
# anything else. Change it in the same commit as the fragments it describes.
|
|
153
|
+
version=0.1.0
|
|
154
|
+
`;
|
|
155
|
+
|
|
150
156
|
const FILES = {
|
|
151
157
|
"apionly.yaml": CONFIG,
|
|
152
158
|
".redocly.yaml": REDOCLY,
|
|
@@ -155,6 +161,7 @@ const FILES = {
|
|
|
155
161
|
"specs/openapi/shared/conventions.md": CONVENTIONS,
|
|
156
162
|
"specs/openapi/shared/servers.yaml": SERVERS,
|
|
157
163
|
"specs/openapi/bundles/example-service_openapi_structure.yaml": BUNDLE,
|
|
164
|
+
"specs/openapi/bundles/example-service.bundle.properties": VERSION,
|
|
158
165
|
"specs/openapi/paths/example/ExamplesV1.yaml": PATH_FRAGMENT,
|
|
159
166
|
"specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
|
|
160
167
|
"specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
|
package/src/pack.js
CHANGED
|
@@ -96,7 +96,8 @@ function pack(config, target, { version, closureSha256, outDir, log = () => {} }
|
|
|
96
96
|
if (declared !== String(version)) {
|
|
97
97
|
throw new PackError(
|
|
98
98
|
`target '${target}': ${path.basename(document)} declares info.version '${declared}' ` +
|
|
99
|
-
`but is being packed as '${version}'. Build
|
|
99
|
+
`but is being packed as '${version}'. Build it again first: its version file, or the ` +
|
|
100
|
+
`--pre-release it was built with, has changed since.`
|
|
100
101
|
);
|
|
101
102
|
}
|
|
102
103
|
}
|
package/src/pipeline.js
CHANGED
|
@@ -12,11 +12,20 @@ const { generateAsyncApi, isAggregate } = require("./aggregate");
|
|
|
12
12
|
|
|
13
13
|
class BuildError extends Error {}
|
|
14
14
|
|
|
15
|
-
function run(command, args, { quiet }) {
|
|
15
|
+
function run(command, args, { quiet, reportFile = null } = {}) {
|
|
16
16
|
try {
|
|
17
17
|
const out = execFileSync(command, args, { encoding: "utf8", stdio: quiet ? "pipe" : "inherit" });
|
|
18
|
+
if (reportFile) {
|
|
19
|
+
fs.mkdirSync(path.dirname(reportFile), { recursive: true });
|
|
20
|
+
fs.writeFileSync(reportFile, out);
|
|
21
|
+
}
|
|
18
22
|
return out;
|
|
19
23
|
} catch (error) {
|
|
24
|
+
const output = (error.stdout || "") + (error.stderr || "");
|
|
25
|
+
if (reportFile) {
|
|
26
|
+
fs.mkdirSync(path.dirname(reportFile), { recursive: true });
|
|
27
|
+
fs.writeFileSync(reportFile, output);
|
|
28
|
+
}
|
|
20
29
|
throw new BuildError(
|
|
21
30
|
`${command} ${args.join(" ")} failed` + (error.stdout ? `\n${error.stdout}` : "") +
|
|
22
31
|
(error.stderr ? `\n${error.stderr}` : "")
|
|
@@ -91,13 +100,14 @@ function bundle(config, target, kind, outFile, log) {
|
|
|
91
100
|
}
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
function lint(config, kind, file, log) {
|
|
103
|
+
function lint(config, kind, file, log, { report = false, reportFile = null } = {}) {
|
|
95
104
|
const tool = kind === "openapi" ? config.tool("redocly") : config.tool("asyncapi");
|
|
96
105
|
const args = kind === "openapi"
|
|
97
106
|
? ["--yes", tool, "lint"].concat(config.lintConfig("openapi") ? ["--config", config.lintConfig("openapi")] : []).concat([file])
|
|
98
107
|
: ["--yes", tool, "validate", file];
|
|
99
108
|
log(`-- Validating ${path.basename(file)}`);
|
|
100
|
-
run("npx", args, { quiet: true });
|
|
109
|
+
const output = run("npx", args, { quiet: true, reportFile });
|
|
110
|
+
if (report && output.trim()) log(output.trimEnd());
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
/**
|
|
@@ -145,9 +155,12 @@ function prepare(config, { kinds = ["openapi", "asyncapi"], log = () => {} } = {
|
|
|
145
155
|
/**
|
|
146
156
|
* Build every requested target.
|
|
147
157
|
*
|
|
158
|
+
* `versionOf(target)` names the version to stamp on a target's documents; a target
|
|
159
|
+
* it returns nothing for keeps the version its source declares.
|
|
160
|
+
*
|
|
148
161
|
* @returns {Array<{target, kind, file, distributed}>}
|
|
149
162
|
*/
|
|
150
|
-
function build(config, { targets,
|
|
163
|
+
function build(config, { targets, versionOf = () => null, kinds = ["openapi", "asyncapi"], log = () => {} } = {}) {
|
|
151
164
|
const results = [];
|
|
152
165
|
for (const kind of kinds) {
|
|
153
166
|
const all = config.targetsFor(kind).filter((t) => !targets || targets.includes(t));
|
|
@@ -172,6 +185,7 @@ function build(config, { targets, version, kinds = ["openapi", "asyncapi"], log
|
|
|
172
185
|
}
|
|
173
186
|
const outFile = path.join(config.distDir(target), config.outputName(kind));
|
|
174
187
|
bundle(config, target, kind, outFile, log);
|
|
188
|
+
const version = versionOf(target);
|
|
175
189
|
if (version) {
|
|
176
190
|
log(`-- Stamping version '${version}'`);
|
|
177
191
|
stampFile(outFile, version);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Fragments no target reaches.
|
|
4
|
+
//
|
|
5
|
+
// A linter checks documents, and a fragment reaches a document only through a
|
|
6
|
+
// $ref. A fragment that no target references is therefore never linted, however
|
|
7
|
+
// wrong it is, until the day a target starts to use it. Looking for unreferenced
|
|
8
|
+
// fragments is how that is caught before then -- and how a definition left behind
|
|
9
|
+
// by a change that stopped using it is found at all.
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
const { forTargets } = require("./closure");
|
|
15
|
+
|
|
16
|
+
function yamlFiles(dir) {
|
|
17
|
+
const files = [];
|
|
18
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
const full = path.join(dir, entry.name);
|
|
20
|
+
if (entry.isDirectory()) files.push(...yamlFiles(full));
|
|
21
|
+
else if (entry.isFile() && /\.ya?ml$/.test(entry.name)) files.push(full);
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const portable = (file) => file.split(path.sep).join("/");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every YAML file under the source root that no target's closure reaches,
|
|
30
|
+
* relative to the source root and sorted.
|
|
31
|
+
*
|
|
32
|
+
* Every target counts, `publish: false` ones included: a documentation view is
|
|
33
|
+
* linted like any other target, so whatever it reaches is linted too. The staged
|
|
34
|
+
* tree must already exist, as for forTargets.
|
|
35
|
+
*
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
function unreferenced(config) {
|
|
39
|
+
const reached = new Set();
|
|
40
|
+
for (const [, entry] of forTargets(config)) {
|
|
41
|
+
for (const [kind, files] of Object.entries(entry.byKind)) {
|
|
42
|
+
const stagingRoot = config.stagingRoot(kind);
|
|
43
|
+
for (const file of files) reached.add(portable(path.relative(stagingRoot, file)));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const sourceRoot = config.sourceRoot();
|
|
47
|
+
return yamlFiles(sourceRoot)
|
|
48
|
+
.map((file) => portable(path.relative(sourceRoot, file)))
|
|
49
|
+
.filter((file) => !reached.has(file))
|
|
50
|
+
.sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { unreferenced };
|