diff --git a/__tests__/lib/mdxish/variables-code.test.ts b/__tests__/lib/mdxish/variables-code.test.ts index a6cd91b1d..220efb88f 100644 --- a/__tests__/lib/mdxish/variables-code.test.ts +++ b/__tests__/lib/mdxish/variables-code.test.ts @@ -108,6 +108,36 @@ const name = 'Bearer ${variable}'; expect(getCodeText(tree)).toBe('sk_live_123'); }); + it('stringifies structured variables in code', () => { + const tree = mdxish('`<> {user.profile} {user.limit} {user.active}`', { + variables: { + user: { + active: true, + keys: [{ apiKey: 'rdme_123' }], + limit: 25, + profile: { plan: 'enterprise' }, + }, + defaults: [], + }, + }); + + expect(getCodeText(tree)).toBe('[{"apiKey":"rdme_123"}] {"plan":"enterprise"} 25 true'); + }); + + it('coerces null and undefined user variable values to empty strings', () => { + const tree = mdxish('`<>|<>`', { + variables: { + user: { + nullValue: null, + undefinedValue: undefined, + }, + defaults: [], + }, + }); + + expect(getCodeText(tree)).toBe('|'); + }); + it('does not double-resolve when a legacy variable value contains an MDX variable pattern', () => { const tree = mdxish('`<>`', { variables: { diff --git a/__tests__/lib/render-mdxish/Variables.test.tsx b/__tests__/lib/render-mdxish/Variables.test.tsx index 7508db6b2..1c6f23f29 100644 --- a/__tests__/lib/render-mdxish/Variables.test.tsx +++ b/__tests__/lib/render-mdxish/Variables.test.tsx @@ -1,10 +1,41 @@ import '@testing-library/jest-dom'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { mdxish, renderMdxish } from '../../../lib'; -describe('render mdxish variables in code', () => { +describe('render mdxish variables', () => { + it.each([ + { + expected: '[{"apiKey":"rdme_123"}]', + md: '{user.keys}', + name: 'arrays', + user: { keys: [{ apiKey: 'rdme_123' }] }, + }, + { + expected: '{"plan":"enterprise"}', + md: '{user.profile}', + name: 'objects', + user: { profile: { plan: 'enterprise' } }, + }, + { + expected: '25', + md: '{user.limit}', + name: 'primitives', + user: { limit: 25 }, + }, + ])('supports structured user variables: $name', ({ expected, md, user }) => { + const variables = { + user, + defaults: [], + }; + const mod = renderMdxish(mdxish(md, { variables }), { variables }); + + render(); + + expect(screen.getByText(expected)).toBeVisible(); + }); + it('resolves legacy and mdx variables in inline code', () => { const md = 'Use `<>` and `{user.region}`'; const variables = { diff --git a/__tests__/lib/render-mdxish/toc.test.tsx b/__tests__/lib/render-mdxish/toc.test.tsx index 5a3097385..900621491 100644 --- a/__tests__/lib/render-mdxish/toc.test.tsx +++ b/__tests__/lib/render-mdxish/toc.test.tsx @@ -130,6 +130,28 @@ describe('toc transformer', () => { expect(screen.findByText('Setup for admins')).toBeDefined(); }); + it('stringifies structured variables in labels', async () => { + const md = `# Keys {user.keys} + +## Profile {user.profile} {user.limit} +`; + const variables = { + user: { + keys: [{ apiKey: 'rdme_123' }], + limit: 25, + profile: { plan: 'enterprise' }, + }, + defaults: [], + }; + + const { Toc } = renderMdxish(mdxish(md), { variables }); + + render(); + + expect(await screen.findByText('Keys [{"apiKey":"rdme_123"}]')).toBeDefined(); + expect(await screen.findByText('Profile {"plan":"enterprise"} 25')).toBeDefined(); + }); + it('keeps adjacent legacy variable values and suffixes together', () => { const md = '## Hello <>! Nice'; const variables = { diff --git a/__tests__/plugins/toc.test.tsx b/__tests__/plugins/toc.test.tsx index d9a204133..42ec4cfeb 100644 --- a/__tests__/plugins/toc.test.tsx +++ b/__tests__/plugins/toc.test.tsx @@ -229,6 +229,27 @@ export const toc = [ expect(screen.findByText('Setup for admins')).toBeDefined(); }); + it('stringifies structured variables in labels', async () => { + const md = `# Keys {user.keys} + +## Profile {user.profile} {user.limit}`; + const variables = { + user: { + keys: [{ apiKey: 'rdme_123' }], + limit: 25, + profile: { plan: 'enterprise' }, + }, + defaults: [], + }; + + const { Toc } = run(compile(md), { variables }); + + render(); + + expect(await screen.findByText('Keys [{"apiKey":"rdme_123"}]')).toBeDefined(); + expect(await screen.findByText('Profile {"plan":"enterprise"} 25')).toBeDefined(); + }); + it('keeps mixed inline phrasing together', () => { const md = '## Hello {user.name}! N*ic*e [day](https://example.com)s'; const variables = { diff --git a/__tests__/variables/index.test.tsx b/__tests__/variables/index.test.tsx index 4b8487f87..e87ec3010 100644 --- a/__tests__/variables/index.test.tsx +++ b/__tests__/variables/index.test.tsx @@ -34,4 +34,31 @@ export const Hello = () =>

{user.name}

; expect(screen.getByText('Owlbert')).toBeVisible(); }); + + it.each([ + { + expected: 'rdme_123', + md: '{user.keys[0].apiKey}', + name: 'arrays', + user: { keys: [{ apiKey: 'rdme_123' }] }, + }, + { + expected: 'enterprise', + md: '{user.profile.plan}', + name: 'objects', + user: { profile: { plan: 'enterprise' } }, + }, + { + expected: 'active 25', + md: "{user.active ? 'active' : 'inactive'} {user.limit}", + name: 'primitives', + user: { active: true, limit: 25 }, + }, + ])('supports structured user variables: $name', ({ expected, md, user }) => { + const Content = execute(md, {}, { variables: { user } }); + + render(); + + expect(screen.getByText(expected)).toBeVisible(); + }); }); diff --git a/processor/plugin/toc.ts b/processor/plugin/toc.ts index 19edce7d4..de0bf591c 100644 --- a/processor/plugin/toc.ts +++ b/processor/plugin/toc.ts @@ -16,6 +16,7 @@ import { h } from 'hastscript'; import { mdx, plain } from '../../lib'; import { STANDARD_HTML_TAGS } from '../../utils/common-html-words'; +import { flattenUserVariables } from '../../utils/user'; import { hasNamedExport } from '../utils'; const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; @@ -149,7 +150,7 @@ const getDepth = (el: HastHeading) => { const flattenVariables = (variables?: Variables): Record => { if (!variables) return {}; return { - ...variables.user, + ...flattenUserVariables(variables.user), ...Object.fromEntries( (variables.defaults || []).filter(d => !(d.name in variables.user)).map(d => [d.name, d.default]), ), diff --git a/processor/transform/mdxish/variables-code.ts b/processor/transform/mdxish/variables-code.ts index 0cc5a6aa0..559cd606a 100644 --- a/processor/transform/mdxish/variables-code.ts +++ b/processor/transform/mdxish/variables-code.ts @@ -5,6 +5,8 @@ import type { Plugin } from 'unified'; import { MDX_VARIABLE_REGEXP, VARIABLE_REGEXP } from '@readme/variable'; import { visit } from 'unist-util-visit'; +import { flattenUserVariables } from '../../../utils/user'; + interface Options { variables?: Variables; } @@ -18,7 +20,7 @@ function flattenVariables(variables?: Variables): Record { return { ...Object.fromEntries((variables.defaults || []).map(d => [d.name, d.default])), - ...variables.user, + ...flattenUserVariables(variables.user), }; } diff --git a/types.d.ts b/types.d.ts index 7f7aa4b89..ee0d5ca4c 100644 --- a/types.d.ts +++ b/types.d.ts @@ -287,7 +287,7 @@ interface TocList extends Element { interface Variables { defaults: { default: string; name: string }[]; - user: Record; + user: Record; } interface TocListItem extends Element { diff --git a/utils/user.ts b/utils/user.ts index db1fc0b74..b9ce912cf 100644 --- a/utils/user.ts +++ b/utils/user.ts @@ -5,9 +5,30 @@ interface Default { export interface Variables { defaults: Default[]; - user: Record; + user: Record; } +/** + * Coerce a user variable value to a string for substitution into markdown text. + * Non-string values (arrays, objects, numbers) are stringified via JSON or `String()` + * so that `<>` syntax doesn't produce `[object Object]` for structured data like + * JWT `keys`. + */ +const stringifyVariableValue = (value: unknown): string => { + if (typeof value === 'string') return value; + if (value == null) return ''; + if (typeof value === 'object') return JSON.stringify(value) ?? ''; + return String(value); +}; + +/** + * Flatten `variables.user` into a string-keyed string-valued record by coercing + * each value. Used by markdown substitution paths that need a plain + * `Record` lookup. + */ +export const flattenUserVariables = (user: Record): Record => + Object.fromEntries(Object.entries(user).map(([name, value]) => [name, stringifyVariableValue(value)])); + const User = (variables?: Variables) => { const { user = {}, defaults = [] } = variables || {};