asciidoctor-ldl 0.1.2 → 0.2.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/README.adoc +137 -9
- data/lib/asciidoctor/ldl/js/ldl_render.mjs +24 -183
- data/lib/asciidoctor/ldl/js/node-loader.mjs +111 -0
- data/lib/asciidoctor/ldl/js/render-core.mjs +123 -0
- data/lib/asciidoctor/ldl/renderer.rb +0 -0
- data/lib/asciidoctor/ldl/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 153d82aa52e632c7db31b8a00b17330ce4a84b4c8fc29dfc8f1f88c90f9a041c
|
|
4
|
+
data.tar.gz: 92fe7029769a85ed463e99cdc6f9941fcfe38acac98bb379bf3bbe18d53971a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e959fecda528aad3d8f430cf560b5e5b254f726f802dc62417cb43e1b08bba92c8839b1f99a0abe61ddf3511edbe150d08bf163587782d1822e8430a67d5abde
|
|
7
|
+
data.tar.gz: 5827fc98af7d978bc4a66652203a556e3606319ac418e710a033a1f820c65c00f707d9caf5738319605b4793da18a29e94346112c979298d5c0064afd13527cc
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.1] - 2026-07-23
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- First release published from CI via trusted publishing for **both** the gem
|
|
14
|
+
(RubyGems) and the npm package. No functional changes since 0.2.0 (which was
|
|
15
|
+
the initial, manually published npm release; the gem's first release carrying
|
|
16
|
+
the 0.2.0 changes is this one).
|
|
17
|
+
- The npm release workflow now skips publishing a version already on the
|
|
18
|
+
registry, so a re-triggered tag still attaches release assets without failing.
|
|
19
|
+
|
|
20
|
+
## [0.2.0] - 2026-07-23
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Asciidoctor.js extension**, published to npm as
|
|
25
|
+
`@openpowershift/asciidoctor-ldl`. It registers the same `[ldl]` block and
|
|
26
|
+
`ldl::` macro, honours the same attributes, and emits the same roles as the
|
|
27
|
+
Ruby gem. In Node it writes SVG/PNG files with identical content-hash names;
|
|
28
|
+
in the browser it embeds SVG inline. Ships as a single bundled JS file
|
|
29
|
+
(Node ESM + CJS and a browser build) with TypeScript declarations.
|
|
30
|
+
- A shared, environment-agnostic rendering core (`render-core.mjs`) used by both
|
|
31
|
+
the gem's Node helper and the npm extension, so Ruby and JavaScript produce
|
|
32
|
+
**byte-identical** SVG and identical file names. A parity test suite asserts
|
|
33
|
+
this across formats, scale, theme, labels and font options.
|
|
34
|
+
- A **standalone single-file build** (`@openpowershift/asciidoctor-ldl/standalone`,
|
|
35
|
+
and attached to each GitHub release as `asciidoctor-ldl-standalone.js`) with
|
|
36
|
+
the renderer bundled in. It renders SVG inline and synchronously — drop it into
|
|
37
|
+
Asciidoctor VS Code's `.asciidoctor/lib` with no `npm install` required.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- The content-hash used for output file names now uses a canonical, cross-tool
|
|
42
|
+
serialization (this changes generated file names from earlier versions; output
|
|
43
|
+
content is unchanged aside from the name).
|
|
44
|
+
- Gem and npm package versions are released in lockstep from a single tag.
|
|
45
|
+
|
|
9
46
|
## [0.1.2] - 2026-07-23
|
|
10
47
|
|
|
11
48
|
### Added
|
|
@@ -46,7 +83,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
46
83
|
- Content-addressed caching with a digest sidecar so unchanged diagrams are not
|
|
47
84
|
re-rendered.
|
|
48
85
|
|
|
49
|
-
[Unreleased]: https://github.com/OpenPowerShift/asciidoctor-ldl/compare/v0.1
|
|
86
|
+
[Unreleased]: https://github.com/OpenPowerShift/asciidoctor-ldl/compare/v0.2.1...HEAD
|
|
87
|
+
[0.2.1]: https://github.com/OpenPowerShift/asciidoctor-ldl/compare/v0.2.0...v0.2.1
|
|
88
|
+
[0.2.0]: https://github.com/OpenPowerShift/asciidoctor-ldl/compare/v0.1.2...v0.2.0
|
|
50
89
|
[0.1.2]: https://github.com/OpenPowerShift/asciidoctor-ldl/compare/v0.1.1...v0.1.2
|
|
51
90
|
[0.1.1]: https://github.com/OpenPowerShift/asciidoctor-ldl/compare/v0.1.0...v0.1.1
|
|
52
91
|
[0.1.0]: https://github.com/OpenPowerShift/asciidoctor-ldl/releases/tag/v0.1.0
|
data/README.adoc
CHANGED
|
@@ -25,6 +25,15 @@ TRIP = OVERCURRENT AND NOT BLOCK
|
|
|
25
25
|
....
|
|
26
26
|
----
|
|
27
27
|
|
|
28
|
+
It ships in two forms that share a single rendering core, so a diagram and its
|
|
29
|
+
options produce *byte-identical output* either way:
|
|
30
|
+
|
|
31
|
+
* a *Ruby gem*, `asciidoctor-ldl` (the default examples below), and
|
|
32
|
+
* an *Asciidoctor.js extension*, `@openpowershift/asciidoctor-ldl` on npm — see
|
|
33
|
+
<<use-with-asciidoctor-js>>.
|
|
34
|
+
|
|
35
|
+
Both accept the same <<attributes,attributes>> and emit the same roles.
|
|
36
|
+
|
|
28
37
|
toc::[]
|
|
29
38
|
|
|
30
39
|
== How it works
|
|
@@ -144,6 +153,109 @@ the document):
|
|
|
144
153
|
ldl::diagrams/trip-matrix.ldl[format=png,scale=2]
|
|
145
154
|
----
|
|
146
155
|
|
|
156
|
+
[[use-with-asciidoctor-js]]
|
|
157
|
+
== Use with Asciidoctor.js
|
|
158
|
+
|
|
159
|
+
The extension is also published to npm as `@openpowershift/asciidoctor-ldl`, for
|
|
160
|
+
https://docs.asciidoctor.org/asciidoctor.js/latest/[Asciidoctor.js] 4. It shares
|
|
161
|
+
the same rendering core as the gem, so the same diagram and options yield
|
|
162
|
+
byte-identical SVG and the same generated file names. It ships as a single
|
|
163
|
+
bundled file (Node ESM + CommonJS, plus a browser build) with TypeScript types.
|
|
164
|
+
|
|
165
|
+
[source,console]
|
|
166
|
+
----
|
|
167
|
+
$ npm install @openpowershift/asciidoctor-ldl @openpowershift/logic-diagram-language
|
|
168
|
+
$ npm install @resvg/resvg-js # only needed for format=png
|
|
169
|
+
----
|
|
170
|
+
|
|
171
|
+
The renderer (`@openpowershift/logic-diagram-language`) is a runtime dependency;
|
|
172
|
+
`@resvg/resvg-js` is optional and only used for PNG.
|
|
173
|
+
|
|
174
|
+
=== Programmatic use (Node)
|
|
175
|
+
|
|
176
|
+
Rendering is asynchronous, so `convert` returns a promise — always `await` it.
|
|
177
|
+
|
|
178
|
+
[source,js]
|
|
179
|
+
----
|
|
180
|
+
import { Extensions, convert } from '@asciidoctor/core'
|
|
181
|
+
import ldl from '@openpowershift/asciidoctor-ldl'
|
|
182
|
+
|
|
183
|
+
const registry = Extensions.create()
|
|
184
|
+
ldl.register(registry) // or: ldl.register(registry, { packageDir })
|
|
185
|
+
|
|
186
|
+
const html = await convert(source, {
|
|
187
|
+
extension_registry: registry,
|
|
188
|
+
safe: 'safe',
|
|
189
|
+
})
|
|
190
|
+
----
|
|
191
|
+
|
|
192
|
+
Images are written under `imagesdir` (or `imagesoutdir`) exactly as the gem
|
|
193
|
+
does, with identical content-hash file names and digest-sidecar caching.
|
|
194
|
+
|
|
195
|
+
=== Command line
|
|
196
|
+
|
|
197
|
+
[source,console]
|
|
198
|
+
----
|
|
199
|
+
$ npx asciidoctor -r @openpowershift/asciidoctor-ldl document.adoc
|
|
200
|
+
----
|
|
201
|
+
|
|
202
|
+
=== Browser
|
|
203
|
+
|
|
204
|
+
A browser build is exported at `@openpowershift/asciidoctor-ldl/browser`. It
|
|
205
|
+
renders *SVG only* (PNG needs a native rasteriser) and embeds the diagram
|
|
206
|
+
*inline*; your bundler or import map must provide
|
|
207
|
+
`@openpowershift/logic-diagram-language`.
|
|
208
|
+
|
|
209
|
+
[source,js]
|
|
210
|
+
----
|
|
211
|
+
import { Extensions, convert } from '@asciidoctor/core'
|
|
212
|
+
import ldl from '@openpowershift/asciidoctor-ldl/browser'
|
|
213
|
+
|
|
214
|
+
const registry = Extensions.create()
|
|
215
|
+
ldl.register(registry)
|
|
216
|
+
const html = await convert(source, { extension_registry: registry })
|
|
217
|
+
----
|
|
218
|
+
|
|
219
|
+
=== Asciidoctor VS Code
|
|
220
|
+
|
|
221
|
+
The https://marketplace.visualstudio.com/items?itemName=asciidoctor.asciidoctor-vscode[AsciiDoc]
|
|
222
|
+
extension loads Asciidoctor.js extensions from `.asciidoctor/lib/**/*.js` in the
|
|
223
|
+
workspace: it `require`s each file and calls its `register(registry)`. Enable
|
|
224
|
+
*Enable Asciidoctor.js extensions registration*
|
|
225
|
+
(`asciidoc.extensions.registerWorkspaceExtensions`) and trust the workspace when
|
|
226
|
+
prompted. Diagrams render as inline SVG in the preview (PNG is not available
|
|
227
|
+
there).
|
|
228
|
+
|
|
229
|
+
*Option A — zero install (recommended).* Download
|
|
230
|
+
`asciidoctor-ldl-standalone.js` from the
|
|
231
|
+
https://github.com/OpenPowerShift/asciidoctor-ldl/releases[latest release] and
|
|
232
|
+
save it as `<workspace>/.asciidoctor/lib/ldl.js`. It is a single self-contained
|
|
233
|
+
file with the renderer bundled in — nothing to `npm install`, which is ideal
|
|
234
|
+
when you cannot add packages. Reload the window; `[ldl]` blocks and `ldl::`
|
|
235
|
+
macros render.
|
|
236
|
+
|
|
237
|
+
*Option B — via npm.* Install into the workspace and point a loader file at the
|
|
238
|
+
bundled *standalone* entry:
|
|
239
|
+
|
|
240
|
+
[source,console]
|
|
241
|
+
----
|
|
242
|
+
$ npm install @openpowershift/asciidoctor-ldl @openpowershift/logic-diagram-language
|
|
243
|
+
----
|
|
244
|
+
|
|
245
|
+
[source,js]
|
|
246
|
+
.<workspace>/.asciidoctor/lib/ldl.js
|
|
247
|
+
----
|
|
248
|
+
module.exports = require('@openpowershift/asciidoctor-ldl/standalone')
|
|
249
|
+
----
|
|
250
|
+
|
|
251
|
+
Use the `/standalone` subpath: it renders synchronously and inline, so it works
|
|
252
|
+
regardless of which Asciidoctor.js version the editor bundles. (The default
|
|
253
|
+
entry uses asynchronous rendering, which older bundled engines may not await.)
|
|
254
|
+
|
|
255
|
+
NOTE: If your workspace `package.json` sets `"type": "module"`, also drop a
|
|
256
|
+
`.asciidoctor/lib/package.json` containing `{ "type": "commonjs" }` so the `.js`
|
|
257
|
+
loader file is treated as CommonJS.
|
|
258
|
+
|
|
147
259
|
== Attributes
|
|
148
260
|
|
|
149
261
|
Every option can be set on the individual block/macro, or document-wide using
|
|
@@ -379,26 +491,42 @@ $ asciidoctor-pdf -r asciidoctor-ldl protection-scheme.adoc
|
|
|
379
491
|
|
|
380
492
|
== Development
|
|
381
493
|
|
|
494
|
+
The repository holds both implementations, sharing one rendering core
|
|
495
|
+
(`lib/asciidoctor/ldl/js/render-core.mjs`):
|
|
496
|
+
|
|
497
|
+
* the Ruby gem (`lib/`, `test/`), and
|
|
498
|
+
* the Asciidoctor.js extension (`js/`, TypeScript, bundled with esbuild).
|
|
499
|
+
|
|
382
500
|
[source,console]
|
|
383
501
|
----
|
|
502
|
+
# Ruby gem
|
|
384
503
|
$ bundle install
|
|
385
504
|
$ (cd test/js && npm install) # LDL package + resvg for the test suite
|
|
386
505
|
$ bundle exec rake test # Ruby unit + end-to-end tests
|
|
387
506
|
$ bundle exec rake jstest # Node helper tests
|
|
507
|
+
|
|
508
|
+
# Asciidoctor.js extension
|
|
509
|
+
$ (cd js && npm install)
|
|
510
|
+
$ (cd js && npm run build) # single bundled file per target → js/dist
|
|
511
|
+
$ (cd js && npm test) # extension + Ruby↔JS parity tests
|
|
388
512
|
----
|
|
389
513
|
|
|
390
|
-
The
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
unavailable.
|
|
514
|
+
The parity suite (`js/test/parity.test.mjs`) renders identical LDL through the
|
|
515
|
+
Ruby CLI and the JS extension and asserts the generated files are byte-identical;
|
|
516
|
+
it self-skips when the Ruby toolchain is absent.
|
|
394
517
|
|
|
395
518
|
== Releasing
|
|
396
519
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
520
|
+
Gem and npm package are versioned in *lockstep*: one `v<x.y.z>` tag releases both.
|
|
521
|
+
|
|
522
|
+
* *RubyGems* via https://guides.rubygems.org/trusted-publishing/[Trusted
|
|
523
|
+
Publishing] (OIDC) — `.github/workflows/release.yml`.
|
|
524
|
+
* *npm* (`@openpowershift/asciidoctor-ldl`) via npm Trusted Publishing (OIDC,
|
|
525
|
+
with provenance) — `.github/workflows/release-npm.yml`.
|
|
526
|
+
|
|
527
|
+
Push a version tag (`git tag v0.2.0 && git push origin v0.2.0`) and both
|
|
528
|
+
workflows build, test and publish. Configure the trusted publisher once on each
|
|
529
|
+
of RubyGems and npm to match the respective workflow filename.
|
|
402
530
|
|
|
403
531
|
== License
|
|
404
532
|
|
|
@@ -1,70 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// ldl_render.mjs —
|
|
2
|
+
// ldl_render.mjs — CLI wrapper the Ruby gem shells out to. It is a thin adapter
|
|
3
|
+
// over the shared render core (render-core.mjs) and Node loader
|
|
4
|
+
// (node-loader.mjs); the Asciidoctor.js extension uses the very same core, so
|
|
5
|
+
// both integrations emit byte-identical output.
|
|
3
6
|
//
|
|
4
|
-
// Contract (
|
|
7
|
+
// Contract (driven by lib/asciidoctor/ldl/renderer.rb):
|
|
5
8
|
// * LDL source is read from stdin.
|
|
6
|
-
// * Options come from CLI flags (see parseArgs
|
|
9
|
+
// * Options come from CLI flags (see parseArgs).
|
|
7
10
|
// * The rendered artifact is written to the path given by --out.
|
|
8
|
-
// * On success
|
|
9
|
-
//
|
|
10
|
-
// * On failure a human-readable message is written to stderr and the
|
|
11
|
-
// process exits non-zero.
|
|
12
|
-
//
|
|
13
|
-
// SVG rendering is isomorphic (only needs @openpowershift/logic-diagram-language).
|
|
14
|
-
// PNG rendering additionally needs @resvg/resvg-js, resolved lazily so an
|
|
15
|
-
// SVG-only install never has to have it present.
|
|
11
|
+
// * On success a one-line JSON summary is written to stderr; on failure a
|
|
12
|
+
// human-readable message is written to stderr and the process exits non-zero.
|
|
16
13
|
|
|
17
14
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
const PACKAGE = '@openpowershift/logic-diagram-language';
|
|
23
|
-
|
|
24
|
-
// Resolve an installed package's ESM entry point from a base directory,
|
|
25
|
-
// without relying on a `require` export condition (the LDL package is
|
|
26
|
-
// ESM-only, so `require.resolve(pkg)` throws ERR_PACKAGE_PATH_NOT_EXPORTED).
|
|
27
|
-
// The `./package.json` subpath *is* exported, so resolve that and read the
|
|
28
|
-
// declared entry ourselves. Returns a file:// URL string, or null.
|
|
29
|
-
function resolveEntryFrom(base, pkg) {
|
|
30
|
-
let pkgJsonPath;
|
|
31
|
-
try {
|
|
32
|
-
const req = createRequire(pathToFileURL(join(base, 'package.json')).href);
|
|
33
|
-
pkgJsonPath = req.resolve(`${pkg}/package.json`);
|
|
34
|
-
} catch (_) {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
const meta = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
38
|
-
const dot = meta.exports && meta.exports['.'];
|
|
39
|
-
const entry =
|
|
40
|
-
(dot && typeof dot === 'object' && (dot.import || dot.default)) ||
|
|
41
|
-
(typeof dot === 'string' ? dot : null) ||
|
|
42
|
-
meta.module ||
|
|
43
|
-
meta.main ||
|
|
44
|
-
'index.js';
|
|
45
|
-
return pathToFileURL(resolve(dirname(pkgJsonPath), entry)).href;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Candidate base directories to search, in priority order.
|
|
49
|
-
function candidateBases(packageDir) {
|
|
50
|
-
const bases = [];
|
|
51
|
-
if (packageDir) bases.push(packageDir);
|
|
52
|
-
if (process.env.LDL_PACKAGE_DIR) bases.push(process.env.LDL_PACKAGE_DIR);
|
|
53
|
-
bases.push(process.cwd());
|
|
54
|
-
bases.push(dirname(fileURLToPath(import.meta.url)));
|
|
55
|
-
return bases;
|
|
56
|
-
}
|
|
15
|
+
import { isAbsolute, join } from 'node:path';
|
|
16
|
+
import { renderSvg, renderBaseSvg, normalizeScale } from './render-core.mjs';
|
|
17
|
+
import { loadLibrary, rasterisePng } from './node-loader.mjs';
|
|
57
18
|
|
|
58
19
|
function parseArgs(argv) {
|
|
59
20
|
const opts = {
|
|
60
|
-
format: 'svg',
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
showIds: false,
|
|
64
|
-
showLabels: true,
|
|
65
|
-
out: null,
|
|
66
|
-
packageDir: null,
|
|
67
|
-
fontFamily: null,
|
|
21
|
+
format: 'svg', scale: 1, theme: 'light',
|
|
22
|
+
showIds: false, showLabels: true,
|
|
23
|
+
fontFamily: null, out: null, packageDir: null,
|
|
68
24
|
};
|
|
69
25
|
for (let i = 0; i < argv.length; i++) {
|
|
70
26
|
const arg = argv[i];
|
|
@@ -77,153 +33,38 @@ function parseArgs(argv) {
|
|
|
77
33
|
case '--no-show-ids': opts.showIds = false; break;
|
|
78
34
|
case '--show-labels': opts.showLabels = true; break;
|
|
79
35
|
case '--no-show-labels': opts.showLabels = false; break;
|
|
36
|
+
case '--font-family': opts.fontFamily = next(); break;
|
|
80
37
|
case '--out': opts.out = next(); break;
|
|
81
38
|
case '--package-dir': opts.packageDir = next(); break;
|
|
82
|
-
|
|
83
|
-
default:
|
|
84
|
-
throw new Error(`unknown argument: ${arg}`);
|
|
39
|
+
default: throw new Error(`unknown argument: ${arg}`);
|
|
85
40
|
}
|
|
86
41
|
}
|
|
87
42
|
if (!opts.out) throw new Error('missing required --out <path>');
|
|
88
|
-
|
|
89
|
-
throw new Error(`invalid --scale: ${opts.scale}`);
|
|
90
|
-
}
|
|
43
|
+
normalizeScale(opts.scale); // validates
|
|
91
44
|
if (opts.format !== 'svg' && opts.format !== 'png') {
|
|
92
45
|
throw new Error(`unsupported --format: ${opts.format} (expected svg or png)`);
|
|
93
46
|
}
|
|
94
47
|
return opts;
|
|
95
48
|
}
|
|
96
49
|
|
|
97
|
-
// Resolve the LDL package from a set of candidate base directories, so it can
|
|
98
|
-
// live in the user's project, a globally installed location, or a checkout
|
|
99
|
-
// pointed at by --package-dir / LDL_PACKAGE_DIR.
|
|
100
|
-
async function loadLibrary(packageDir) {
|
|
101
|
-
const tried = [];
|
|
102
|
-
for (const base of candidateBases(packageDir)) {
|
|
103
|
-
// Allow a base that points straight at a package checkout (…/lib/index.js).
|
|
104
|
-
for (const direct of [join(base, 'lib', 'index.js'), join(base, 'index.js')]) {
|
|
105
|
-
try {
|
|
106
|
-
return await import(pathToFileURL(direct).href);
|
|
107
|
-
} catch (e) { tried.push(`${direct} (${e.code || 'error'})`); }
|
|
108
|
-
}
|
|
109
|
-
// Otherwise resolve it as an installed dependency from that base.
|
|
110
|
-
const entry = resolveEntryFrom(base, PACKAGE);
|
|
111
|
-
if (entry) {
|
|
112
|
-
try {
|
|
113
|
-
return await import(entry);
|
|
114
|
-
} catch (e) { tried.push(`${entry} (${e.code || 'error'})`); }
|
|
115
|
-
} else {
|
|
116
|
-
tried.push(`${base}:${PACKAGE} (not found)`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
throw new Error(
|
|
120
|
-
`could not resolve ${PACKAGE}.\n` +
|
|
121
|
-
`Install it where your document is built, e.g.\n` +
|
|
122
|
-
` npm install ${PACKAGE}\n` +
|
|
123
|
-
`or point the ldl-package-dir attribute / LDL_PACKAGE_DIR env var at it.\n` +
|
|
124
|
-
`Searched:\n ${tried.join('\n ')}`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async function loadResvg(packageDir) {
|
|
128
|
-
for (const base of candidateBases(packageDir)) {
|
|
129
|
-
try {
|
|
130
|
-
const req = createRequire(pathToFileURL(join(base, 'package.json')).href);
|
|
131
|
-
return await import(pathToFileURL(req.resolve('@resvg/resvg-js')).href);
|
|
132
|
-
} catch (_) { /* try next */ }
|
|
133
|
-
const entry = resolveEntryFrom(base, '@resvg/resvg-js');
|
|
134
|
-
if (entry) {
|
|
135
|
-
try { return await import(entry); } catch (_) { /* try next */ }
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
throw new Error(
|
|
139
|
-
'PNG output needs @resvg/resvg-js, which was not found.\n' +
|
|
140
|
-
'Install it alongside the LDL package:\n' +
|
|
141
|
-
' npm install @resvg/resvg-js\n' +
|
|
142
|
-
'or use format=svg (the default), which needs no extra dependency and is\n' +
|
|
143
|
-
'ideal for asciidoctor-pdf (vector, scalable, compact).');
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// The renderer emits <svg viewBox="0 0 W H" …> with no intrinsic width/height.
|
|
147
|
-
// Add them (scaled) so downstream consumers — prawn-svg in asciidoctor-pdf,
|
|
148
|
-
// browsers, resvg — get a concrete size and the scale attribute is honoured.
|
|
149
|
-
function sizeFromViewBox(svg) {
|
|
150
|
-
const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
|
|
151
|
-
if (!m) return null;
|
|
152
|
-
return { width: parseFloat(m[1]), height: parseFloat(m[2]) };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// The renderer tags diagram text with font-family="sans-serif". Some consumers
|
|
156
|
-
// map that generic family to a glyph-poor built-in font (e.g. prawn-svg in
|
|
157
|
-
// asciidoctor-pdf uses Helvetica, which lacks − U+2212 and ≥ U+2265). Rewriting
|
|
158
|
-
// it to a named font that the consumer has registered fixes those glyphs.
|
|
159
|
-
function applyFontFamily(svg, family) {
|
|
160
|
-
if (!family) return svg;
|
|
161
|
-
const q = family.replace(/"/g, '"');
|
|
162
|
-
return svg
|
|
163
|
-
.replace(/font-family="sans-serif"/g, `font-family="${q}"`)
|
|
164
|
-
.replace(/font-family:\s*sans-serif/g, `font-family:${family}`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function applySvgScale(svg, scale) {
|
|
168
|
-
const size = sizeFromViewBox(svg);
|
|
169
|
-
if (!size) return { svg, size };
|
|
170
|
-
const w = size.width * scale;
|
|
171
|
-
const h = size.height * scale;
|
|
172
|
-
// Insert width/height right after "<svg"; drop the max-width/height clamp so
|
|
173
|
-
// an explicit size actually takes effect when embedded as an image.
|
|
174
|
-
const scaled = svg
|
|
175
|
-
.replace(/<svg/, `<svg width="${w}" height="${h}"`)
|
|
176
|
-
.replace(/\s*max-width:100%;max-height:100%;/, '');
|
|
177
|
-
return { svg: scaled, size: { width: w, height: h } };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
50
|
async function main() {
|
|
181
51
|
const opts = parseArgs(process.argv.slice(2));
|
|
182
52
|
const source = readFileSync(0, 'utf8'); // fd 0 = stdin
|
|
183
|
-
|
|
184
53
|
const lib = await loadLibrary(opts.packageDir);
|
|
185
|
-
const { parse, renderDiagram, resolveOptions, LIGHT_DIAGRAM, DARK_DIAGRAM } = lib;
|
|
186
|
-
|
|
187
|
-
const { diagram, errors } = parse(source);
|
|
188
|
-
if (errors && errors.length) {
|
|
189
|
-
const lines = errors.map((e) => {
|
|
190
|
-
const pos = e.position ? ` (line ${e.position.line}, col ${e.position.column})` : '';
|
|
191
|
-
return ` ${e.message}${pos}`;
|
|
192
|
-
});
|
|
193
|
-
throw new Error(`LDL parse error:\n${lines.join('\n')}`);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const renderOptions = resolveOptions(diagram.options);
|
|
197
|
-
renderOptions.showIds = opts.showIds;
|
|
198
|
-
renderOptions.showLabels = opts.showLabels;
|
|
199
|
-
const theme = opts.theme === 'dark' ? DARK_DIAGRAM : LIGHT_DIAGRAM;
|
|
200
|
-
|
|
201
|
-
let svg = applyFontFamily(renderDiagram(diagram, renderOptions, theme), opts.fontFamily);
|
|
202
|
-
const scaled = applySvgScale(svg, opts.scale);
|
|
203
|
-
svg = scaled.svg;
|
|
204
|
-
const size = scaled.size || { width: 0, height: 0 };
|
|
205
|
-
|
|
206
54
|
const outPath = isAbsolute(opts.out) ? opts.out : join(process.cwd(), opts.out);
|
|
207
55
|
|
|
208
56
|
if (opts.format === 'svg') {
|
|
57
|
+
const { svg, width, height } = renderSvg(lib, { ...opts, source });
|
|
209
58
|
writeFileSync(outPath, svg, 'utf8');
|
|
210
|
-
process.stderr.write(JSON.stringify({ format: 'svg', width
|
|
59
|
+
process.stderr.write(JSON.stringify({ format: 'svg', width, height, out: outPath }) + '\n');
|
|
211
60
|
return;
|
|
212
61
|
}
|
|
213
62
|
|
|
214
|
-
// PNG
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const resvg = new Resvg(base, {
|
|
220
|
-
fitTo: { mode: 'zoom', value: opts.scale },
|
|
221
|
-
background: 'white',
|
|
222
|
-
});
|
|
223
|
-
const rendered = resvg.render();
|
|
224
|
-
const png = rendered.asPng();
|
|
225
|
-
writeFileSync(outPath, png);
|
|
226
|
-
process.stderr.write(JSON.stringify({ format: 'png', width: rendered.width, height: rendered.height, out: outPath }) + '\n');
|
|
63
|
+
// PNG: rasterise the unscaled base SVG with resvg's zoom.
|
|
64
|
+
const base = renderBaseSvg(lib, { ...opts, source });
|
|
65
|
+
const { data, width, height } = await rasterisePng(base, normalizeScale(opts.scale), opts.packageDir);
|
|
66
|
+
writeFileSync(outPath, data);
|
|
67
|
+
process.stderr.write(JSON.stringify({ format: 'png', width, height, out: outPath }) + '\n');
|
|
227
68
|
}
|
|
228
69
|
|
|
229
70
|
main().catch((err) => {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// node-loader.mjs — Node-only concerns for the LDL render core: resolving the
|
|
2
|
+
// LDL library and @resvg/resvg-js from a set of candidate directories, and
|
|
3
|
+
// rasterising SVG to PNG. Kept separate from render-core.mjs so the core stays
|
|
4
|
+
// environment-agnostic (usable in the browser).
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const PACKAGE = '@openpowershift/logic-diagram-language';
|
|
12
|
+
|
|
13
|
+
// Resolve an installed package's ESM entry point from a base directory, without
|
|
14
|
+
// relying on a `require` export condition (the LDL package is ESM-only, so
|
|
15
|
+
// `require.resolve(pkg)` throws ERR_PACKAGE_PATH_NOT_EXPORTED). The
|
|
16
|
+
// `./package.json` subpath *is* exported, so resolve that and read the declared
|
|
17
|
+
// entry ourselves. Returns a file:// URL string, or null.
|
|
18
|
+
function resolveEntryFrom(base, pkg) {
|
|
19
|
+
let pkgJsonPath;
|
|
20
|
+
try {
|
|
21
|
+
const req = createRequire(pathToFileURL(join(base, 'package.json')).href);
|
|
22
|
+
pkgJsonPath = req.resolve(`${pkg}/package.json`);
|
|
23
|
+
} catch (_) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const meta = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
27
|
+
const dot = meta.exports && meta.exports['.'];
|
|
28
|
+
const entry =
|
|
29
|
+
(dot && typeof dot === 'object' && (dot.import || dot.default)) ||
|
|
30
|
+
(typeof dot === 'string' ? dot : null) ||
|
|
31
|
+
meta.module ||
|
|
32
|
+
meta.main ||
|
|
33
|
+
'index.js';
|
|
34
|
+
return pathToFileURL(resolve(dirname(pkgJsonPath), entry)).href;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The directory of this module, or null when unavailable (e.g. bundled into a
|
|
38
|
+
// CommonJS file where import.meta.url is empty).
|
|
39
|
+
function moduleDir() {
|
|
40
|
+
try {
|
|
41
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function candidateBases(packageDir) {
|
|
48
|
+
const bases = [];
|
|
49
|
+
if (packageDir) bases.push(packageDir);
|
|
50
|
+
if (process.env.LDL_PACKAGE_DIR) bases.push(process.env.LDL_PACKAGE_DIR);
|
|
51
|
+
bases.push(process.cwd());
|
|
52
|
+
const md = moduleDir();
|
|
53
|
+
if (md) bases.push(md);
|
|
54
|
+
return bases;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function loadLibrary(packageDir) {
|
|
58
|
+
const tried = [];
|
|
59
|
+
for (const base of candidateBases(packageDir)) {
|
|
60
|
+
for (const direct of [join(base, 'lib', 'index.js'), join(base, 'index.js')]) {
|
|
61
|
+
try {
|
|
62
|
+
return await import(pathToFileURL(direct).href);
|
|
63
|
+
} catch (e) { tried.push(`${direct} (${e.code || 'error'})`); }
|
|
64
|
+
}
|
|
65
|
+
const entry = resolveEntryFrom(base, PACKAGE);
|
|
66
|
+
if (entry) {
|
|
67
|
+
try {
|
|
68
|
+
return await import(entry);
|
|
69
|
+
} catch (e) { tried.push(`${entry} (${e.code || 'error'})`); }
|
|
70
|
+
} else {
|
|
71
|
+
tried.push(`${base}:${PACKAGE} (not found)`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
throw new Error(
|
|
75
|
+
`could not resolve ${PACKAGE}.\n` +
|
|
76
|
+
`Install it where your document is built, e.g.\n` +
|
|
77
|
+
` npm install ${PACKAGE}\n` +
|
|
78
|
+
`or point the ldl-package-dir attribute / LDL_PACKAGE_DIR env var at it.\n` +
|
|
79
|
+
`Searched:\n ${tried.join('\n ')}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function loadResvg(packageDir) {
|
|
83
|
+
for (const base of candidateBases(packageDir)) {
|
|
84
|
+
try {
|
|
85
|
+
const req = createRequire(pathToFileURL(join(base, 'package.json')).href);
|
|
86
|
+
return await import(pathToFileURL(req.resolve('@resvg/resvg-js')).href);
|
|
87
|
+
} catch (_) { /* try next */ }
|
|
88
|
+
const entry = resolveEntryFrom(base, '@resvg/resvg-js');
|
|
89
|
+
if (entry) {
|
|
90
|
+
try { return await import(entry); } catch (_) { /* try next */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new Error(
|
|
94
|
+
'PNG output needs @resvg/resvg-js, which was not found.\n' +
|
|
95
|
+
'Install it alongside the LDL package:\n' +
|
|
96
|
+
' npm install @resvg/resvg-js\n' +
|
|
97
|
+
'or use format=svg (the default), which needs no extra dependency and is\n' +
|
|
98
|
+
'ideal for asciidoctor-pdf (vector, scalable, compact).');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Rasterise an SVG string to a PNG Buffer at `scale`× using resvg's zoom, so the
|
|
102
|
+
// scale multiplies device pixels crisply. Returns { data, width, height }.
|
|
103
|
+
export async function rasterisePng(baseSvg, scale, packageDir) {
|
|
104
|
+
const { Resvg } = await loadResvg(packageDir);
|
|
105
|
+
const resvg = new Resvg(baseSvg, {
|
|
106
|
+
fitTo: { mode: 'zoom', value: scale },
|
|
107
|
+
background: 'white',
|
|
108
|
+
});
|
|
109
|
+
const rendered = resvg.render();
|
|
110
|
+
return { data: rendered.asPng(), width: rendered.width, height: rendered.height };
|
|
111
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// render-core.mjs — the environment-agnostic rendering core shared by the Ruby
|
|
2
|
+
// gem's Node helper, the Asciidoctor.js extension (Node), and the browser build.
|
|
3
|
+
//
|
|
4
|
+
// It has NO Node-specific imports on purpose, so the exact same code runs in
|
|
5
|
+
// every environment and both the Ruby and JavaScript integrations produce
|
|
6
|
+
// byte-identical SVG. Anything that needs the filesystem or module resolution
|
|
7
|
+
// lives in node-loader.mjs instead.
|
|
8
|
+
//
|
|
9
|
+
// The functions here take the *already-loaded* LDL library module
|
|
10
|
+
// (`@openpowershift/logic-diagram-language`) as their first argument, so this
|
|
11
|
+
// file never has to resolve or import it.
|
|
12
|
+
|
|
13
|
+
// Keep this in lockstep with the Ruby DEFAULT_FONT_FAMILY (renderer.rb) and the
|
|
14
|
+
// npm/gem version policy — see docs. Named real fonts first, ending in the
|
|
15
|
+
// generic so browsers/resvg still resolve it; deliberately omits the PDF
|
|
16
|
+
// built-ins so prawn-svg only uses one when it is actually registered.
|
|
17
|
+
export const DEFAULT_FONT_FAMILY =
|
|
18
|
+
'DejaVu Sans, Bitstream Vera Sans, Liberation Sans, Arial, sans-serif';
|
|
19
|
+
|
|
20
|
+
export class LdlParseError extends Error {}
|
|
21
|
+
|
|
22
|
+
// The renderer tags diagram text font-family="sans-serif". Rewrite it to a named
|
|
23
|
+
// family so consumers that map the generic family to a glyph-poor font (e.g.
|
|
24
|
+
// prawn-svg → Helvetica, which lacks − U+2212 and ≥ U+2265) render correctly.
|
|
25
|
+
export function applyFontFamily(svg, family) {
|
|
26
|
+
if (!family) return svg;
|
|
27
|
+
const q = String(family).replace(/"/g, '"');
|
|
28
|
+
return svg
|
|
29
|
+
.replace(/font-family="sans-serif"/g, `font-family="${q}"`)
|
|
30
|
+
.replace(/font-family:\s*sans-serif/g, `font-family:${family}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sizeFromViewBox(svg) {
|
|
34
|
+
const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
return { width: parseFloat(m[1]), height: parseFloat(m[2]) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Add explicit width/height (scaled from the viewBox) and drop the max-width
|
|
40
|
+
// clamp, so an embedded image honours the size while the viewBox keeps it crisp.
|
|
41
|
+
export function applySvgScale(svg, scale) {
|
|
42
|
+
const size = sizeFromViewBox(svg);
|
|
43
|
+
if (!size) return { svg, width: 0, height: 0 };
|
|
44
|
+
const width = size.width * scale;
|
|
45
|
+
const height = size.height * scale;
|
|
46
|
+
const scaled = svg
|
|
47
|
+
.replace(/<svg/, `<svg width="${width}" height="${height}"`)
|
|
48
|
+
.replace(/\s*max-width:100%;max-height:100%;/, '');
|
|
49
|
+
return { svg: scaled, width, height };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Render LDL source to an SVG string at the diagram's intrinsic size (no scale
|
|
53
|
+
// applied), with the font family rewritten. This is the single rendering path;
|
|
54
|
+
// SVG output scales this, PNG output rasterises it.
|
|
55
|
+
export function renderBaseSvg(lib, opts) {
|
|
56
|
+
const { parse, renderDiagram, resolveOptions, LIGHT_DIAGRAM, DARK_DIAGRAM } = lib;
|
|
57
|
+
const { source, theme = 'light', showIds = false, showLabels = true, fontFamily = null } = opts;
|
|
58
|
+
|
|
59
|
+
const { diagram, errors } = parse(source);
|
|
60
|
+
if (errors && errors.length) {
|
|
61
|
+
const lines = errors.map((e) => {
|
|
62
|
+
const pos = e.position ? ` (line ${e.position.line}, col ${e.position.column})` : '';
|
|
63
|
+
return ` ${e.message}${pos}`;
|
|
64
|
+
});
|
|
65
|
+
throw new LdlParseError(`LDL parse error:\n${lines.join('\n')}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const renderOptions = resolveOptions(diagram.options);
|
|
69
|
+
renderOptions.showIds = showIds;
|
|
70
|
+
renderOptions.showLabels = showLabels;
|
|
71
|
+
const themeObj = String(theme).toLowerCase() === 'dark' ? DARK_DIAGRAM : LIGHT_DIAGRAM;
|
|
72
|
+
|
|
73
|
+
return applyFontFamily(renderDiagram(diagram, renderOptions, themeObj), fontFamily);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Render to a final SVG string (scaled). Returns { svg, width, height }.
|
|
77
|
+
export function renderSvg(lib, opts) {
|
|
78
|
+
const scale = normalizeScale(opts.scale);
|
|
79
|
+
return applySvgScale(renderBaseSvg(lib, opts), scale);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- shared option / identity helpers (must match the Ruby side) ------------
|
|
83
|
+
|
|
84
|
+
export function normalizeScale(scale) {
|
|
85
|
+
const n = Number(scale == null || scale === '' ? 1 : scale);
|
|
86
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error(`invalid scale: ${scale}`);
|
|
87
|
+
return n;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Canonical %g-style number, matching Ruby's sprintf('%g', n) for the value
|
|
91
|
+
// ranges scales take (no exponent for normal magnitudes): trailing zeros are
|
|
92
|
+
// dropped. Used in the content digest so filenames match across Ruby and JS.
|
|
93
|
+
export function canonicalScale(scale) {
|
|
94
|
+
const n = normalizeScale(scale);
|
|
95
|
+
if (Number.isInteger(n)) return String(n);
|
|
96
|
+
// Round like %g's 6 significant digits, then strip trailing zeros.
|
|
97
|
+
return parseFloat(n.toPrecision(6)).toString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function normalizeFontFamily(value) {
|
|
101
|
+
if (value == null) return DEFAULT_FONT_FAMILY;
|
|
102
|
+
const family = String(value).trim();
|
|
103
|
+
if (family === '' || family.toLowerCase() === 'none' || family.toLowerCase() === 'sans-serif') {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return family;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The canonical material hashed to name the output file. MUST be identical to
|
|
110
|
+
// the Ruby Renderer#digest input so a given diagram+options yields the same
|
|
111
|
+
// file name whether rendered by the gem or the npm package.
|
|
112
|
+
export function digestMaterial(opts) {
|
|
113
|
+
return [
|
|
114
|
+
opts.source,
|
|
115
|
+
opts.format,
|
|
116
|
+
canonicalScale(opts.scale),
|
|
117
|
+
String(opts.theme || 'light').toLowerCase(),
|
|
118
|
+
opts.showIds ? 'true' : 'false',
|
|
119
|
+
opts.showLabels ? 'true' : 'false',
|
|
120
|
+
opts.fontFamily || '',
|
|
121
|
+
opts.version,
|
|
122
|
+
].join('\n');
|
|
123
|
+
}
|
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asciidoctor-ldl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Mulholland
|
|
@@ -78,6 +78,8 @@ files:
|
|
|
78
78
|
- lib/asciidoctor/ldl.rb
|
|
79
79
|
- lib/asciidoctor/ldl/extension.rb
|
|
80
80
|
- lib/asciidoctor/ldl/js/ldl_render.mjs
|
|
81
|
+
- lib/asciidoctor/ldl/js/node-loader.mjs
|
|
82
|
+
- lib/asciidoctor/ldl/js/render-core.mjs
|
|
81
83
|
- lib/asciidoctor/ldl/renderer.rb
|
|
82
84
|
- lib/asciidoctor/ldl/version.rb
|
|
83
85
|
homepage: https://github.com/OpenPowerShift/asciidoctor-ldl
|