From 30d1e9fdea2c5806adbf32db370594f0b879f7e2 Mon Sep 17 00:00:00 2001 From: Ujjwal Verma Date: Sat, 3 Oct 2026 01:37:56 +0530 Subject: [PATCH 1/5] refactor(*): load dependencies with import statements An ES module build cannot follow a require() call: esbuild keeps it as a runtime require, which throws in browsers (the concerto-core one in markdown-transform runs at load time). Convert the five remaining calls to imports and declare the two untyped modules. dayjs has no exports map, so its plugin subpath carries the .js extension Node ESM needs. Signed-off-by: Ujjwal Verma --- .../markdown-cicero/src/CiceroMarkTransformer.ts | 2 +- .../markdown-html/src/ToCiceroMarkVisitor.ts | 3 +-- packages/markdown-html/src/type-of.d.ts | 16 ++++++++++++++++ packages/markdown-template/src/datetimeutil.ts | 2 +- .../markdown-template/src/templatemarkutil.ts | 4 ++-- .../markdown-transform/src/builtinTransforms.ts | 3 +-- packages/markdown-transform/src/dijkstrajs.d.ts | 16 ++++++++++++++++ .../markdown-transform/src/transformEngine.ts | 3 +-- 8 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 packages/markdown-html/src/type-of.d.ts create mode 100644 packages/markdown-transform/src/dijkstrajs.d.ts diff --git a/packages/markdown-cicero/src/CiceroMarkTransformer.ts b/packages/markdown-cicero/src/CiceroMarkTransformer.ts index 40b3beeb..79c900de 100644 --- a/packages/markdown-cicero/src/CiceroMarkTransformer.ts +++ b/packages/markdown-cicero/src/CiceroMarkTransformer.ts @@ -14,7 +14,7 @@ import { ModelManager, Factory, Serializer } from '@accordproject/concerto-core'; import MarkdownIt from 'markdown-it'; -import MarkdownItCicero = require('@accordproject/markdown-it-cicero'); +import MarkdownItCicero from '@accordproject/markdown-it-cicero'; import { FromMarkdownIt, CommonMarkTransformer, diff --git a/packages/markdown-html/src/ToCiceroMarkVisitor.ts b/packages/markdown-html/src/ToCiceroMarkVisitor.ts index 43aa5548..7d881389 100644 --- a/packages/markdown-html/src/ToCiceroMarkVisitor.ts +++ b/packages/markdown-html/src/ToCiceroMarkVisitor.ts @@ -14,9 +14,8 @@ import { CommonMarkModel } from '@accordproject/markdown-common'; import defaultRules, { Rule } from './rules'; +import typeOf from 'type-of'; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const typeOf = require('type-of'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const jsdom: any = typeof DOMParser === 'undefined' ? require('jsdom') : null; const JSDOM: any = jsdom ? jsdom.JSDOM : null; diff --git a/packages/markdown-html/src/type-of.d.ts b/packages/markdown-html/src/type-of.d.ts new file mode 100644 index 00000000..4bec9cb3 --- /dev/null +++ b/packages/markdown-html/src/type-of.d.ts @@ -0,0 +1,16 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// type-of publishes no type declarations. +declare module 'type-of'; diff --git a/packages/markdown-template/src/datetimeutil.ts b/packages/markdown-template/src/datetimeutil.ts index 62c37768..62edfc53 100644 --- a/packages/markdown-template/src/datetimeutil.ts +++ b/packages/markdown-template/src/datetimeutil.ts @@ -13,7 +13,7 @@ */ import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; +import utc from 'dayjs/plugin/utc.js'; dayjs.extend(utc); /** diff --git a/packages/markdown-template/src/templatemarkutil.ts b/packages/markdown-template/src/templatemarkutil.ts index 8dc58abf..cdff4b5b 100644 --- a/packages/markdown-template/src/templatemarkutil.ts +++ b/packages/markdown-template/src/templatemarkutil.ts @@ -13,7 +13,7 @@ */ import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; +import utc from 'dayjs/plugin/utc.js'; dayjs.extend(utc); import { ModelManager, Factory, Serializer, Introspector } from '@accordproject/concerto-core'; @@ -29,7 +29,7 @@ import { normalizeNLs } from './normalize'; import { TypeVisitor } from './TypeVisitor'; import { FormulaVisitor } from './FormulaVisitor'; import MarkdownIt from 'markdown-it'; -import MarkdownItTemplate = require('@accordproject/markdown-it-template'); +import MarkdownItTemplate from '@accordproject/markdown-it-template'; import templaterules from './templaterules'; export interface TemplateMarkManager { diff --git a/packages/markdown-transform/src/builtinTransforms.ts b/packages/markdown-transform/src/builtinTransforms.ts index 487108b2..fe7c4387 100644 --- a/packages/markdown-transform/src/builtinTransforms.ts +++ b/packages/markdown-transform/src/builtinTransforms.ts @@ -12,8 +12,7 @@ * limitations under the License. */ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { ModelLoader } = require('@accordproject/concerto-core'); +import { ModelLoader } from '@accordproject/concerto-core'; import { CommonMarkTransformer } from '@accordproject/markdown-common'; import { CiceroMarkTransformer } from '@accordproject/markdown-cicero'; import { TemplateMarkTransformer } from '@accordproject/markdown-template'; diff --git a/packages/markdown-transform/src/dijkstrajs.d.ts b/packages/markdown-transform/src/dijkstrajs.d.ts new file mode 100644 index 00000000..bc570833 --- /dev/null +++ b/packages/markdown-transform/src/dijkstrajs.d.ts @@ -0,0 +1,16 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// dijkstrajs publishes no type declarations. +declare module 'dijkstrajs'; diff --git a/packages/markdown-transform/src/transformEngine.ts b/packages/markdown-transform/src/transformEngine.ts index ed8ac7fc..366c36d2 100644 --- a/packages/markdown-transform/src/transformEngine.ts +++ b/packages/markdown-transform/src/transformEngine.ts @@ -12,8 +12,7 @@ * limitations under the License. */ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const dijkstra = require('dijkstrajs'); +import dijkstra from 'dijkstrajs'; const find_path = dijkstra.find_path; export interface FormatNode { From 396de5854e11966dc713b44f3ca7f76c45e7b0a7 Mon Sep 17 00:00:00 2001 From: Ujjwal Verma Date: Sat, 3 Oct 2026 01:38:18 +0530 Subject: [PATCH 2/5] feat(markdown-template): hash formula names with @noble/hashes formulaName() was the only use of Node crypto, which pulled about 1 MB of crypto-browserify into browser bundles. @noble/hashes 1.x is a pure JavaScript SHA-256 (5 KB minified) with CommonJS and ESM builds. Names are unchanged; new tests compare against Node crypto. Signed-off-by: Ujjwal Verma --- package-lock.json | 13 ++++++++ packages/markdown-template/package.json | 1 + packages/markdown-template/src/util.test.ts | 37 +++++++++++++++++++++ packages/markdown-template/src/util.ts | 9 ++--- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 packages/markdown-template/src/util.test.ts diff --git a/package-lock.json b/package-lock.json index dd0baacb..b6294178 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1358,6 +1358,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -9941,6 +9953,7 @@ "@accordproject/markdown-cicero": "1.1.0", "@accordproject/markdown-common": "1.1.0", "@accordproject/markdown-it-template": "1.1.0", + "@noble/hashes": "^1.8.0", "dayjs": "1.11.13", "markdown-it": "^14.1.0", "process": "^0.11.10" diff --git a/packages/markdown-template/package.json b/packages/markdown-template/package.json index 718c65fa..435d7d11 100644 --- a/packages/markdown-template/package.json +++ b/packages/markdown-template/package.json @@ -76,6 +76,7 @@ "@accordproject/markdown-cicero": "1.1.0", "@accordproject/markdown-common": "1.1.0", "@accordproject/markdown-it-template": "1.1.0", + "@noble/hashes": "^1.8.0", "dayjs": "1.11.13", "markdown-it": "^14.1.0", "process": "^0.11.10" diff --git a/packages/markdown-template/src/util.test.ts b/packages/markdown-template/src/util.test.ts new file mode 100644 index 00000000..cdcebfa2 --- /dev/null +++ b/packages/markdown-template/src/util.test.ts @@ -0,0 +1,37 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createHash } from 'crypto'; +import { formulaName } from './util'; + +describe('#util', () => { + describe('#formulaName', () => { + it('should hash the code with SHA-256', () => { + expect(formulaName('')).toBe('formula_e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); + + // Formula names used to come from Node's crypto module. They are + // stored in TemplateMark, so they must not change. + it.each([ + ['simple code', 'return 1 + 2;'], + ['multi-line code', 'const a = 1;\r\nconst b = 2;\nreturn a + b;'], + ['non-ASCII text', 'return "ünïcødé 🙂 €";'], + ['an unpaired surrogate', 'return "\uD800";'], + ['long code', 'return now;'.repeat(10000)], + ])('should match Node crypto for %s', (_name, code) => { + const expected = 'formula_' + createHash('sha256').update(code).digest('hex'); + expect(formulaName(code)).toBe(expected); + }); + }); +}); diff --git a/packages/markdown-template/src/util.ts b/packages/markdown-template/src/util.ts index 80818089..f761a2e5 100644 --- a/packages/markdown-template/src/util.ts +++ b/packages/markdown-template/src/util.ts @@ -12,7 +12,10 @@ * limitations under the License. */ -import * as crypto from 'crypto'; +// A pure JavaScript SHA-256, so formula names need no Node `crypto` (or a +// browser polyfill for it) and are identical in every runtime. +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; /** * Flatten an array of arrays @@ -25,7 +28,5 @@ export function flatten(arr: T[][]): T[] { * Returns a unique chosen name for a formula */ export function formulaName(code: string): string { - const hasher = crypto.createHash('sha256'); - hasher.update(code); - return 'formula_' + hasher.digest('hex'); + return 'formula_' + bytesToHex(sha256(utf8ToBytes(code))); } From 57190270491761d532481339d07943d1b29e7594 Mon Sep 17 00:00:00 2001 From: Ujjwal Verma Date: Sat, 3 Oct 2026 01:38:39 +0530 Subject: [PATCH 3/5] feat(*)!: publish Node and browser ES module builds with exports maps scripts/build-esm.js, modelled on Concerto v5, emits lib/esm (import condition) and lib/esm-browser (browser condition) next to the CommonJS lib/. Dependencies stay bare imports so consumers share one copy of each; the browser build stubs Node builtins and jsdom. scripts/smoke-esm.mjs runs after the root build. BREAKING CHANGE: each package now has an exports map, so deep imports into lib/ no longer resolve, and Node import loads the ESM build. Signed-off-by: Ujjwal Verma --- package-lock.json | 485 +++++++++++++++++++++ package.json | 3 + packages/markdown-cicero/package.json | 14 +- packages/markdown-common/package.json | 14 +- packages/markdown-html/package.json | 14 +- packages/markdown-it-cicero/package.json | 14 +- packages/markdown-it-template/package.json | 14 +- packages/markdown-template/package.json | 14 +- packages/markdown-transform/package.json | 14 +- scripts/build-esm.js | 161 +++++++ scripts/smoke-esm.mjs | 246 +++++++++++ 11 files changed, 986 insertions(+), 7 deletions(-) create mode 100644 scripts/build-esm.js create mode 100644 scripts/smoke-esm.mjs diff --git a/package-lock.json b/package-lock.json index b6294178..75645c68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@accordproject/concerto-core": "^5.0.0", "ajv": "^8.17.1", "dayjs": "1.11.13", + "esbuild": "^0.27.7", "glob": "^7.1.7", "handlebars": "^4.7.9", "license-check-and-add": "2.3.6", @@ -738,6 +739,448 @@ "node": ">=10.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "dev": true, @@ -3589,6 +4032,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escalade": { "version": "3.2.0", "license": "MIT", diff --git a/package.json b/package.json index 0c3a2d42..66d4faf6 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "@accordproject/concerto-core": "^5.0.0", "ajv": "^8.17.1", "dayjs": "1.11.13", + "esbuild": "^0.27.7", "glob": "^7.1.7", "handlebars": "^4.7.9", "license-check-and-add": "2.3.6", @@ -26,6 +27,8 @@ "private": true, "scripts": { "build": "npm run build -w @accordproject/markdown-common && npm run build -w @accordproject/markdown-it-template -w @accordproject/markdown-it-cicero && npm run build -w @accordproject/markdown-cicero && npm run build -w @accordproject/markdown-html -w @accordproject/markdown-template && npm run build -w @accordproject/markdown-transform && npm run build -w @accordproject/markdown-cli", + "postbuild": "npm run test:esm", + "test:esm": "node ./scripts/smoke-esm.mjs", "postinstall": "npm run models:get", "models:get": "node ./scripts/external/getExternalModels.js", "models:clean": "node ./scripts/external/cleanExternalModels.js", diff --git a/packages/markdown-cicero/package.json b/packages/markdown-cicero/package.json index c46d3cfc..a3513784 100644 --- a/packages/markdown-cicero/package.json +++ b/packages/markdown-cicero/package.json @@ -13,8 +13,19 @@ "lib" ], "main": "lib/index.js", + "module": "lib/esm/index.mjs", "types": "lib/index.d.ts", "typings": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "browser": "./lib/esm-browser/index.mjs", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "scripts": { "pretest": "npm run lint && npm run build", "lint": "eslint . --ext .ts", @@ -24,7 +35,8 @@ "test:noisy": "jest", "test:updateSnapshot": "jest --updateSnapshot --silent", "test:cov": "npm run lint && npm run build && jest --coverage --silent", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run build:esm", + "build:esm": "node ../../scripts/build-esm.js", "clean": "rimraf lib" }, "repository": { diff --git a/packages/markdown-common/package.json b/packages/markdown-common/package.json index 429b9e8e..c7a95aa8 100644 --- a/packages/markdown-common/package.json +++ b/packages/markdown-common/package.json @@ -13,6 +13,7 @@ "lib" ], "main": "lib/index.js", + "module": "lib/esm/index.mjs", "types": "lib/index.d.ts", "scripts": { "pretest": "npm run lint && npm run build", @@ -23,10 +24,21 @@ "test:noisy": "jest", "test:updateSnapshot": "jest --updateSnapshot --silent", "test:cov": "npm run lint && npm run build && jest --coverage --silent", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run build:esm", + "build:esm": "node ../../scripts/build-esm.js", "clean": "rimraf lib" }, "typings": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "browser": "./lib/esm-browser/index.mjs", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "repository": { "type": "git", "url": "git+https://github.com/accordproject/markdown-transform.git", diff --git a/packages/markdown-html/package.json b/packages/markdown-html/package.json index 7ee5c2d2..78b7954d 100644 --- a/packages/markdown-html/package.json +++ b/packages/markdown-html/package.json @@ -14,12 +14,24 @@ "umd" ], "main": "lib/index.js", + "module": "lib/esm/index.mjs", "browser": "umd/markdown-html.js", "types": "lib/index.d.ts", "typings": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "browser": "./lib/esm-browser/index.mjs", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "scripts": { "webpack": "webpack --config webpack.config.js --mode production", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run build:esm", + "build:esm": "node ../../scripts/build-esm.js", "build:dist": "npm run build && npm run webpack", "prepublishOnly": "npm run build:dist", "pretest": "npm run lint && npm run build", diff --git a/packages/markdown-it-cicero/package.json b/packages/markdown-it-cicero/package.json index 60552225..ccca41d0 100644 --- a/packages/markdown-it-cicero/package.json +++ b/packages/markdown-it-cicero/package.json @@ -13,8 +13,19 @@ "lib" ], "main": "lib/index.js", + "module": "lib/esm/index.mjs", "types": "lib/index.d.ts", "typings": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "browser": "./lib/esm-browser/index.mjs", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "scripts": { "pretest": "npm run lint && npm run build", "lint": "eslint . --ext .ts", @@ -23,7 +34,8 @@ "test": "jest --silent", "test:noisy": "jest", "test:cov": "npm run lint && npm run build && jest --coverage --silent", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run build:esm", + "build:esm": "node ../../scripts/build-esm.js", "clean": "rimraf lib" }, "repository": { diff --git a/packages/markdown-it-template/package.json b/packages/markdown-it-template/package.json index 69f98101..84073a11 100644 --- a/packages/markdown-it-template/package.json +++ b/packages/markdown-it-template/package.json @@ -13,8 +13,19 @@ "lib" ], "main": "lib/index.js", + "module": "lib/esm/index.mjs", "types": "lib/index.d.ts", "typings": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "browser": "./lib/esm-browser/index.mjs", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "scripts": { "pretest": "npm run lint && npm run build", "lint": "eslint . --ext .ts", @@ -23,7 +34,8 @@ "test": "jest --silent", "test:noisy": "jest", "test:cov": "npm run lint && npm run build && jest --coverage --silent", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run build:esm", + "build:esm": "node ../../scripts/build-esm.js", "clean": "rimraf lib" }, "repository": { diff --git a/packages/markdown-template/package.json b/packages/markdown-template/package.json index 435d7d11..1bc576a3 100644 --- a/packages/markdown-template/package.json +++ b/packages/markdown-template/package.json @@ -14,12 +14,24 @@ "umd" ], "main": "lib/index.js", + "module": "lib/esm/index.mjs", "browser": "umd/markdown-template.js", "types": "lib/index.d.ts", "typings": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "browser": "./lib/esm-browser/index.mjs", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "scripts": { "webpack": "webpack --config webpack.config.js --mode production", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run build:esm", + "build:esm": "node ../../scripts/build-esm.js", "build:dist": "npm run build && npm run webpack", "prepublishOnly": "npm run build:dist", "pretest": "npm run lint && npm run build", diff --git a/packages/markdown-transform/package.json b/packages/markdown-transform/package.json index 51574b26..48c8e5fb 100644 --- a/packages/markdown-transform/package.json +++ b/packages/markdown-transform/package.json @@ -14,12 +14,24 @@ "umd" ], "main": "lib/index.js", + "module": "lib/esm/index.mjs", "browser": "umd/markdown-transform.js", "types": "lib/index.d.ts", "typings": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "browser": "./lib/esm-browser/index.mjs", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "scripts": { "webpack": "webpack --config webpack.config.js --mode production", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run build:esm", + "build:esm": "node ../../scripts/build-esm.js", "build:dist": "npm run build && npm run webpack", "prepublishOnly": "npm run build:dist", "pretest": "npm run lint && npm run build", diff --git a/scripts/build-esm.js b/scripts/build-esm.js new file mode 100644 index 00000000..2e1bb96c --- /dev/null +++ b/scripts/build-esm.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* + * Emits the two ES module builds of the package in the current directory, + * next to the CommonJS build that tsc writes to lib/: + * + * lib/esm/ the `import` export condition, for Node + * lib/esm-browser/ the `browser` export condition, for bundlers targeting + * the web + * + * Run it from a package directory after tsc (`npm run build:esm`). The layout + * follows Concerto's scripts/build-esm.js. + */ + +const fs = require('fs'); +const path = require('path'); +const { builtinModules } = require('module'); +const esbuild = require('esbuild'); + +const packageDir = process.cwd(); +const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')); +const srcDir = path.join(packageDir, 'src'); + +/** + * Every source module is an entry point, so the output mirrors the module + * graph that tsc emits for lib/ instead of flattening it into one file. A + * consumer's bundler can then drop whole modules the consumer never reaches, + * and code shared between entry points is hoisted into chunks. + * + * @param {string} dir - directory to scan + * @param {string[]} found - accumulator + * @return {string[]} absolute paths of the source modules under dir + */ +function collectEntryPoints(dir, found = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '__snapshots__') { + collectEntryPoints(entryPath, found); + } + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts') && !entry.name.endsWith('.test.ts')) { + found.push(entryPath); + } + } + return found; +} + +// Dependencies whose code cannot run in a browser. The only one is jsdom, which +// markdown-html requires solely when DOMParser is missing, i.e. under Node. +const nodeOnlyDependencies = ['jsdom']; + +// Every dependency (workspace siblings, Concerto as a peer, and third-party +// packages) stays a bare import in both builds, so it resolves through the +// consumer's node_modules exactly like the require() calls in lib/. Bundling +// them instead would give each package a private copy: markdown-it alone would +// appear once per package that uses it, and none of the copies could be +// deduplicated or patched with a lockfile bump. +const dependencies = [...new Set([ + ...Object.keys(packageJson.dependencies || {}), + ...Object.keys(packageJson.peerDependencies || {}), +])]; + +const asExternal = names => names.flatMap(name => [name, `${name}/*`]); + +const builtinSpecifiers = new Set([ + ...builtinModules, + ...builtinModules.map(name => `node:${name}`), +]); + +// The browser build replaces Node builtins and Node-only dependencies with +// empty modules, so a downstream bundler without Node polyfills is never asked +// to resolve `fs` or jsdom. This matches the webpack UMD configs, which map the +// same modules to `false`. +const stubNodeOnlyModulesPlugin = { + name: 'stub-node-only-modules', + setup(build) { + build.onResolve({ filter: /^(node:|[a-z])/ }, args => { + if (builtinSpecifiers.has(args.path) || nodeOnlyDependencies.includes(args.path)) { + return { path: args.path, namespace: 'node-only-stub' }; + } + return undefined; + }); + build.onLoad({ filter: /.*/, namespace: 'node-only-stub' }, () => ({ + contents: 'module.exports = {};', + loader: 'js', + })); + }, +}; + +/** + * Build options for one target. + * + * @param {'node'|'browser'} target - the runtime the build is for + * @return {object} esbuild options shared by every entry point of that target + */ +function buildOptionsFor(target) { + if (target === 'node') { + return { + platform: 'node', + external: [...asExternal(dependencies), ...builtinSpecifiers], + // jsdom is loaded with require() (only when DOMParser is missing), + // which has no meaning in an ES module unless one is provided. + banner: { js: 'import { createRequire as __createRequire } from "module";\nconst require = __createRequire(import.meta.url);' }, + }; + } + return { + platform: 'browser', + external: asExternal(dependencies.filter(name => !nodeOnlyDependencies.includes(name))), + plugins: [stubNodeOnlyModulesPlugin], + }; +} + +async function main() { + const entryPoints = collectEntryPoints(srcDir); + const targets = [ + { target: 'node', outdir: path.join(packageDir, 'lib', 'esm') }, + { target: 'browser', outdir: path.join(packageDir, 'lib', 'esm-browser') }, + ]; + + for (const { target, outdir } of targets) { + // Chunk names are content hashes, so stale chunks from an earlier + // build would otherwise accumulate and be published. + fs.rmSync(outdir, { recursive: true, force: true }); + await esbuild.build({ + ...buildOptionsFor(target), + entryPoints, + outdir, + outbase: srcDir, + bundle: true, + splitting: true, + format: 'esm', + target: 'es2020', + sourcemap: true, + logLevel: 'warning', + // The package has no "type": "module", so ES modules need the .mjs + // extension. esbuild rewrites the relative specifiers it emits to + // match, which Node requires since it does not infer extensions. + outExtension: { '.js': '.mjs' }, + }); + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke-esm.mjs b/scripts/smoke-esm.mjs new file mode 100644 index 00000000..de633d20 --- /dev/null +++ b/scripts/smoke-esm.mjs @@ -0,0 +1,246 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Smoke test for the ES module builds, run after `npm run build`. + * + * The jest suites run src/ through ts-jest, so nothing else exercises what a + * consumer actually loads: Node's `import` resolves to lib/esm, and a bundler + * targeting the web resolves the `browser` condition to lib/esm-browser. + * + * Run with `npm run test:esm`. + */ + +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { createHash } from 'crypto'; +import { builtinModules, createRequire } from 'module'; +import { fileURLToPath } from 'url'; + +import { ModelManager } from '@accordproject/concerto-core'; +import { CommonMarkTransformer } from '@accordproject/markdown-common'; +import { CiceroMarkTransformer } from '@accordproject/markdown-cicero'; +import { HtmlTransformer } from '@accordproject/markdown-html'; +import { TemplateMarkTransformer } from '@accordproject/markdown-template'; +import { transform } from '@accordproject/markdown-transform'; +import MarkdownIt from 'markdown-it'; +import MarkdownItCicero from '@accordproject/markdown-it-cicero'; +import MarkdownItTemplate from '@accordproject/markdown-it-template'; + +const require = createRequire(import.meta.url); +const packagesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'packages'); + +const PACKAGES = [ + 'markdown-common', + 'markdown-cicero', + 'markdown-html', + 'markdown-template', + 'markdown-transform', + 'markdown-it-cicero', + 'markdown-it-template', +]; +// These export a single markdown-it plugin function (`module.exports = fn`). +const PLUGIN_PACKAGES = ['markdown-it-cicero', 'markdown-it-template']; +// Dependencies the browser build replaces with empty modules (see build-esm.js). +const NODE_ONLY_DEPENDENCIES = ['jsdom']; + +const MODEL = `namespace smoke@1.0.0 +@template +concept Agreement { + o String party + o DateTime effective +}`; + +const checks = []; + +/** + * Register a named check. + * + * @param {string} name - what the check covers + * @param {Function} fn - the check body; throws to fail + */ +function check(name, fn) { + checks.push([name, fn]); +} + +/** + * Lists the .mjs files of one build of one package. + * + * @param {string} name - package directory name + * @param {string} build - 'esm' or 'esm-browser' + * @return {Array<[string, string]>} [relative path, source] pairs + */ +function modulesOf(name, build) { + const dir = path.join(packagesDir, name, 'lib', build); + return fs.readdirSync(dir, { recursive: true }) + .filter(file => file.endsWith('.mjs')) + .map(file => [`${name}/lib/${build}/${file}`, fs.readFileSync(path.join(dir, file), 'utf8')]); +} + +/** + * Depth-first search of a markdown DOM. + * + * @param {object} node - the node to start from + * @param {string} classSuffix - the end of the $class to look for + * @return {object|undefined} the first matching node + */ +function findNode(node, classSuffix) { + if (node.$class && node.$class.endsWith(classSuffix)) { + return node; + } + for (const child of node.nodes || []) { + const found = findNode(child, classSuffix); + if (found) { + return found; + } + } + return undefined; +} + +/** + * Specifiers a module loads with a runtime require() call. In an ES module + * these are invisible to a bundler, and esbuild's __require helper throws + * "Dynamic require ... is not supported" wherever no require() is in scope. + * + * @param {string} source - module source + * @return {string[]} the required specifiers + */ +function runtimeRequires(source) { + return [...source.matchAll(/\b__require\("([^"]+)"\)/g)].map(match => match[1]); +} + +check('Node resolves the import condition to lib/esm', () => { + for (const name of PACKAGES) { + const resolved = import.meta.resolve(`@accordproject/${name}`); + assert.ok(resolved.includes('/lib/esm/'), `${name}: expected lib/esm, got ${resolved}`); + } +}); + +check('ESM and CJS entry points expose the same names', async () => { + for (const name of PACKAGES) { + const esm = await import(`@accordproject/${name}`); + const cjs = require(`@accordproject/${name}`); + if (PLUGIN_PACKAGES.includes(name)) { + assert.strictEqual(typeof esm.default, 'function', `${name}: ESM default is not a function`); + assert.strictEqual(typeof cjs, 'function', `${name}: CJS export is not a function`); + continue; + } + const esmNames = Object.keys(esm).filter(key => key !== 'default').sort(); + const cjsNames = Object.keys(cjs).filter(key => key !== 'default' && key !== '__esModule').sort(); + assert.deepStrictEqual(esmNames, cjsNames, `${name}: export names differ between ESM and CJS`); + } +}); + +check('markdown round-trips through CommonMark and CiceroMark', () => { + const markdown = '# Heading\n\nSome *emphasis* and a [link](https://accordproject.org).'; + const commonMark = new CommonMarkTransformer(); + const dom = commonMark.fromMarkdown(markdown); + assert.deepStrictEqual(commonMark.fromMarkdown(commonMark.toMarkdown(dom)), dom); + const ciceroMark = new CiceroMarkTransformer(); + assert.ok(ciceroMark.fromMarkdown(markdown).nodes.length > 0); +}); + +check('the markdown-it plugins work as default imports', () => { + const tokenTypes = (plugin, source) => new MarkdownIt().use(plugin).parse(source, {}) + .flatMap(token => [token, ...(token.children || [])]) + .map(token => token.type); + assert.ok(tokenTypes(MarkdownItCicero, '{{% return 1 %}}').includes('formula')); + assert.ok(tokenTypes(MarkdownItTemplate, 'Hello {{name}}.').includes('variable')); +}); + +check('a template with a variable, a date and a formula parses', () => { + const modelManager = new ModelManager(); + modelManager.addCTOModel(MODEL); + const templateMark = new TemplateMarkTransformer().fromMarkdownTemplate( + { content: 'Between {{party}}, from {{effective as "D MMMM YYYY"}}, for {{% return 1 + 2 %}} days.' }, + modelManager, + 'contract', + ); + assert.ok(findNode(templateMark, '.VariableDefinition'), 'no variable in the TemplateMark'); + const formula = findNode(templateMark, '.FormulaDefinition'); + assert.ok(formula, 'no formula in the TemplateMark'); + // Formula names are a SHA-256 of the code. Compare with Node's own + // implementation to show the bundled one produces the same names. + const expected = 'formula_' + createHash('sha256').update(formula.code.contents).digest('hex'); + assert.strictEqual(formula.name, expected); +}); + +check('transform() runs a multi-step conversion to HTML', async () => { + const html = await transform('# Title\n\nBody text.', 'markdown', ['html']); + assert.match(html, /

Title<\/h1>/); +}); + +// The Node build loads jsdom with require(), so this covers its createRequire +// banner. +check('HtmlTransformer parses HTML under Node', () => { + const ciceroMark = new HtmlTransformer().toCiceroMark('

