@bendyline/squisq-formats 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-6M7Z25LA.js +46 -0
- package/dist/chunk-6M7Z25LA.js.map +1 -0
- package/dist/{chunk-S3Y7H2BK.js → chunk-APSIAEXL.js} +9 -8
- package/dist/chunk-APSIAEXL.js.map +1 -0
- package/dist/{chunk-UDS45KUJ.js → chunk-BJQIJ4S4.js} +36 -52
- package/dist/chunk-BJQIJ4S4.js.map +1 -0
- package/dist/{chunk-33YRFXZZ.js → chunk-CRTC6DEX.js} +259 -13
- package/dist/chunk-CRTC6DEX.js.map +1 -0
- package/dist/chunk-FMOIGA5T.js +201 -0
- package/dist/chunk-FMOIGA5T.js.map +1 -0
- package/dist/{chunk-VN2KEOYB.js → chunk-I2GFTEOV.js} +321 -91
- package/dist/chunk-I2GFTEOV.js.map +1 -0
- package/dist/chunk-JBNVE2GV.js +116 -0
- package/dist/chunk-JBNVE2GV.js.map +1 -0
- package/dist/chunk-L53YIGYS.js +141 -0
- package/dist/chunk-L53YIGYS.js.map +1 -0
- package/dist/{chunk-ERZ627GR.js → chunk-ON232HJR.js} +11 -26
- package/dist/chunk-ON232HJR.js.map +1 -0
- package/dist/chunk-PN52A5AA.js +32 -0
- package/dist/chunk-PN52A5AA.js.map +1 -0
- package/dist/{chunk-7DEUGIOO.js → chunk-QSE7EWE7.js} +12 -87
- package/dist/chunk-QSE7EWE7.js.map +1 -0
- package/dist/container/index.d.ts +7 -5
- package/dist/container/index.js +39 -70
- package/dist/container/index.js.map +1 -1
- package/dist/csv/index.d.ts +37 -0
- package/dist/csv/index.js +13 -0
- package/dist/csv/index.js.map +1 -0
- package/dist/docx/index.d.ts +128 -15
- package/dist/docx/index.js +19 -22
- package/dist/docx/index.js.map +1 -1
- package/dist/epub/index.d.ts +66 -11
- package/dist/epub/index.js +11 -18
- package/dist/epub/index.js.map +1 -1
- package/dist/html/index.d.ts +315 -15
- package/dist/html/index.js +32 -165
- package/dist/html/index.js.map +1 -1
- package/dist/index.d.ts +10 -38
- package/dist/index.js +80 -37
- package/dist/index.js.map +1 -1
- package/dist/ooxml/index.d.ts +353 -14
- package/dist/ooxml/index.js +116 -22
- package/dist/ooxml/index.js.map +1 -1
- package/dist/pdf/index.d.ts +143 -17
- package/dist/pdf/index.js +16 -25
- package/dist/pdf/index.js.map +1 -1
- package/dist/pptx/index.d.ts +98 -21
- package/dist/pptx/index.js +18 -34
- package/dist/pptx/index.js.map +1 -1
- package/dist/xlsx/index.d.ts +29 -25
- package/dist/xlsx/index.js +13 -44
- package/dist/xlsx/index.js.map +1 -1
- package/package.json +7 -2
- package/src/__tests__/csvImport.test.ts +51 -0
- package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
- package/src/__tests__/htmlImport.test.ts +57 -0
- package/src/__tests__/plainHtml.test.ts +31 -0
- package/src/__tests__/pptxExport.test.ts +138 -0
- package/src/__tests__/pptxImport.test.ts +70 -0
- package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
- package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
- package/src/__tests__/roundTripMatrix.test.ts +136 -0
- package/src/__tests__/xlsxImport.test.ts +80 -0
- package/src/csv/index.ts +165 -0
- package/src/docx/export.ts +27 -44
- package/src/docx/import.ts +1 -2
- package/src/epub/export.ts +10 -27
- package/src/html/import.ts +297 -0
- package/src/html/index.ts +4 -0
- package/src/html/plainHtml.ts +66 -16
- package/src/index.ts +10 -3
- package/src/ooxml/namespaces.ts +3 -0
- package/src/pdf/export.ts +10 -5
- package/src/pdf/import.ts +1 -1
- package/src/pptx/export.ts +225 -85
- package/src/pptx/import.ts +156 -0
- package/src/pptx/index.ts +10 -28
- package/src/shared/inlineRuns.ts +99 -0
- package/src/shared/text.ts +41 -0
- package/src/xlsx/import.ts +159 -0
- package/src/xlsx/index.ts +11 -28
- package/dist/__tests__/container.test.d.ts +0 -8
- package/dist/__tests__/container.test.d.ts.map +0 -1
- package/dist/__tests__/container.test.js +0 -147
- package/dist/__tests__/container.test.js.map +0 -1
- package/dist/__tests__/docxExport.test.d.ts +0 -8
- package/dist/__tests__/docxExport.test.d.ts.map +0 -1
- package/dist/__tests__/docxExport.test.js +0 -383
- package/dist/__tests__/docxExport.test.js.map +0 -1
- package/dist/__tests__/docxImport.test.d.ts +0 -8
- package/dist/__tests__/docxImport.test.d.ts.map +0 -1
- package/dist/__tests__/docxImport.test.js +0 -309
- package/dist/__tests__/docxImport.test.js.map +0 -1
- package/dist/__tests__/epub.test.d.ts +0 -8
- package/dist/__tests__/epub.test.d.ts.map +0 -1
- package/dist/__tests__/epub.test.js +0 -472
- package/dist/__tests__/epub.test.js.map +0 -1
- package/dist/__tests__/html.test.d.ts +0 -8
- package/dist/__tests__/html.test.d.ts.map +0 -1
- package/dist/__tests__/html.test.js +0 -317
- package/dist/__tests__/html.test.js.map +0 -1
- package/dist/__tests__/ooxml.test.d.ts +0 -5
- package/dist/__tests__/ooxml.test.d.ts.map +0 -1
- package/dist/__tests__/ooxml.test.js +0 -152
- package/dist/__tests__/ooxml.test.js.map +0 -1
- package/dist/__tests__/pdfExport.test.d.ts +0 -8
- package/dist/__tests__/pdfExport.test.d.ts.map +0 -1
- package/dist/__tests__/pdfExport.test.js +0 -275
- package/dist/__tests__/pdfExport.test.js.map +0 -1
- package/dist/__tests__/pdfImport.test.d.ts +0 -11
- package/dist/__tests__/pdfImport.test.d.ts.map +0 -1
- package/dist/__tests__/pdfImport.test.js +0 -254
- package/dist/__tests__/pdfImport.test.js.map +0 -1
- package/dist/__tests__/plainHtml.test.d.ts +0 -5
- package/dist/__tests__/plainHtml.test.d.ts.map +0 -1
- package/dist/__tests__/plainHtml.test.js +0 -327
- package/dist/__tests__/plainHtml.test.js.map +0 -1
- package/dist/__tests__/plainHtmlBundle.test.d.ts +0 -11
- package/dist/__tests__/plainHtmlBundle.test.d.ts.map +0 -1
- package/dist/__tests__/plainHtmlBundle.test.js +0 -210
- package/dist/__tests__/plainHtmlBundle.test.js.map +0 -1
- package/dist/__tests__/roundTrip.test.d.ts +0 -7
- package/dist/__tests__/roundTrip.test.d.ts.map +0 -1
- package/dist/__tests__/roundTrip.test.js +0 -173
- package/dist/__tests__/roundTrip.test.js.map +0 -1
- package/dist/chunk-33YRFXZZ.js.map +0 -1
- package/dist/chunk-67KIJHV2.js +0 -21
- package/dist/chunk-67KIJHV2.js.map +0 -1
- package/dist/chunk-7DEUGIOO.js.map +0 -1
- package/dist/chunk-ERZ627GR.js.map +0 -1
- package/dist/chunk-S3Y7H2BK.js.map +0 -1
- package/dist/chunk-UDS45KUJ.js.map +0 -1
- package/dist/chunk-VN2KEOYB.js.map +0 -1
- package/dist/chunk-YN5HFCEW.js +0 -120
- package/dist/chunk-YN5HFCEW.js.map +0 -1
- package/dist/container/index.d.ts.map +0 -1
- package/dist/docx/export.d.ts +0 -69
- package/dist/docx/export.d.ts.map +0 -1
- package/dist/docx/export.js +0 -985
- package/dist/docx/export.js.map +0 -1
- package/dist/docx/import.d.ts +0 -65
- package/dist/docx/import.d.ts.map +0 -1
- package/dist/docx/import.js +0 -779
- package/dist/docx/import.js.map +0 -1
- package/dist/docx/index.d.ts.map +0 -1
- package/dist/docx/styles.d.ts +0 -68
- package/dist/docx/styles.d.ts.map +0 -1
- package/dist/docx/styles.js +0 -125
- package/dist/docx/styles.js.map +0 -1
- package/dist/epub/export.d.ts +0 -72
- package/dist/epub/export.d.ts.map +0 -1
- package/dist/epub/export.js +0 -698
- package/dist/epub/export.js.map +0 -1
- package/dist/epub/index.d.ts.map +0 -1
- package/dist/html/docsHtmlBundle.d.ts +0 -53
- package/dist/html/docsHtmlBundle.d.ts.map +0 -1
- package/dist/html/docsHtmlBundle.js +0 -299
- package/dist/html/docsHtmlBundle.js.map +0 -1
- package/dist/html/htmlTemplate.d.ts +0 -70
- package/dist/html/htmlTemplate.d.ts.map +0 -1
- package/dist/html/htmlTemplate.js +0 -282
- package/dist/html/htmlTemplate.js.map +0 -1
- package/dist/html/imageUtils.d.ts +0 -29
- package/dist/html/imageUtils.d.ts.map +0 -1
- package/dist/html/imageUtils.js +0 -63
- package/dist/html/imageUtils.js.map +0 -1
- package/dist/html/index.d.ts.map +0 -1
- package/dist/html/plainHtml.d.ts +0 -79
- package/dist/html/plainHtml.d.ts.map +0 -1
- package/dist/html/plainHtml.js +0 -614
- package/dist/html/plainHtml.js.map +0 -1
- package/dist/html/plainHtmlBundle.d.ts +0 -93
- package/dist/html/plainHtmlBundle.d.ts.map +0 -1
- package/dist/html/plainHtmlBundle.js +0 -357
- package/dist/html/plainHtmlBundle.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/ooxml/index.d.ts.map +0 -1
- package/dist/ooxml/namespaces.d.ts +0 -77
- package/dist/ooxml/namespaces.d.ts.map +0 -1
- package/dist/ooxml/namespaces.js +0 -104
- package/dist/ooxml/namespaces.js.map +0 -1
- package/dist/ooxml/reader.d.ts +0 -52
- package/dist/ooxml/reader.d.ts.map +0 -1
- package/dist/ooxml/reader.js +0 -190
- package/dist/ooxml/reader.js.map +0 -1
- package/dist/ooxml/types.d.ts +0 -82
- package/dist/ooxml/types.d.ts.map +0 -1
- package/dist/ooxml/types.js +0 -9
- package/dist/ooxml/types.js.map +0 -1
- package/dist/ooxml/writer.d.ts +0 -71
- package/dist/ooxml/writer.d.ts.map +0 -1
- package/dist/ooxml/writer.js +0 -200
- package/dist/ooxml/writer.js.map +0 -1
- package/dist/ooxml/xmlUtils.d.ts +0 -76
- package/dist/ooxml/xmlUtils.d.ts.map +0 -1
- package/dist/ooxml/xmlUtils.js +0 -108
- package/dist/ooxml/xmlUtils.js.map +0 -1
- package/dist/pdf/export.d.ts +0 -56
- package/dist/pdf/export.d.ts.map +0 -1
- package/dist/pdf/export.js +0 -768
- package/dist/pdf/export.js.map +0 -1
- package/dist/pdf/import.d.ts +0 -94
- package/dist/pdf/import.d.ts.map +0 -1
- package/dist/pdf/import.js +0 -796
- package/dist/pdf/import.js.map +0 -1
- package/dist/pdf/index.d.ts.map +0 -1
- package/dist/pdf/styles.d.ts +0 -142
- package/dist/pdf/styles.d.ts.map +0 -1
- package/dist/pdf/styles.js +0 -145
- package/dist/pdf/styles.js.map +0 -1
- package/dist/pptx/export.d.ts +0 -75
- package/dist/pptx/export.d.ts.map +0 -1
- package/dist/pptx/export.js +0 -724
- package/dist/pptx/export.js.map +0 -1
- package/dist/pptx/index.d.ts.map +0 -1
- package/dist/pptx/styles.d.ts +0 -54
- package/dist/pptx/styles.d.ts.map +0 -1
- package/dist/pptx/styles.js +0 -73
- package/dist/pptx/styles.js.map +0 -1
- package/dist/pptx/templates.d.ts +0 -24
- package/dist/pptx/templates.d.ts.map +0 -1
- package/dist/pptx/templates.js +0 -158
- package/dist/pptx/templates.js.map +0 -1
- package/dist/xlsx/index.d.ts.map +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export theme reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* Every export format must honor the theme the document selects in its
|
|
5
|
+
* frontmatter (`squisq-theme`) — including an inline custom theme stored in
|
|
6
|
+
* `squisq-custom-themes` — without an explicit `themeId` option. This guards
|
|
7
|
+
* the fix that made PDF/EPUB read the frontmatter theme like PPTX/DOCX/HTML,
|
|
8
|
+
* and that custom themes resolve doc-scoped in exports (no global register).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import JSZip from 'jszip';
|
|
13
|
+
import { compileTheme } from '@bendyline/squisq/schemas';
|
|
14
|
+
import { writeCustomThemesToFrontmatter } from '@bendyline/squisq/doc';
|
|
15
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
16
|
+
import { markdownDocToPptx } from '../pptx/export';
|
|
17
|
+
import { markdownDocToDocx } from '../docx/export';
|
|
18
|
+
import { markdownDocToPdf } from '../pdf/export';
|
|
19
|
+
import { markdownDocToEpub } from '../epub/export';
|
|
20
|
+
import { markdownDocToPlainHtml } from '../html/plainHtml';
|
|
21
|
+
|
|
22
|
+
const brand = compileTheme({
|
|
23
|
+
id: 'custom-brand',
|
|
24
|
+
name: 'Brand',
|
|
25
|
+
seedColors: { primary: '#ff0088', background: '#123456', text: '#abcdef' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const withTheme = parseMarkdown(`---
|
|
29
|
+
title: Audit
|
|
30
|
+
squisq-theme: custom-brand
|
|
31
|
+
squisq-custom-themes: ${writeCustomThemesToFrontmatter([brand])}
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
# Heading One
|
|
35
|
+
|
|
36
|
+
Body with a [link](https://example.com).
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
const noTheme = parseMarkdown('# Heading One\n\nBody.\n');
|
|
40
|
+
|
|
41
|
+
/** Concatenate the text of every XML/CSS/HTML entry in a ZIP export. */
|
|
42
|
+
async function zipText(buf: ArrayBuffer): Promise<string> {
|
|
43
|
+
const zip = await JSZip.loadAsync(buf);
|
|
44
|
+
let text = '';
|
|
45
|
+
for (const name of Object.keys(zip.files)) {
|
|
46
|
+
if (/\.(xml|css|html|xhtml|rels)$/i.test(name)) {
|
|
47
|
+
text += await zip.files[name].async('string');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return text.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('export theme reconciliation (frontmatter-only custom theme)', () => {
|
|
54
|
+
it('HTML applies the frontmatter custom theme colors', () => {
|
|
55
|
+
const html = markdownDocToPlainHtml(withTheme, {}).toLowerCase();
|
|
56
|
+
expect(html).toContain('123456'); // background
|
|
57
|
+
expect(html).toContain('abcdef'); // text
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('PPTX applies the frontmatter custom theme colors', async () => {
|
|
61
|
+
const text = await zipText(await markdownDocToPptx(withTheme, {}));
|
|
62
|
+
expect(text).toContain('123456');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('DOCX applies the frontmatter custom theme colors', async () => {
|
|
66
|
+
const text = await zipText(await markdownDocToDocx(withTheme, {}));
|
|
67
|
+
expect(text).toContain('123456');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('EPUB applies the frontmatter custom theme colors (frontmatter fallback)', async () => {
|
|
71
|
+
const text = await zipText(await markdownDocToEpub(withTheme, {}));
|
|
72
|
+
expect(text).toContain('123456');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('PDF themes from frontmatter — themed output differs from un-themed', async () => {
|
|
76
|
+
const themed = new Uint8Array(await markdownDocToPdf(withTheme, {}));
|
|
77
|
+
const plain = new Uint8Array(await markdownDocToPdf(noTheme, {}));
|
|
78
|
+
const differs = themed.length !== plain.length || !themed.every((b, i) => b === plain[i]);
|
|
79
|
+
expect(differs).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('an explicit themeId option still overrides the frontmatter theme', async () => {
|
|
83
|
+
const bare = await zipText(await markdownDocToPptx(withTheme, { themeId: 'standard' }));
|
|
84
|
+
// The custom background must NOT appear when the caller forces 'standard'.
|
|
85
|
+
expect(bare).not.toContain('123456');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { htmlToMarkdown, htmlToMarkdownDocSync } from '../html/import.js';
|
|
3
|
+
|
|
4
|
+
describe('htmlToMarkdown', () => {
|
|
5
|
+
it('converts headings and paragraphs', () => {
|
|
6
|
+
const md = htmlToMarkdown('<h1>Title</h1><p>Hello <strong>world</strong>.</p>');
|
|
7
|
+
expect(md).toContain('# Title');
|
|
8
|
+
expect(md).toContain('Hello **world**.');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('converts links and inline code', () => {
|
|
12
|
+
const md = htmlToMarkdown(
|
|
13
|
+
'<p>See <a href="https://x.test">site</a> and <code>npm i</code></p>',
|
|
14
|
+
);
|
|
15
|
+
expect(md).toContain('[site](https://x.test)');
|
|
16
|
+
expect(md).toContain('`npm i`');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('converts unordered and ordered lists', () => {
|
|
20
|
+
const md = htmlToMarkdown('<ul><li>a</li><li>b</li></ul><ol><li>one</li><li>two</li></ol>');
|
|
21
|
+
expect(md).toMatch(/[-*]\s+a/);
|
|
22
|
+
expect(md).toMatch(/[-*]\s+b/);
|
|
23
|
+
expect(md).toMatch(/1\.\s+one/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('converts blockquotes and tables', () => {
|
|
27
|
+
const md = htmlToMarkdown(
|
|
28
|
+
'<blockquote><p>quoted</p></blockquote>' +
|
|
29
|
+
'<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>cell</td></tr></tbody></table>',
|
|
30
|
+
);
|
|
31
|
+
expect(md).toContain('> quoted');
|
|
32
|
+
expect(md).toContain('cell');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('drops scripts and styles (sanitized by default)', () => {
|
|
36
|
+
const md = htmlToMarkdown('<p>safe</p><script>alert(1)</script><style>.x{color:red}</style>');
|
|
37
|
+
expect(md).toContain('safe');
|
|
38
|
+
expect(md).not.toContain('alert(1)');
|
|
39
|
+
expect(md).not.toContain('color:red');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('strips javascript: links via sanitizer', () => {
|
|
43
|
+
const md = htmlToMarkdown('<a href="javascript:alert(1)">click</a>');
|
|
44
|
+
expect(md).not.toContain('javascript:alert(1)');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('builds a document node tree', () => {
|
|
48
|
+
const doc = htmlToMarkdownDocSync('<h2>Hi</h2>');
|
|
49
|
+
expect(doc.type).toBe('document');
|
|
50
|
+
expect(doc.children[0]?.type).toBe('heading');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles unwrapped div/span containers', () => {
|
|
54
|
+
const md = htmlToMarkdown('<div><span>plain </span><em>text</em></div>');
|
|
55
|
+
expect(md).toContain('plain *text*');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -43,6 +43,12 @@ describe('markdownDocToPlainHtml', () => {
|
|
|
43
43
|
);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it('renders unsafe markdown links as inert text by default', () => {
|
|
47
|
+
const html = render('[x](javascript:alert(1))');
|
|
48
|
+
expect(html).toContain('<p>x</p>');
|
|
49
|
+
expect(html).not.toContain('javascript:');
|
|
50
|
+
});
|
|
51
|
+
|
|
46
52
|
it('renders ordered and unordered lists', () => {
|
|
47
53
|
const html = render('- one\n- two\n\n1. first\n2. second');
|
|
48
54
|
expect(html).toContain('<ul>');
|
|
@@ -112,6 +118,31 @@ describe('markdownDocToPlainHtml', () => {
|
|
|
112
118
|
expect(html).not.toContain('src="resized.png"');
|
|
113
119
|
});
|
|
114
120
|
|
|
121
|
+
it('sanitizes raw HTML by default', () => {
|
|
122
|
+
const html = render(
|
|
123
|
+
'<div><img src="x.jpg" onerror="alert(1)"><script>alert(1)</script><span onclick="alert(1)">ok</span></div>',
|
|
124
|
+
);
|
|
125
|
+
expect(html).toContain('<img src="x.jpg" />');
|
|
126
|
+
expect(html).toContain('<span>ok</span>');
|
|
127
|
+
expect(html).not.toContain('<script>');
|
|
128
|
+
expect(html).not.toContain('onerror');
|
|
129
|
+
expect(html).not.toContain('onclick');
|
|
130
|
+
expect(html).not.toContain('javascript:');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('strips raw HTML when requested', () => {
|
|
134
|
+
const html = render('<div><span>hidden</span></div>', { htmlPolicy: 'strip' });
|
|
135
|
+
expect(html).not.toContain('<span>hidden</span>');
|
|
136
|
+
expect(html).not.toContain('hidden');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('preserves raw HTML only with the trusted opt-in', () => {
|
|
140
|
+
const html = render('<div><span onclick="alert(1)">ok</span></div>', {
|
|
141
|
+
htmlPolicy: 'trusted',
|
|
142
|
+
});
|
|
143
|
+
expect(html).toContain('<div><span onclick="alert(1)">ok</span></div>');
|
|
144
|
+
});
|
|
145
|
+
|
|
115
146
|
it('escapes quotes in href and src attributes', () => {
|
|
116
147
|
const html = render('\n\n[c](d"e)');
|
|
117
148
|
expect(html).toContain('"');
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { MarkdownBlockNode, MarkdownDocument } from '@bendyline/squisq/markdown';
|
|
3
|
+
import type { Transition } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { TRANSITION_TYPES } from '@bendyline/squisq/schemas';
|
|
5
|
+
import { markdownDocToPptx } from '../pptx/export';
|
|
6
|
+
import { openPackage, getPartXml } from '../ooxml/reader';
|
|
7
|
+
import { NS_PML, NS_PML_2010 } from '../ooxml/namespaces';
|
|
8
|
+
|
|
9
|
+
function docWithSecondSlideTransition(transition: Transition): MarkdownDocument {
|
|
10
|
+
return {
|
|
11
|
+
type: 'document',
|
|
12
|
+
children: [
|
|
13
|
+
{
|
|
14
|
+
type: 'heading',
|
|
15
|
+
depth: 2,
|
|
16
|
+
children: [{ type: 'text', value: 'One' }],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
type: 'paragraph',
|
|
20
|
+
children: [{ type: 'text', value: 'Intro' }],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'heading',
|
|
24
|
+
depth: 2,
|
|
25
|
+
children: [{ type: 'text', value: 'Two' }],
|
|
26
|
+
attributes: {
|
|
27
|
+
params: {
|
|
28
|
+
transition: transition.type,
|
|
29
|
+
...(transition.duration !== undefined
|
|
30
|
+
? { transitionDuration: String(transition.duration) }
|
|
31
|
+
: {}),
|
|
32
|
+
...(transition.direction ? { transitionDirection: transition.direction } : {}),
|
|
33
|
+
},
|
|
34
|
+
blockMeta: { transition },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: 'paragraph',
|
|
39
|
+
children: [{ type: 'text', value: 'Body' }],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function docWithAllTransitions(): MarkdownDocument {
|
|
46
|
+
const children: MarkdownBlockNode[] = [
|
|
47
|
+
{
|
|
48
|
+
type: 'heading',
|
|
49
|
+
depth: 2,
|
|
50
|
+
children: [{ type: 'text', value: 'Transition Gallery' }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: 'paragraph',
|
|
54
|
+
children: [{ type: 'text', value: 'Intro slide before transition checks.' }],
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
for (const type of TRANSITION_TYPES) {
|
|
59
|
+
const transition: Transition = { type, duration: 0.8 };
|
|
60
|
+
children.push(
|
|
61
|
+
{
|
|
62
|
+
type: 'heading',
|
|
63
|
+
depth: 2,
|
|
64
|
+
children: [{ type: 'text', value: type }],
|
|
65
|
+
attributes: {
|
|
66
|
+
params: { transition: type, transitionDuration: '0.8' },
|
|
67
|
+
blockMeta: { transition },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'paragraph',
|
|
72
|
+
children: [{ type: 'text', value: `Transition token: ${type}` }],
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { type: 'document', children };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('markdownDocToPptx transitions', () => {
|
|
81
|
+
it('writes valid transition XML for every shared transition token', async () => {
|
|
82
|
+
const pkg = await openPackage(await markdownDocToPptx(docWithAllTransitions()));
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < TRANSITION_TYPES.length; i++) {
|
|
85
|
+
const type = TRANSITION_TYPES[i];
|
|
86
|
+
const slide = await getPartXml(pkg, `ppt/slides/slide${i + 2}.xml`);
|
|
87
|
+
expect(slide, type).not.toBeNull();
|
|
88
|
+
|
|
89
|
+
const transitions = slide!.getElementsByTagNameNS(NS_PML, 'transition');
|
|
90
|
+
if (type === 'cut') {
|
|
91
|
+
expect(transitions, type).toHaveLength(0);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
expect(transitions, type).toHaveLength(1);
|
|
96
|
+
expect(transitions[0].getAttribute('spd'), type).toBe('med');
|
|
97
|
+
|
|
98
|
+
const childElements = Array.from(transitions[0].childNodes).filter(
|
|
99
|
+
(node): node is Element => node.nodeType === 1,
|
|
100
|
+
);
|
|
101
|
+
expect(childElements, type).toHaveLength(1);
|
|
102
|
+
expect([NS_PML, NS_PML_2010], type).toContain(childElements[0].namespaceURI);
|
|
103
|
+
|
|
104
|
+
if (childElements[0].namespaceURI === NS_PML_2010) {
|
|
105
|
+
expect(slide!.documentElement.getAttribute('mc:Ignorable'), type).toContain('p14');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('writes standard PresentationML transition XML', async () => {
|
|
111
|
+
const md = docWithSecondSlideTransition({
|
|
112
|
+
type: 'checkerboard',
|
|
113
|
+
duration: 1.3,
|
|
114
|
+
direction: 'vertical',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const pkg = await openPackage(await markdownDocToPptx(md));
|
|
118
|
+
const slide = await getPartXml(pkg, 'ppt/slides/slide2.xml');
|
|
119
|
+
const transition = slide!.getElementsByTagNameNS(NS_PML, 'transition')[0];
|
|
120
|
+
const checker = transition.getElementsByTagNameNS(NS_PML, 'checker')[0];
|
|
121
|
+
|
|
122
|
+
expect(transition.getAttribute('spd')).toBe('slow');
|
|
123
|
+
expect(checker.getAttribute('dir')).toBe('vert');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('writes PowerPoint 2010 transition XML when needed', async () => {
|
|
127
|
+
const md = docWithSecondSlideTransition({ type: 'flash', duration: 0.4 });
|
|
128
|
+
|
|
129
|
+
const pkg = await openPackage(await markdownDocToPptx(md));
|
|
130
|
+
const slide = await getPartXml(pkg, 'ppt/slides/slide2.xml');
|
|
131
|
+
const transition = slide!.getElementsByTagNameNS(NS_PML, 'transition')[0];
|
|
132
|
+
const flash = transition.getElementsByTagNameNS(NS_PML_2010, 'flash')[0];
|
|
133
|
+
|
|
134
|
+
expect(transition.getAttribute('spd')).toBe('fast');
|
|
135
|
+
expect(flash).toBeDefined();
|
|
136
|
+
expect(slide!.documentElement.getAttribute('mc:Ignorable')).toBe('p14');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for PPTX import: pptxToMarkdownDoc. Builds a minimal .pptx fixture with
|
|
3
|
+
* the shared OOXML writer, then imports it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import type {
|
|
8
|
+
MarkdownHeading,
|
|
9
|
+
MarkdownList,
|
|
10
|
+
MarkdownParagraph,
|
|
11
|
+
MarkdownText,
|
|
12
|
+
} from '@bendyline/squisq/markdown';
|
|
13
|
+
import { NS_DRAWINGML, NS_PML, NS_R, REL_OFFICE_DOCUMENT, REL_SLIDE } from '../ooxml/namespaces';
|
|
14
|
+
import { createPackage } from '../ooxml/writer';
|
|
15
|
+
import { xmlDeclaration } from '../ooxml/xmlUtils';
|
|
16
|
+
import { pptxToMarkdownDoc } from '../pptx/import';
|
|
17
|
+
|
|
18
|
+
async function buildTestPptx(): Promise<ArrayBuffer> {
|
|
19
|
+
const pkg = createPackage();
|
|
20
|
+
|
|
21
|
+
pkg.addPart(
|
|
22
|
+
'ppt/presentation.xml',
|
|
23
|
+
`${xmlDeclaration()}<p:presentation xmlns:p="${NS_PML}" xmlns:r="${NS_R}">` +
|
|
24
|
+
`<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>`,
|
|
25
|
+
'application/xml',
|
|
26
|
+
);
|
|
27
|
+
pkg.addPart(
|
|
28
|
+
'ppt/slides/slide1.xml',
|
|
29
|
+
`${xmlDeclaration()}<p:sld xmlns:p="${NS_PML}" xmlns:a="${NS_DRAWINGML}"><p:cSld><p:spTree>` +
|
|
30
|
+
`<p:sp><p:nvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>` +
|
|
31
|
+
`<p:txBody><a:p><a:r><a:t>My Title</a:t></a:r></a:p></p:txBody></p:sp>` +
|
|
32
|
+
`<p:sp><p:txBody>` +
|
|
33
|
+
`<a:p><a:r><a:t>First bullet</a:t></a:r></a:p>` +
|
|
34
|
+
`<a:p><a:r><a:t>Second bullet</a:t></a:r></a:p>` +
|
|
35
|
+
`</p:txBody></p:sp>` +
|
|
36
|
+
`</p:spTree></p:cSld></p:sld>`,
|
|
37
|
+
'application/xml',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
pkg.addRelationship('', {
|
|
41
|
+
id: 'rId1',
|
|
42
|
+
type: REL_OFFICE_DOCUMENT,
|
|
43
|
+
target: 'ppt/presentation.xml',
|
|
44
|
+
});
|
|
45
|
+
pkg.addRelationship('ppt/presentation.xml', {
|
|
46
|
+
id: 'rId1',
|
|
47
|
+
type: REL_SLIDE,
|
|
48
|
+
target: 'slides/slide1.xml',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return pkg.toArrayBuffer();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('pptxToMarkdownDoc', () => {
|
|
55
|
+
it('imports a slide as a heading + bullet list', async () => {
|
|
56
|
+
const doc = await pptxToMarkdownDoc(await buildTestPptx());
|
|
57
|
+
expect(doc.type).toBe('document');
|
|
58
|
+
|
|
59
|
+
const heading = doc.children[0] as MarkdownHeading;
|
|
60
|
+
expect(heading.type).toBe('heading');
|
|
61
|
+
expect(heading.depth).toBe(2);
|
|
62
|
+
expect((heading.children[0] as MarkdownText).value).toBe('My Title');
|
|
63
|
+
|
|
64
|
+
const list = doc.children[1] as MarkdownList;
|
|
65
|
+
expect(list.type).toBe('list');
|
|
66
|
+
expect(list.children).toHaveLength(2);
|
|
67
|
+
const firstItemPara = list.children[0]!.children[0] as MarkdownParagraph;
|
|
68
|
+
expect((firstItemPara.children[0] as MarkdownText).value).toBe('First bullet');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { parseMarkdown, type MarkdownDocument } from '@bendyline/squisq/markdown';
|
|
2
|
+
|
|
3
|
+
export interface RoundTripFixture {
|
|
4
|
+
name: string;
|
|
5
|
+
markdown: string;
|
|
6
|
+
doc: MarkdownDocument;
|
|
7
|
+
keyPhrases: string[];
|
|
8
|
+
headingTexts: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeFixture(
|
|
12
|
+
name: string,
|
|
13
|
+
markdown: string,
|
|
14
|
+
keyPhrases: string[],
|
|
15
|
+
headingTexts: string[],
|
|
16
|
+
): RoundTripFixture {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
markdown,
|
|
20
|
+
doc: parseMarkdown(markdown),
|
|
21
|
+
keyPhrases,
|
|
22
|
+
headingTexts,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const ROUNDTRIP_FIXTURES = {
|
|
27
|
+
story: makeFixture(
|
|
28
|
+
'story',
|
|
29
|
+
`# River Journal
|
|
30
|
+
|
|
31
|
+
The field team walked the north bank and recorded water conditions.
|
|
32
|
+
|
|
33
|
+
## Morning Snapshot
|
|
34
|
+
|
|
35
|
+
- Water level is steady.
|
|
36
|
+
- Team status is ready.
|
|
37
|
+
- Safety checks are complete.
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
The crew published an update and linked the [full report](https://example.com/report).
|
|
42
|
+
`,
|
|
43
|
+
['river journal', 'north bank', 'water level', 'team status', 'full report'],
|
|
44
|
+
['River Journal', 'Morning Snapshot', 'Notes'],
|
|
45
|
+
),
|
|
46
|
+
|
|
47
|
+
mixed: makeFixture(
|
|
48
|
+
'mixed',
|
|
49
|
+
`# Ops Weekly
|
|
50
|
+
|
|
51
|
+
This week we tracked **launch readiness**, *quality signals*, and risk burn-down.
|
|
52
|
+
|
|
53
|
+
## Checklist
|
|
54
|
+
|
|
55
|
+
1. Confirm deployment window.
|
|
56
|
+
2. Validate rollback script.
|
|
57
|
+
3. Notify stakeholders.
|
|
58
|
+
|
|
59
|
+
## Outcome
|
|
60
|
+
|
|
61
|
+
Launch readiness improved and support tickets declined.
|
|
62
|
+
`,
|
|
63
|
+
[
|
|
64
|
+
'ops weekly',
|
|
65
|
+
'launch readiness',
|
|
66
|
+
'quality signals',
|
|
67
|
+
'rollback script',
|
|
68
|
+
'support tickets declined',
|
|
69
|
+
],
|
|
70
|
+
['Ops Weekly', 'Checklist', 'Outcome'],
|
|
71
|
+
),
|
|
72
|
+
|
|
73
|
+
table: makeFixture(
|
|
74
|
+
'table',
|
|
75
|
+
`# Metrics
|
|
76
|
+
|
|
77
|
+
| Metric | Value |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| Throughput | 120 req/s |
|
|
80
|
+
| Error Rate | 0.2% |
|
|
81
|
+
| P95 | 180ms |
|
|
82
|
+
`,
|
|
83
|
+
['metrics', 'throughput', '120 req/s', 'error rate', 'p95'],
|
|
84
|
+
['Metrics'],
|
|
85
|
+
),
|
|
86
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { expect } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
MarkdownDocument,
|
|
4
|
+
MarkdownNode,
|
|
5
|
+
MarkdownBlockNode,
|
|
6
|
+
MarkdownHeading,
|
|
7
|
+
} from '@bendyline/squisq/markdown';
|
|
8
|
+
|
|
9
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === 'object' && value !== null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function collectText(node: unknown, out: string[]): void {
|
|
14
|
+
if (!isRecord(node)) return;
|
|
15
|
+
|
|
16
|
+
const maybeType = node.type;
|
|
17
|
+
if (typeof maybeType === 'string') {
|
|
18
|
+
if (maybeType === 'text' && typeof node.value === 'string') {
|
|
19
|
+
out.push(node.value);
|
|
20
|
+
}
|
|
21
|
+
if ((maybeType === 'code' || maybeType === 'inlineCode') && typeof node.value === 'string') {
|
|
22
|
+
out.push(node.value);
|
|
23
|
+
}
|
|
24
|
+
if (maybeType === 'link' && typeof node.url === 'string') {
|
|
25
|
+
out.push(node.url);
|
|
26
|
+
}
|
|
27
|
+
if (maybeType === 'image') {
|
|
28
|
+
if (typeof node.alt === 'string') out.push(node.alt);
|
|
29
|
+
if (typeof node.url === 'string') out.push(node.url);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const children = node.children;
|
|
34
|
+
if (Array.isArray(children)) {
|
|
35
|
+
for (const child of children) collectText(child, out);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeText(input: string): string {
|
|
40
|
+
return input
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/\r\n/g, '\n')
|
|
43
|
+
.replace(/[^a-z0-9%/.:\-\s]+/g, ' ')
|
|
44
|
+
.replace(/\s+/g, ' ')
|
|
45
|
+
.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function compactText(input: string): string {
|
|
49
|
+
return normalizeText(input).replace(/\s+/g, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractDocumentText(doc: MarkdownDocument): string {
|
|
53
|
+
const out: string[] = [];
|
|
54
|
+
collectText(doc, out);
|
|
55
|
+
return normalizeText(out.join(' '));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractHeadingTexts(doc: MarkdownDocument): string[] {
|
|
59
|
+
return doc.children
|
|
60
|
+
.filter((block): block is MarkdownHeading => block.type === 'heading')
|
|
61
|
+
.map((heading) => {
|
|
62
|
+
const out: string[] = [];
|
|
63
|
+
collectText(heading, out);
|
|
64
|
+
return normalizeText(out.join(' '));
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function containsNodeType(node: unknown, wantedType: string): boolean {
|
|
70
|
+
if (!isRecord(node)) return false;
|
|
71
|
+
if (node.type === wantedType) return true;
|
|
72
|
+
const children = node.children;
|
|
73
|
+
if (!Array.isArray(children)) return false;
|
|
74
|
+
return children.some((child) => containsNodeType(child, wantedType));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function retainRatio(source: string, roundTrip: string): number {
|
|
78
|
+
const sourceTokens = source.split(' ').filter((t) => t.length >= 4);
|
|
79
|
+
if (sourceTokens.length === 0) return 1;
|
|
80
|
+
|
|
81
|
+
const targetSet = new Set(roundTrip.split(' ').filter((t) => t.length >= 4));
|
|
82
|
+
let matched = 0;
|
|
83
|
+
for (const token of sourceTokens) {
|
|
84
|
+
if (targetSet.has(token)) matched++;
|
|
85
|
+
}
|
|
86
|
+
return matched / sourceTokens.length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface HybridRoundTripAssertions {
|
|
90
|
+
source: MarkdownDocument;
|
|
91
|
+
roundTrip: MarkdownDocument;
|
|
92
|
+
keyPhrases: string[];
|
|
93
|
+
headingTexts?: string[];
|
|
94
|
+
requireHeadingOrder?: boolean;
|
|
95
|
+
requireListSurvival?: boolean;
|
|
96
|
+
requireTableSurvival?: boolean;
|
|
97
|
+
minRetainRatio?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function assertHybridRoundTrip(args: HybridRoundTripAssertions): void {
|
|
101
|
+
const sourceText = extractDocumentText(args.source);
|
|
102
|
+
const roundTripText = extractDocumentText(args.roundTrip);
|
|
103
|
+
|
|
104
|
+
expect(roundTripText.length).toBeGreaterThan(0);
|
|
105
|
+
|
|
106
|
+
const minRetainRatio = args.minRetainRatio ?? 0.5;
|
|
107
|
+
expect(retainRatio(sourceText, roundTripText)).toBeGreaterThanOrEqual(minRetainRatio);
|
|
108
|
+
|
|
109
|
+
for (const phrase of args.keyPhrases) {
|
|
110
|
+
const normalizedPhrase = normalizeText(phrase);
|
|
111
|
+
if (roundTripText.includes(normalizedPhrase)) continue;
|
|
112
|
+
expect(compactText(roundTripText)).toContain(compactText(normalizedPhrase));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (args.requireHeadingOrder && args.headingTexts && args.headingTexts.length > 0) {
|
|
116
|
+
const normalizedExpected = args.headingTexts.map((h) => normalizeText(h));
|
|
117
|
+
const actualHeadings = extractHeadingTexts(args.roundTrip);
|
|
118
|
+
|
|
119
|
+
let cursor = -1;
|
|
120
|
+
for (const expectedHeading of normalizedExpected) {
|
|
121
|
+
const next = actualHeadings.findIndex(
|
|
122
|
+
(heading, index) => index > cursor && heading.includes(expectedHeading),
|
|
123
|
+
);
|
|
124
|
+
expect(next).toBeGreaterThan(cursor);
|
|
125
|
+
cursor = next;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (args.requireListSurvival && containsNodeType(args.source, 'list')) {
|
|
130
|
+
expect(containsNodeType(args.roundTrip, 'list')).toBe(true);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (args.requireTableSurvival && containsNodeType(args.source, 'table')) {
|
|
134
|
+
expect(containsNodeType(args.roundTrip, 'table')).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function extractNormalizedText(doc: MarkdownDocument): string {
|
|
139
|
+
return extractDocumentText(doc);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function docHasNodeType(doc: MarkdownDocument, wantedType: MarkdownNode['type']): boolean {
|
|
143
|
+
return containsNodeType(doc, wantedType);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function firstHeadingText(doc: MarkdownDocument): string | null {
|
|
147
|
+
const firstHeading = doc.children.find(
|
|
148
|
+
(block): block is MarkdownBlockNode & { type: 'heading' } => block.type === 'heading',
|
|
149
|
+
);
|
|
150
|
+
if (!firstHeading) return null;
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
collectText(firstHeading, out);
|
|
153
|
+
return normalizeText(out.join(' '));
|
|
154
|
+
}
|