From b745e2aa980ad4b078e28f1c5619d94a4a8889f8 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 13 Nov 2025 09:11:57 +0000 Subject: [PATCH 1/4] Demotes H1 in markdown if required --- .../preview-components/declarationfield.njk | 2 +- .../src/form/form-editor/preview/markdown.js | 4 ++-- model/src/utils/markdown.ts | 22 ++++++++++++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/designer/client/src/views/preview-components/declarationfield.njk b/designer/client/src/views/preview-components/declarationfield.njk index 2ad3349c9e..c0427d00bf 100644 --- a/designer/client/src/views/preview-components/declarationfield.njk +++ b/designer/client/src/views/preview-components/declarationfield.njk @@ -13,7 +13,7 @@
- {{ model.declaration.text | markdown | safe }} + {{ model.declaration.text | markdown({ demoteH1: true }) | safe }}
diff --git a/model/src/form/form-editor/preview/markdown.js b/model/src/form/form-editor/preview/markdown.js index ed83d3b57a..a174924e19 100644 --- a/model/src/form/form-editor/preview/markdown.js +++ b/model/src/form/form-editor/preview/markdown.js @@ -31,7 +31,7 @@ export class Markdown extends Content { constructor(htmlElements, questionRenderer) { super(htmlElements, questionRenderer) const { content } = htmlElements.values - this._content = markdownToHtml(content) + this._content = markdownToHtml(content, { demoteH1: true }) } /** @@ -39,7 +39,7 @@ export class Markdown extends Content { * @protected */ _setContent(value) { - super._setContent(markdownToHtml(value)) + super._setContent(markdownToHtml(value, { demoteH1: true })) } } diff --git a/model/src/utils/markdown.ts b/model/src/utils/markdown.ts index f3962fb53e..d2f36798da 100644 --- a/model/src/utils/markdown.ts +++ b/model/src/utils/markdown.ts @@ -26,12 +26,24 @@ function renderLink(href: string, text: string, baseUrl?: string) { return `${label}` } +function demoteH1(text: string, depth: number) { + if (depth === 1) { + depth = 2 + } + return ` + ${text} + ` +} + /** * Convert markdown to HTML, escaping any HTML tags first */ export function markdownToHtml( markdown: string | null | undefined, - baseUrl?: string // optional in some contexts, e.g. from the designer where it might not make sense + options?: { + baseUrl?: string // optional in some contexts, e.g. from the designer where it might not make sense, + demoteH1?: boolean + } ) { if (markdown === undefined || markdown === null) { return '' @@ -46,8 +58,12 @@ export function markdownToHtml( const renderer = new Renderer() renderer.link = ({ href, text }: Tokens.Link): string => { - return renderLink(href, text, baseUrl) + return renderLink(href, text, options?.baseUrl) + } + if (options?.demoteH1) { + renderer.heading = ({ text, depth }: Tokens.Heading): string => { + return demoteH1(text, depth) + } } - return marked.parse(escaped, { async: false, renderer }) } From d06839beb43575bb02e562352198b61fd9d8394b Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 13 Nov 2025 09:30:12 +0000 Subject: [PATCH 2/4] Fixed tests --- .../form/form-editor/preview/markdown.test.js | 8 ++-- model/src/utils/markdown.test.js | 40 ++++++++++++++++++- model/src/utils/markdown.ts | 5 +-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/model/src/form/form-editor/preview/markdown.test.js b/model/src/form/form-editor/preview/markdown.test.js index a73385b3b7..6d7b9d69f9 100644 --- a/model/src/form/form-editor/preview/markdown.test.js +++ b/model/src/form/form-editor/preview/markdown.test.js @@ -8,18 +8,20 @@ describe('markdown', () => { const questionElements = new ContentElements( buildMarkdownComponent({ title: 'Which quest would you like to pick?', - content: '# This is a heading' + content: '# This is a heading demoted' }) ) describe('Markdown', () => { it('should create class', () => { - expect(questionElements.values.content).toBe('# This is a heading') + expect(questionElements.values.content).toBe( + '# This is a heading demoted' + ) const res = new Markdown(questionElements, renderer) expect(res.renderInput).toEqual({ id: 'markdown', name: 'markdown', classes: '', - content: '

This is a heading

\n' + content: '

This is a heading demoted

\n' }) expect(res.titleText).toBe('Which quest would you like to pick?') expect(res.question).toBe('Which quest would you like to pick?') diff --git a/model/src/utils/markdown.test.js b/model/src/utils/markdown.test.js index 018a217b6c..8f9b2445c6 100644 --- a/model/src/utils/markdown.test.js +++ b/model/src/utils/markdown.test.js @@ -45,7 +45,7 @@ describe('Helpers', () => { html: '

link

\n' } ])("formats '$markdown' to '$html'", ({ markdown, html }) => { - expect(markdownToHtml(markdown, exampleBaseUrl)).toBe(html) + expect(markdownToHtml(markdown, { baseUrl: exampleBaseUrl })).toBe(html) }) }) @@ -63,4 +63,42 @@ describe('Helpers', () => { expect(markdownToHtml(markdown)).toBe(html) }) }) + + describe('markdown with H1 demotion', () => { + it.each([ + { + markdown: '# This is an H1 demoted', + html: '

This is an H1 demoted

\n' + }, + { + markdown: '## This is an H2', + html: '

This is an H2

\n' + }, + { + markdown: '### This is an H3', + html: '

This is an H3

\n' + } + ])("formats '$markdown' to '$html'", ({ markdown, html }) => { + expect(markdownToHtml(markdown, { demoteH1: true })).toBe(html) + }) + }) + + describe('markdown without H1 demotion', () => { + it.each([ + { + markdown: '# This is an H1', + html: '

This is an H1

\n' + }, + { + markdown: '## This is an H2', + html: '

This is an H2

\n' + }, + { + markdown: '### This is an H3', + html: '

This is an H3

\n' + } + ])("formats '$markdown' to '$html'", ({ markdown, html }) => { + expect(markdownToHtml(markdown)).toBe(html) + }) + }) }) diff --git a/model/src/utils/markdown.ts b/model/src/utils/markdown.ts index d2f36798da..d6e4f2ecba 100644 --- a/model/src/utils/markdown.ts +++ b/model/src/utils/markdown.ts @@ -30,9 +30,8 @@ function demoteH1(text: string, depth: number) { if (depth === 1) { depth = 2 } - return ` - ${text} - ` + return `${text} +` } /** From 8c5f37bfe2b4a625c1df416b801bae54df45e33a Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 14 Nov 2025 12:49:52 +0000 Subject: [PATCH 3/4] Reworked to demote from a starting level --- .../preview-components/declarationfield.njk | 2 +- .../src/form/form-editor/preview/markdown.js | 4 +- model/src/utils/markdown.test.js | 53 +++++++++++-------- model/src/utils/markdown.ts | 17 +++--- 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/designer/client/src/views/preview-components/declarationfield.njk b/designer/client/src/views/preview-components/declarationfield.njk index c0427d00bf..faf4a1217d 100644 --- a/designer/client/src/views/preview-components/declarationfield.njk +++ b/designer/client/src/views/preview-components/declarationfield.njk @@ -13,7 +13,7 @@
- {{ model.declaration.text | markdown({ demoteH1: true }) | safe }} + {{ model.declaration.text | markdown({ startingHeaderLevel: 2 }) | safe }}
diff --git a/model/src/form/form-editor/preview/markdown.js b/model/src/form/form-editor/preview/markdown.js index a174924e19..e914b0c0d9 100644 --- a/model/src/form/form-editor/preview/markdown.js +++ b/model/src/form/form-editor/preview/markdown.js @@ -31,7 +31,7 @@ export class Markdown extends Content { constructor(htmlElements, questionRenderer) { super(htmlElements, questionRenderer) const { content } = htmlElements.values - this._content = markdownToHtml(content, { demoteH1: true }) + this._content = markdownToHtml(content, { startingHeaderLevel: 2 }) } /** @@ -39,7 +39,7 @@ export class Markdown extends Content { * @protected */ _setContent(value) { - super._setContent(markdownToHtml(value, { demoteH1: true })) + super._setContent(markdownToHtml(value, { startingHeaderLevel: 2 })) } } diff --git a/model/src/utils/markdown.test.js b/model/src/utils/markdown.test.js index 8f9b2445c6..e8649588fa 100644 --- a/model/src/utils/markdown.test.js +++ b/model/src/utils/markdown.test.js @@ -64,41 +64,48 @@ describe('Helpers', () => { }) }) - describe('markdown with H1 demotion', () => { + describe('markdown with demotion from speific levels', () => { it.each([ { - markdown: '# This is an H1 demoted', - html: '

This is an H1 demoted

\n' + level: 1, + markdown: + '# This is H1\n## This is H2\n### This is H3\n#### This is H4\n##### This is H5\n###### This is H6\n', + html: '

This is H1

\n

This is H2

\n

This is H3

\n

This is H4

\n
This is H5
\n
This is H6
\n' }, { - markdown: '## This is an H2', - html: '

This is an H2

\n' + level: 2, + markdown: + '# This is H1\n## This is H2\n### This is H3\n#### This is H4\n##### This is H5\n###### This is H6\n', + html: '

This is H1

\n

This is H2

\n

This is H3

\n
This is H4
\n
This is H5
\n
This is H6
\n' }, { - markdown: '### This is an H3', - html: '

This is an H3

\n' - } - ])("formats '$markdown' to '$html'", ({ markdown, html }) => { - expect(markdownToHtml(markdown, { demoteH1: true })).toBe(html) - }) - }) - - describe('markdown without H1 demotion', () => { - it.each([ + level: 3, + markdown: + '# This is H1\n## This is H2\n### This is H3\n#### This is H4\n##### This is H5\n###### This is H6\n', + html: '

This is H1

\n

This is H2

\n
This is H3
\n
This is H4
\n
This is H5
\n
This is H6
\n' + }, { - markdown: '# This is an H1', - html: '

This is an H1

\n' + level: 4, + markdown: + '# This is H1\n## This is H2\n### This is H3\n#### This is H4\n##### This is H5\n###### This is H6\n', + html: '

This is H1

\n
This is H2
\n
This is H3
\n
This is H4
\n
This is H5
\n
This is H6
\n' }, { - markdown: '## This is an H2', - html: '

This is an H2

\n' + level: 5, + markdown: + '# This is H1\n## This is H2\n### This is H3\n#### This is H4\n##### This is H5\n###### This is H6\n', + html: '
This is H1
\n
This is H2
\n
This is H3
\n
This is H4
\n
This is H5
\n
This is H6
\n' }, { - markdown: '### This is an H3', - html: '

This is an H3

\n' + level: 6, + markdown: + '# This is H1\n## This is H2\n### This is H3\n#### This is H4\n##### This is H5\n###### This is H6\n', + html: '
This is H1
\n
This is H2
\n
This is H3
\n
This is H4
\n
This is H5
\n
This is H6
\n' } - ])("formats '$markdown' to '$html'", ({ markdown, html }) => { - expect(markdownToHtml(markdown)).toBe(html) + ])("formats '$markdown' to '$html'", ({ markdown, html, level }) => { + expect(markdownToHtml(markdown, { startingHeaderLevel: level })).toBe( + html + ) }) }) }) diff --git a/model/src/utils/markdown.ts b/model/src/utils/markdown.ts index d6e4f2ecba..febe4cb5d8 100644 --- a/model/src/utils/markdown.ts +++ b/model/src/utils/markdown.ts @@ -26,10 +26,13 @@ function renderLink(href: string, text: string, baseUrl?: string) { return `${label}` } -function demoteH1(text: string, depth: number) { - if (depth === 1) { - depth = 2 - } +function demoteHeading( + text: string, + depth: number, + startingHeaderLevel: number +) { + // Max heading is h6 so don't demote further than that + depth = Math.min(depth + startingHeaderLevel - 1, 6) return `${text} ` } @@ -41,7 +44,7 @@ export function markdownToHtml( markdown: string | null | undefined, options?: { baseUrl?: string // optional in some contexts, e.g. from the designer where it might not make sense, - demoteH1?: boolean + startingHeaderLevel?: number } ) { if (markdown === undefined || markdown === null) { @@ -59,9 +62,9 @@ export function markdownToHtml( renderer.link = ({ href, text }: Tokens.Link): string => { return renderLink(href, text, options?.baseUrl) } - if (options?.demoteH1) { + if (options?.startingHeaderLevel) { renderer.heading = ({ text, depth }: Tokens.Heading): string => { - return demoteH1(text, depth) + return demoteHeading(text, depth, options.startingHeaderLevel ?? 1) } } return marked.parse(escaped, { async: false, renderer }) From 2c00f8a817902beb6aa853d65aa987cb3e49684a Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 14 Nov 2025 13:27:08 +0000 Subject: [PATCH 4/4] Fixed test --- model/src/form/form-editor/preview/markdown.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/src/form/form-editor/preview/markdown.test.js b/model/src/form/form-editor/preview/markdown.test.js index 6d7b9d69f9..4204f7c3f6 100644 --- a/model/src/form/form-editor/preview/markdown.test.js +++ b/model/src/form/form-editor/preview/markdown.test.js @@ -42,7 +42,7 @@ describe('markdown', () => { res.optional = true expect(res.titleText).toBe('New question (optional)') res.content = '## This is a subheading' - expect(res.content).toBe('

This is a subheading

\n') + expect(res.content).toBe('

This is a subheading

\n') }) }) })