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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20802d92a457bdb984de019c20d9d44f26a325c0c511704c2d574e5f201fa4c3
4
- data.tar.gz: 2572f3ff4960d43f228854f35005877f5cd28e6f6f57c621746b8fa3750f905d
3
+ metadata.gz: 153d82aa52e632c7db31b8a00b17330ce4a84b4c8fc29dfc8f1f88c90f9a041c
4
+ data.tar.gz: 92fe7029769a85ed463e99cdc6f9941fcfe38acac98bb379bf3bbe18d53971a3
5
5
  SHA512:
6
- metadata.gz: 0cb60bae5692c098502337f8cc4aaec5c69ada73b6df5b9d5a614a32b3a0a4653e39b60e78ca03101f68bf45778c0a322aa4eb9299fa1726d943b43c34a8a5c8
7
- data.tar.gz: a4b1228d7ce234a33fd7b18b48a54739791de5d2089736d70d5b84e184b8ccd355f7e9b34195824d1e177fdc4a353e49d6cc24254721df6a4b6a1c5ec2162020
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.2...HEAD
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 Ruby end-to-end tests convert real AsciiDoc through Asciidoctor and assert on
391
- the generated files, roles and markup. They resolve the npm package from
392
- `test/js` (or `LDL_PACKAGE_DIR`) and skip cleanly if Node or the package is
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
- Releases publish to RubyGems via https://guides.rubygems.org/trusted-publishing/[Trusted
398
- Publishing] (OIDC) — no API key is stored in the repository. Push a version tag
399
- (`git tag v0.1.0 && git push origin v0.1.0`) and the `Release` workflow builds,
400
- tests and publishes the gem. Configure the trusted publisher once on RubyGems to
401
- match `.github/workflows/release.yml`.
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 — render LDL source to SVG or PNG for the asciidoctor-ldl gem.
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 (kept deliberately small so the Ruby side is easy to drive):
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 below).
9
+ // * Options come from CLI flags (see parseArgs).
7
10
  // * The rendered artifact is written to the path given by --out.
8
- // * On success nothing is written to stdout; a one-line JSON summary
9
- // ({"format","width","height","out"}) is written to stderr for logging.
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 { createRequire } from 'node:module';
19
- import { fileURLToPath, pathToFileURL } from 'node:url';
20
- import { dirname, join, resolve, isAbsolute } from 'node:path';
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
- scale: 1,
62
- theme: 'light',
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
- case '--font-family': opts.fontFamily = next(); break;
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
- if (!Number.isFinite(opts.scale) || opts.scale <= 0) {
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, '&quot;');
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: size.width, height: size.height, out: outPath }) + '\n');
59
+ process.stderr.write(JSON.stringify({ format: 'svg', width, height, out: outPath }) + '\n');
211
60
  return;
212
61
  }
213
62
 
214
- // PNG
215
- const { Resvg } = await loadResvg(opts.packageDir);
216
- // Rasterise from the *unscaled* viewBox using resvg's own zoom, so the scale
217
- // multiplies device pixels crisply rather than baking a size into the SVG.
218
- const base = applyFontFamily(renderDiagram(diagram, renderOptions, theme), opts.fontFamily);
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, '&quot;');
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Asciidoctor
4
4
  module Ldl
5
- VERSION = '0.1.2'
5
+ VERSION = '0.2.1'
6
6
  end
7
7
  end
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.2
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