Hello world

'); + assert.ok(ciceroMark.$class.endsWith('.Document'), `unexpected root ${ciceroMark.$class}`); + assert.ok(findNode(ciceroMark, '.Emph'), 'no emphasis in the CiceroMark'); +}); + +check('the Node build only requires Node-only dependencies at runtime', () => { + const offenders = []; + for (const name of PACKAGES) { + for (const [file, source] of modulesOf(name, 'esm')) { + for (const specifier of runtimeRequires(source)) { + if (!NODE_ONLY_DEPENDENCIES.includes(specifier)) { + offenders.push(`${file} requires ${specifier}`); + } + } + } + } + assert.deepStrictEqual(offenders, [], 'use an import statement so the browser build can follow it'); +}); + +check('the browser build has no Node builtins, runtime requires or free process', () => { + const builtins = new Set([...builtinModules, ...builtinModules.map(name => `node:${name}`), ...NODE_ONLY_DEPENDENCIES]); + const offenders = []; + for (const name of PACKAGES) { + for (const [file, source] of modulesOf(name, 'esm-browser')) { + for (const [, specifier] of source.matchAll(/(?:\bfrom|\bimport)\s*\(?\s*"([^"]+)"/g)) { + if (builtins.has(specifier)) { + offenders.push(`${file} imports ${specifier}`); + } + } + for (const specifier of runtimeRequires(source)) { + offenders.push(`${file} requires ${specifier}`); + } + // A free `process` is answered by a webpack consumer's + // ProvidePlugin with an extensionless `process/browser` request, + // which webpack rejects from an .mjs module. + if (/\bprocess\s*\./.test(source)) { + offenders.push(`${file} references process`); + } + } + } + assert.deepStrictEqual(offenders, []); +}); + +let failures = 0; +for (const [name, fn] of checks) { + try { + await fn(); + console.log(`ok ${name}`); + } catch (err) { + failures++; + console.error(`FAIL ${name}\n ${err.message}`); + } +} + +if (failures > 0) { + console.error(`\n${failures} of ${checks.length} ESM smoke checks failed`); + process.exit(1); +} +console.log(`\n${checks.length} ESM smoke checks passed`); From 21b9217c5c1aca8224197a5bbc79c82e33eaa9ad Mon Sep 17 00:00:00 2001 From: Ujjwal Verma Date: Sat, 3 Oct 2026 01:38:56 +0530 Subject: [PATCH 4/5] test(e2e): exercise the browser ES module build Bundles the packages through the browser export condition with esbuild, as an application would, and runs the result in Chromium. Signed-off-by: Ujjwal Verma --- e2e/README.md | 16 ++-- e2e/tests/esm-browser.spec.ts | 136 ++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 e2e/tests/esm-browser.spec.ts diff --git a/e2e/README.md b/e2e/README.md index bdef4860..d21ca6e8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,6 +1,11 @@ # Browser End-to-End Tests -[Playwright](https://playwright.dev) tests that load the UMD bundles for `markdown-html`, `markdown-template` and `markdown-transform` into a real headless Chromium and call the public API. These tests exist to catch packaging/bundling regressions that unit tests miss — for example, accidentally pulling Node-only modules like `jsdom` into the browser bundle. +[Playwright](https://playwright.dev) tests that load the browser builds into a real headless Chromium and call the public API. These tests exist to catch packaging/bundling regressions that unit tests miss — for example, accidentally pulling Node-only modules like `jsdom` into the browser bundle. + +Two kinds of browser build are covered: + +- the UMD bundles of `markdown-html`, `markdown-template` and `markdown-transform`, loaded with a `