From 9340b9cc44edc1f6c8ee59fc27387eda9dc23642 Mon Sep 17 00:00:00 2001 From: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com> Date: Wed, 6 May 2026 16:11:11 -0400 Subject: [PATCH 1/2] feat: support application tokens (#4276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third top-level token directory `apps/` alongside `core/` and `themes/`. Tokens placed under `apps//` are emitted without the `--pgn-` prefix so brand package authors can override CSS variables defined by individual MFEs (which use their own naming conventions). References to Paragon tokens are preserved as `var(--pgn-…)` so theme variation flows through automatically. App outputs are inlined into each theme variant's bundle by build-scss, so existing brand packages get app overrides without any MFE-side configuration change. See ADR 0021 for the full design rationale. Refs #4274. Co-authored-by: Claude Opus 4.7 (1M context) --- .../0021-support-application-tokens.rst | 192 ++++++++++++++++++ lib/__tests__/build-tokens.test.js | 56 +++++ lib/build-tokens.js | 75 ++++++- tokens/README.md | 47 +++++ tokens/src/apps/.gitkeep | 0 tokens/style-dictionary.js | 25 +++ tokens/utils.js | 13 +- 7 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 docs/decisions/0021-support-application-tokens.rst create mode 100644 tokens/src/apps/.gitkeep diff --git a/docs/decisions/0021-support-application-tokens.rst b/docs/decisions/0021-support-application-tokens.rst new file mode 100644 index 00000000000..c18a7284cd5 --- /dev/null +++ b/docs/decisions/0021-support-application-tokens.rst @@ -0,0 +1,192 @@ +21. Support application tokens +------------------------------ + +Status +====== + +Accepted + +Context +======= + +Paragon's design token system (see `ADR 0019: Scaling Paragon's styles +architecture with design tokens +<0019-scaling-styles-with-design-tokens.rst>`__) prefixes every emitted CSS +variable with ``--pgn-``. This is intentional for tokens that belong to the +Paragon design system: it namespaces them, keeps them from colliding with +consumer CSS, and reflects their origin. + +However, Open edX micro-frontends (MFEs) define their own CSS variables for +component-level customization, and those variables are *not* part of Paragon. +They use names chosen by the MFE itself. For example, ``frontend-app-catalog`` +defines this in its home banner stylesheet: + +.. code-block:: scss + + background-color: var(--catalog-home-page-banner-background-color, var(--pgn-color-gray-500)); + +The fallback (``var(--pgn-color-gray-500)``) covers default rendering when no +MFE-specific override is supplied. Brand package authors who want to customize +the catalog banner separately are expected to set +``--catalog-home-page-banner-background-color`` to a value of their choosing. + +With Paragon's existing token tooling, brand package authors cannot do this. +Every token built through ``paragon build-tokens`` is unconditionally prefixed +with ``pgn``. A brand package author defining a token at path +``catalog.home-page.banner.background-color`` would emit a CSS variable named +``--pgn-catalog-home-page-banner-background-color`` — a name no MFE actually +reads. + +This is the gap reported in `issue #4274 +`__: brand package authors +should be able to use the existing token system to customize MFE CSS +variables, but the system has no mechanism for tokens that don't belong to +Paragon. + +Decision +======== + +We will add a third top-level token directory, ``apps/``, alongside ``core/`` +and ``themes/``. Tokens placed under ``apps//`` will be emitted +**without** the ``--pgn-`` prefix into a new build artifact at +``/apps//variables.css``. References from app tokens to +Paragon tokens will be preserved as ``var(--pgn-…)`` so theme variation +continues to flow through automatically. + +The decision breaks down into three sub-decisions, each with a meaningful +rejected alternative. + +1. Apps emit unprefixed CSS variables; the variable name is the JSON path +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The app build runs with ``prefix: ''`` and a token defined at JSON path +``catalog.home-page.banner.background-color`` emits the CSS variable +``--catalog-home-page-banner-background-color``. The brand package author has +full control over the resulting CSS name through the path they choose. + +**Rejected alternative: a per-app prefix (e.g. ``---…``) injected +automatically by the build.** The reason this fails is that MFE CSS variable +conventions vary across the Open edX codebase. Some MFEs use the unprefixed +app slug (``--catalog-…``), some use the full package name +(``--frontend-app-catalog-…``), some use neither. Any single auto-prefix +scheme would either require brand package authors to write extra path +segments to compensate, or would simply fail to match the MFE's actual +variable name. Letting the JSON path be the source of truth is the only +flexible answer. + +**Rejected alternative: keep the ``--pgn-`` prefix and ask MFEs to adopt +``--pgn-…`` for their own variables.** The ``--pgn-`` prefix marks a variable +as belonging to the Paragon design system. MFE-defined variables are *not* +part of Paragon — they belong to the MFE that defines them — so prefixing +them with ``--pgn-`` would be a category error regardless of any practical +considerations. Asking MFEs to migrate would also shift a non-trivial burden +across the ecosystem and contradict variable conventions MFEs have already +published, but the namespacing argument is the load-bearing one: app tokens +fundamentally aren't Paragon tokens. + +2. App tokens are inlined into each theme variant's CSS bundle +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The per-theme ``index.css`` produced by ``build-tokens`` adds an ``@import`` +line per discovered app, and ``postcss-import`` resolves those imports during +the SCSS compilation step in ``build-scss``. The published +``dist/.min.css`` therefore already contains every app's +custom-property declarations. + +This means no MFE-side runtime change is required to consume app tokens. +Every MFE that loads a brand's ``light.min.css`` (via +``MFE_CONFIG["PARAGON_THEME_URLS"]``) automatically receives every app's +overrides. + +**Rejected alternative (deferred): ship per-app CSS files in ``dist/`` and +extend the runtime manifest.** A natural future shape is one CSS file per app +at ``dist/apps/.min.css``, advertised through a new ``apps`` section of +``theme-urls.json``, with Paragon's runtime loading only the app file +matching the current MFE. This is more efficient when many apps have brand +overrides — each MFE downloads only its own. We didn't do it because it +requires changes across multiple repositories (Paragon's runtime, the +MFE-side convention for "what's my app name", the manifest schema, every +brand package's build), all to optimize a cost (theme bundle size) that +isn't yet a real problem. Brands typically override a small number of apps, +so the linear growth of the bundled approach is bounded. We can revisit +this once volume justifies it. + +**Rejected alternative: convention-based URLs without a manifest.** MFEs +could try-load ``/apps/.min.css`` and fall back +silently if the file is absent. This is the worst of both worlds: it requires +MFE-side changes (so it doesn't deliver the bundled approach's "no MFE +change" benefit), but it provides no manifest for discovery and produces 404 +noise in production logs. + +3. App builds include a theme variant for reference-resolution vocabulary +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The app build's ``include`` covers Paragon's ``core/`` *and* one theme +variant (``themes/light/``). This makes paths like ``color.gray.500`` and +``color.brand.500`` available to ``style-dictionary``'s reference resolver, +so an app token writing ``{ "$value": "{color.gray.500}" }`` correctly emits +``var(--pgn-color-gray-500)`` in the output. + +The choice of theme variant is incidental — only the path layout matters for +reference resolution; values fall out at runtime against whichever Paragon +theme CSS is loaded. ``light`` is hardcoded in +``getAppStyleDictionaryConfig`` because it is the only theme variant Paragon +ships today. + +**Rejected alternative: include only ``core/``.** A natural-looking simpler +design, but it fails: many tokens that brand package authors will want to +reference (colors in particular) are declared in ``themes//`` files, +not in ``core/``. Without a theme in scope, those references would not +resolve and the build would fail or warn. + +This points at an architectural smell — the coupling of "the path exists" +and "the path has this value in this theme" — that is captured in `issue +#4275 `__ as a future +refactor. A semantic-vs-primitive token split would let app builds reference +a theme-invariant schema in ``core/`` and remove the implicit light-theme +dependency. That refactor is out of scope here; the hardcoded ``light`` is a +deliberate workaround that this ADR records so it can be revisited. + +Consequences +============ + +* **Brand package authors gain a token-level mechanism for MFE + customization.** What previously required hand-rolled CSS now flows through + the same JSON-driven design token pipeline as Paragon's own tokens. + +* **No MFE-side change is required.** Brand packages continue to publish + ``dist/.min.css`` that MFEs load via + ``MFE_CONFIG["PARAGON_THEME_URLS"]``. App overrides arrive as additional + ``:root`` declarations in that same file. MFEs that don't read a particular + ``---…`` variable simply ignore the declaration; the theme bundle + grows linearly with the number of apps a brand actually overrides. + +* **The ``--pgn-`` prefix is no longer a hard invariant of the token + system.** Output prefixes now depend on which top-level directory a token + lives under. Consumers writing tokens don't need to reason about it — the + directory choice does it for them. + +* **Per-app CSS files are not currently emitted to ``dist/``.** Apps are + bundled into the theme variant's CSS rather than served independently. If + app overrides become widespread enough that per-MFE loading is worth it, a + future iteration can revisit decision (2) above. + +* **App tokens reach into ``themes/`` for reference resolution, not just + ``core/``.** Many of the paths a brand package author will want to + reference — colors especially — are declared in ``themes//``, not + in ``core/``. The app build accommodates that today by including + ``themes/light`` in its style-dictionary ``include`` purely for vocabulary. + Issue `#4275 `__ tracks + splitting Paragon's tokens into a theme-invariant schema (in ``core/``) + and theme-specific values (in ``themes/``), at which point app builds + would only need ``core/``. + +Resources +========= + +* `Issue #4274 — Handle non-pgn-prefixed tokens from MFEs + `__ +* `Issue #4275 — Decouple semantic token declarations from theme-specific + values `__ +* `ADR 0019 — Scaling Paragon's styles architecture with design tokens + <0019-scaling-styles-with-design-tokens.rst>`__ diff --git a/lib/__tests__/build-tokens.test.js b/lib/__tests__/build-tokens.test.js index 6ee13ea971f..ee33e58abe6 100644 --- a/lib/__tests__/build-tokens.test.js +++ b/lib/__tests__/build-tokens.test.js @@ -1,3 +1,4 @@ +const fs = require('fs'); const buildTokensCommand = require('../build-tokens'); const { initializeStyleDictionary, @@ -6,6 +7,7 @@ const { } = require('../../tokens/style-dictionary'); const { createIndexCssFile } = require('../../tokens/utils'); +jest.mock('fs'); jest.mock('../../tokens/style-dictionary'); jest.mock('../../tokens/utils'); @@ -122,4 +124,58 @@ describe('buildTokensCommand', () => { }, })); }); + + describe('app token discovery', () => { + const mockAppsDirectory = (appNames) => { + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue( + appNames.map((name) => ({ name, isDirectory: () => true })), + ); + }; + + it('builds one config per discovered app, in addition to core and themes', async () => { + mockAppsDirectory(['catalog', 'discussions']); + + await buildTokensCommand(['--source', '/fake/source']); + + // 1 core + 1 light theme + 2 apps = 4 StyleDictionary calls + expect(StyleDictionary).toHaveBeenCalledTimes(4); + }); + + it('uses the expected per-app config shape', async () => { + mockAppsDirectory(['catalog']); + + await buildTokensCommand(['--source', '/fake/source']); + + const appCallArgs = StyleDictionary.mock.calls + .map(([config]) => config) + .find((config) => config.platforms.css.files[0].destination.startsWith('apps/')); + + expect(appCallArgs).toBeDefined(); + expect(appCallArgs.platforms.css.prefix).toBe(''); + expect(appCallArgs.platforms.css.transformGroup).toBe('paragon-css-app'); + expect(appCallArgs.platforms.css.files).toHaveLength(1); + expect(appCallArgs.platforms.css.files[0].destination).toBe('apps/catalog/variables.css'); + expect(appCallArgs.platforms.css.files[0].options.outputReferences).toBe(true); + + // Inline filter passes source tokens, rejects include'd Paragon tokens. + const appFilter = appCallArgs.platforms.css.files[0].filter; + expect(typeof appFilter).toBe('function'); + expect(appFilter({ isSource: true })).toBe(true); + expect(appFilter({ isSource: false })).toBe(false); + }); + + it('does not create an index.css for app configs', async () => { + mockAppsDirectory(['catalog', 'discussions']); + + await buildTokensCommand(['--source', '/fake/source']); + + // Only core + light theme should get an index; the two apps should not. + expect(createIndexCssFile).toHaveBeenCalledTimes(2); + const indexCalls = createIndexCssFile.mock.calls.map(([params]) => params); + indexCalls.forEach(({ themeVariant }) => { + expect(themeVariant === undefined || themeVariant === 'light').toBe(true); + }); + }); + }); }); diff --git a/lib/build-tokens.js b/lib/build-tokens.js index cbd254fc6d0..e9213df94a5 100755 --- a/lib/build-tokens.js +++ b/lib/build-tokens.js @@ -62,8 +62,9 @@ async function buildTokensCommand(commandArgs) { } let themesToProcess = null; + const tokensPath = tokensSource || path.resolve(__dirname, '../tokens/src'); + if (allThemes) { - const tokensPath = tokensSource || path.resolve(__dirname, '../tokens/src'); themesToProcess = fs .readdirSync(`${tokensPath}/themes/`, { withFileTypes: true }) .filter(entry => entry.isDirectory()) @@ -74,6 +75,15 @@ async function buildTokensCommand(commandArgs) { themesToProcess = (themes || 'light').split(',').map(t => t.trim()); } + // Discover app token directories. Skip silently if `apps/` is absent. + const appsPath = path.join(tokensPath, 'apps'); + const appsToProcess = fs.existsSync(appsPath) + ? fs + .readdirSync(appsPath, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + : []; + const StyleDictionary = await initializeStyleDictionary({ themes: themesToProcess }); const coreConfig = { @@ -173,6 +183,51 @@ async function buildTokensCommand(commandArgs) { }, }); + // Per-app style-dictionary config. Outputs the app's own tokens unprefixed + // (`prefix: ''`) into `apps//variables.css`. References to + // include'd Paragon core/theme tokens emit `var(--pgn-…)` thanks to the + // `paragon-css-app` transform group's conditional name transform. + // + // `themes/light/**` is included purely for reference vocabulary — app + // tokens reference paths like `{color.gray.500}` that live in theme files, + // and the build needs those paths in scope to resolve refs. The actual + // values are filtered out of the output. Light is hardcoded because it's + // the only theme Paragon ships and the schema is the same across variants. + // See https://github.com/openedx/paragon/issues/4275 for a proposed split + // that would let us reference a theme-invariant schema directly. + const getAppStyleDictionaryConfig = (appName) => ({ + ...coreConfig, + include: [ + ...coreConfig.include, + path.resolve(__dirname, '../tokens/src/themes/light/**/*.json'), + path.resolve(__dirname, '../tokens/src/themes/light/**/*.toml'), + ], + source: [ + `${tokensPath}/apps/${appName}/**/*.json`, + `${tokensPath}/apps/${appName}/**/*.toml`, + ], + platforms: { + css: { + ...coreConfig.platforms.css, + prefix: '', + transformGroup: 'paragon-css-app', + files: [ + { + format: 'css/custom-variables', + destination: `apps/${appName}/variables.css`, + // Inline filter — strict source-only, distinct from the + // registered `isSource` filter (which also pulls in Paragon + // tokens marked as referenced by source). For apps we want refs + // to Paragon tokens to stay as `var(--pgn-…)` and resolve at + // runtime against Paragon's separately-loaded CSS. + filter: (token) => token.isSource, + options: { outputReferences: true }, + }, + ], + }, + }, + }); + // Create list of style-dictionary configurations to build const configs = []; @@ -187,17 +242,29 @@ async function buildTokensCommand(commandArgs) { configs.push({ config, themeVariant }); }); - // Build tokens for each configuration - await Promise.all(configs.map(async ({ config, themeVariant }) => { + // Add app configs (one per discovered app) + appsToProcess.forEach(appName => { + configs.push({ config: getAppStyleDictionaryConfig(appName), isApp: true }); + }); + + // Phase 1: build all token configs (core, themes, apps) in parallel. + await Promise.all(configs.map(async ({ config }) => { const sd = new StyleDictionary(config); await sd.cleanAllPlatforms(); await sd.buildAllPlatforms(); + })); + + // Phase 2: create index.css for core + each theme variant. Apps don't get + // their own index — their variables.css is consumed via @import from each + // theme variant's index.css (added by createIndexCssFile when apps exist). + configs.forEach(({ themeVariant, isApp }) => { + if (isApp) { return; } createIndexCssFile({ buildDir, isThemeVariant: !!themeVariant, themeVariant, }); - })); + }); } module.exports = buildTokensCommand; diff --git a/tokens/README.md b/tokens/README.md index f93e56f49b7..43c773cda93 100644 --- a/tokens/README.md +++ b/tokens/README.md @@ -155,3 +155,50 @@ functions from tokens that are of `color` category and theirs `type` is one of ` and `item` is one of `["base", "100", "200", ...]`. If you want to generate additional utility classes you need to add a similar JSON file to `src/utilities` directory. + +## Application tokens + +In addition to `core/` and `themes/`, Paragon supports a third top-level token directory: `apps/`. Tokens placed under `apps//` are emitted **without** the `--pgn-` prefix into `/apps//variables.css`. This is intended for theme authors who want to override CSS variables defined by individual MFEs (which use their own naming conventions, not `--pgn-`). + +For example, `frontend-app-catalog`'s home banner uses +```scss +background-color: var(--catalog-home-page-banner-background-color, var(--pgn-color-gray-500)); +``` + +A theme author can target that variable by placing the following at `/apps/catalog/home-page.json`: + +```json +{ + "catalog": { + "home-page": { + "banner": { + "background-color": { "$value": "{color.primary.400}", "$type": "color" } + } + } + } +} +``` + +The build emits: + +```css +:root { + --catalog-home-page-banner-background-color: var(--pgn-color-primary-400); +} +``` + +…replacing the MFE's gray fallback with the theme's primary color. + +### Path conventions + +The recommended structure is kebab-case path segments matching the MFE's CSS variable name 1:1. For example, the four-level nesting above (`catalog` → `home-page` → `banner` → `background-color`) maps to the variable `--catalog-home-page-banner-background-color`. Multi-word concepts like `home-page` and `background-color` use hyphens within a single segment; single-word segments stay simple. + +This is the same convention used by Paragon's existing `core/` and `themes/` token files. + +### Theme variation + +App tokens are not theme-variant-aware in this version — there's a single `apps//variables.css` regardless of how many themes are being built. Theme variation flows through *references*: writing `{ "$value": "{color.primary.400}" }` produces a `var(--pgn-color-primary-400)` reference in the output, which resolves at runtime against whichever Paragon theme CSS is loaded. So when a brand publishes both `light.css` and `dark.css`, the same app override layers correctly on top of each. + +### Output and consumption + +App `variables.css` files are inlined into each theme variant's bundle by `paragon build-scss` via `@import` statements added to the theme's `index.css`. So when a theme author publishes `dist/light.min.css`, it already contains the app overrides. No separate per-app CSS files are emitted in `dist/`, and no MFE-side configuration change is needed — every MFE that loads the brand's `light.min.css` automatically picks up every app's custom-property declarations on `:root`. diff --git a/tokens/src/apps/.gitkeep b/tokens/src/apps/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tokens/style-dictionary.js b/tokens/style-dictionary.js index 98f09b1f6e9..1e6e4003700 100644 --- a/tokens/style-dictionary.js +++ b/tokens/style-dictionary.js @@ -251,6 +251,31 @@ const initializeStyleDictionary = async ({ themes }) => { ], }); + /** + * Conditionally prepends `pgn-` to non-source tokens so that app-config builds + * (which run with `prefix: ''` so app tokens output unprefixed) still emit + * `var(--pgn-…)` for references to Paragon core/theme tokens loaded via `include`. + * Source tokens (the app's own) keep their unprefixed name. + */ + StyleDictionary.registerTransform({ + name: 'name/pgn-for-non-source', + type: 'name', + transform: (token) => (token.isSource ? token.name : `pgn-${token.name}`), + }); + + /** + * Transform group used by app configs. Extends `paragon-css` with the + * conditional name transform appended so it runs after the standard kebab + * name transform. + */ + StyleDictionary.registerTransformGroup({ + name: 'paragon-css-app', + transforms: [ + ...StyleDictionary.hooks.transformGroups['paragon-css'], + 'name/pgn-for-non-source', + ], + }); + /** * The custom formatter to create CSS variables for core tokens. */ diff --git a/tokens/utils.js b/tokens/utils.js index 86c6fdb143e..6964c72bcf3 100644 --- a/tokens/utils.js +++ b/tokens/utils.js @@ -361,8 +361,19 @@ function createIndexCssFile({ buildDir = path.resolve(__dirname, '../styles/css' return sortOrder.indexOf(aName) - sortOrder.indexOf(bName); }); + // For theme variants, app variables.css files get @import'd from the + // theme's index.css so app overrides flow into the theme bundle via + // build-scss's postCSS-import pass. + const appsDir = path.join(buildDir, 'apps'); + const appCssFiles = (isThemeVariant && fs.existsSync(appsDir)) + ? getAllCssFiles(appsDir) + : []; + + // Apps don't have load-order dependencies, so we just append them. + const allCssFiles = [...sortedCssFiles, ...appCssFiles]; + // Generate @import statements with relative paths - const exportStatements = sortedCssFiles.map((file) => { + const exportStatements = allCssFiles.map((file) => { // Get the relative path from the directory path to the file const relativePath = path.relative(directoryPath, file).replace(/\\/g, '/'); return `@import "${relativePath}";`; From 5f42c36612c51ae794942bb1b567783e56c831bb Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Sun, 10 May 2026 20:34:03 -0400 Subject: [PATCH 2/2] chore: update browserslist DB (#4285) Co-authored-by: adamstankiewicz <2828721+adamstankiewicz@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4f06d9bc5f8..a3e29ebd81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14702,9 +14702,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", - "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -15515,9 +15515,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "funding": [ { "type": "opencollective",