create-eziwiki 0.2.0 → 0.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.
@@ -25,6 +25,21 @@ export interface ContentDoc {
25
25
  order: number;
26
26
  /** Excluded from navigation, but still reachable by direct URL */
27
27
  hidden: boolean;
28
+ /**
29
+ * Labels grouping this document with others across the folder tree.
30
+ *
31
+ * A file sits in exactly one directory, so the sidebar can only express one
32
+ * way of organising a wiki. Tags are the second axis: a page belongs to one
33
+ * section and to as many subjects as it touches.
34
+ */
35
+ tags: string[];
36
+ /**
37
+ * Paths this document used to live at.
38
+ *
39
+ * A wiki moves pages; without these, every published link to the old
40
+ * location breaks silently the moment a file is renamed.
41
+ */
42
+ aliases: string[];
28
43
  /** Full parsed frontmatter, for consumers that need custom fields */
29
44
  frontmatter: Record<string, unknown>;
30
45
  /** Markdown body with frontmatter stripped */
@@ -107,6 +122,63 @@ function readBoolean(value: unknown): boolean {
107
122
  return false;
108
123
  }
109
124
 
125
+ /**
126
+ * Reads the `tags` frontmatter.
127
+ *
128
+ * Accepts a single tag or a list, and a comma-separated string, because all
129
+ * three are how people write this and none of them is wrong. Tags are compared
130
+ * case-insensitively — `Setup` and `setup` are one subject, and treating them
131
+ * as two would split a wiki quietly — but the first spelling seen is the one
132
+ * displayed.
133
+ *
134
+ * @param value - The raw frontmatter value
135
+ * @returns Tags in the order written, without duplicates
136
+ */
137
+ function readTags(value: unknown): string[] {
138
+ const raw = typeof value === 'string' ? value.split(',') : Array.isArray(value) ? value : [];
139
+
140
+ const seen = new Set<string>();
141
+ const tags: string[] = [];
142
+
143
+ for (const entry of raw) {
144
+ if (typeof entry !== 'string') continue;
145
+
146
+ const tag = entry.trim();
147
+ if (!tag) continue;
148
+
149
+ const key = tag.toLowerCase();
150
+ if (seen.has(key)) continue;
151
+
152
+ seen.add(key);
153
+ tags.push(tag);
154
+ }
155
+
156
+ return tags;
157
+ }
158
+
159
+ /**
160
+ * Reads the `aliases` frontmatter into a list of content paths.
161
+ *
162
+ * Accepts a single string or a list, since an author moving one page writes
163
+ * one path and should not have to remember which form is required. Leading and
164
+ * trailing slashes and a `.md` suffix are tolerated: the value looks like a
165
+ * path, and being strict about its punctuation would only produce silent
166
+ * misses.
167
+ *
168
+ * @param value - The raw frontmatter value
169
+ * @returns Normalised content paths, without duplicates
170
+ */
171
+ function readAliases(value: unknown): string[] {
172
+ const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [];
173
+
174
+ const paths = raw
175
+ .filter((entry): entry is string => typeof entry === 'string')
176
+ .map((entry) => entry.trim().replace(/^\/+/, '').replace(/\.md$/i, '').replace(/\/+$/, ''))
177
+ .filter(Boolean);
178
+
179
+ return [...new Set(paths)];
180
+ }
181
+
110
182
  /**
111
183
  * Reads the optional `_meta.json` for a content subdirectory.
112
184
  *
@@ -198,6 +270,8 @@ function readDoc(filePath: string): ContentDoc | null {
198
270
  description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
199
271
  order: readOrder(frontmatter.order),
200
272
  hidden: readBoolean(frontmatter.hidden) || frontmatter.nav === false,
273
+ tags: readTags(frontmatter.tags),
274
+ aliases: readAliases(frontmatter.aliases),
201
275
  frontmatter,
202
276
  content,
203
277
  filePath,
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getTags, getTag, getTagsFor, findTagRouteCollisions } from './tags';
3
+ import { getContentRegistry } from './registry';
4
+ import { getSite } from '../site';
5
+
6
+ describe('getTags', () => {
7
+ it('gathers pages by subject across the folder tree', () => {
8
+ for (const tag of getTags()) {
9
+ expect(tag.pages.length).toBeGreaterThan(0);
10
+ expect(tag.slug).toBe(tag.name.toLowerCase());
11
+ }
12
+ });
13
+
14
+ it('sorts subjects by name', () => {
15
+ const names = getTags().map((tag) => tag.name);
16
+
17
+ expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
18
+ });
19
+
20
+ // A page kept off the sidebar on purpose should not reappear here, or the
21
+ // tag index becomes a way of enumerating exactly what was unlisted.
22
+ it('leaves hidden pages out', () => {
23
+ const { hiddenPaths } = getSite();
24
+ const listed = getTags().flatMap((tag) => tag.pages.map((page) => page.path));
25
+
26
+ for (const hidden of hiddenPaths) {
27
+ expect(listed).not.toContain(hidden);
28
+ }
29
+ });
30
+
31
+ it('emits URLs in the form the export serves', () => {
32
+ for (const tag of getTags()) {
33
+ for (const page of tag.pages) {
34
+ expect(page.url).toMatch(/^\/.*\/$/);
35
+ }
36
+ }
37
+ });
38
+
39
+ it('lists a page under each of its tags', () => {
40
+ const { docs } = getContentRegistry();
41
+ const { hiddenPaths } = getSite();
42
+
43
+ for (const doc of docs) {
44
+ if (hiddenPaths.has(doc.path) || doc.tags.length === 0) continue;
45
+
46
+ for (const name of doc.tags) {
47
+ const tag = getTag(name.toLowerCase());
48
+ expect(tag?.pages.some((page) => page.path === doc.path)).toBe(true);
49
+ }
50
+ }
51
+ });
52
+ });
53
+
54
+ describe('getTag', () => {
55
+ it('finds a subject however its name is cased', () => {
56
+ const [first] = getTags();
57
+ if (!first) return;
58
+
59
+ expect(getTag(first.slug)?.slug).toBe(first.slug);
60
+ expect(getTag(first.slug.toUpperCase())?.slug).toBe(first.slug);
61
+ });
62
+
63
+ it('finds nothing for a subject no page carries', () => {
64
+ expect(getTag('no-such-subject')).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe('getTagsFor', () => {
69
+ it('returns the tags a page carries', () => {
70
+ const { docs } = getContentRegistry();
71
+ const { hiddenPaths } = getSite();
72
+ const tagged = docs.find((doc) => doc.tags.length > 0 && !hiddenPaths.has(doc.path));
73
+ if (!tagged) return;
74
+
75
+ expect(
76
+ getTagsFor(tagged.path)
77
+ .map((tag) => tag.slug)
78
+ .sort(),
79
+ ).toEqual(tagged.tags.map((tag) => tag.toLowerCase()).sort());
80
+ });
81
+
82
+ it('returns nothing for an untagged page', () => {
83
+ expect(getTagsFor('no/such/page')).toEqual([]);
84
+ });
85
+ });
86
+
87
+ // `/tags/…` is a route of its own and Next resolves it before the catch-all,
88
+ // so a page published there would be unreachable.
89
+ describe('findTagRouteCollisions', () => {
90
+ it('reports no collision in this wiki', () => {
91
+ expect(findTagRouteCollisions()).toEqual([]);
92
+ });
93
+ });
@@ -0,0 +1,139 @@
1
+ import { getContentRegistry, type ContentDoc } from './registry';
2
+ import { getSite } from '../site';
3
+ import { docPathToUrl } from '../navigation/url';
4
+ import { cached } from '../cache';
5
+
6
+ /**
7
+ * Subjects, gathered across the folder tree.
8
+ *
9
+ * A file lives in one directory, so the sidebar can only ever show one way of
10
+ * organising a wiki. Tags are the other way: a page sits in one section and
11
+ * touches as many subjects as it touches. Where the graph says which pages
12
+ * mention each other, tags say which are about the same thing whether or not
13
+ * anyone thought to link them.
14
+ *
15
+ * Server-only: reads the content registry.
16
+ */
17
+
18
+ /** Route segment the tag pages live under. */
19
+ export const TAGS_SEGMENT = 'tags';
20
+
21
+ /** A page carrying a tag. */
22
+ export interface TaggedPage {
23
+ /** Content path */
24
+ path: string;
25
+ /** Display title */
26
+ title: string;
27
+ /** Href, in the site's configured URL form */
28
+ url: string;
29
+ /** Short summary, when the page has one */
30
+ description?: string;
31
+ }
32
+
33
+ /** A subject and the pages about it. */
34
+ export interface Tag {
35
+ /** The tag as first written by an author */
36
+ name: string;
37
+ /** Lowercased form, used in URLs and for comparison */
38
+ slug: string;
39
+ /** Pages carrying it, in reading order */
40
+ pages: TaggedPage[];
41
+ }
42
+
43
+ let memo: Tag[] | null = null;
44
+
45
+ /**
46
+ * Converts a page to the shape a listing needs.
47
+ */
48
+ function toTaggedPage(doc: ContentDoc): TaggedPage | null {
49
+ const url = docPathToUrl(getSite().urlMap, doc.path);
50
+ if (!url) return null;
51
+
52
+ return { path: doc.path, title: doc.title, url: `/${url}/`, description: doc.description };
53
+ }
54
+
55
+ /**
56
+ * Collects every tag and the pages carrying it.
57
+ *
58
+ * Hidden pages are left out. A page kept off the sidebar on purpose should not
59
+ * reappear in a tag listing, which would make the tag index a way of
60
+ * enumerating exactly what was meant to stay unlisted.
61
+ *
62
+ * @returns Tags sorted by name, each with its pages
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * getTags().map((tag) => `${tag.name} (${tag.pages.length})`);
67
+ * ```
68
+ */
69
+ export function getTags(): Tag[] {
70
+ const hit = cached(memo);
71
+ if (hit) return hit;
72
+
73
+ const { docs } = getContentRegistry();
74
+ const { hiddenPaths } = getSite();
75
+ const bySlug = new Map<string, Tag>();
76
+
77
+ for (const doc of docs) {
78
+ if (hiddenPaths.has(doc.path)) continue;
79
+
80
+ const page = toTaggedPage(doc);
81
+ if (!page) continue;
82
+
83
+ for (const name of doc.tags) {
84
+ const slug = name.toLowerCase();
85
+ const existing = bySlug.get(slug);
86
+
87
+ // The first spelling wins, so a wiki that writes `Setup` once and `setup`
88
+ // thereafter still shows one tag rather than two.
89
+ if (existing) existing.pages.push(page);
90
+ else bySlug.set(slug, { name, slug, pages: [page] });
91
+ }
92
+ }
93
+
94
+ memo = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name));
95
+ return memo;
96
+ }
97
+
98
+ /**
99
+ * Finds one tag by its slug.
100
+ *
101
+ * @param slug - Lowercased tag name from the URL
102
+ * @returns The tag, or null when nothing carries it
103
+ */
104
+ export function getTag(slug: string): Tag | null {
105
+ const wanted = decodeURIComponent(slug).toLowerCase();
106
+ return getTags().find((tag) => tag.slug === wanted) ?? null;
107
+ }
108
+
109
+ /**
110
+ * Returns the tags on one page, in the order they were written.
111
+ *
112
+ * @param path - Content path
113
+ * @returns The page's tags, empty when it has none
114
+ */
115
+ export function getTagsFor(path: string): Tag[] {
116
+ return getTags().filter((tag) => tag.pages.some((page) => page.path === path));
117
+ }
118
+
119
+ /**
120
+ * Reports a content page whose URL the tag routes would shadow.
121
+ *
122
+ * `/tags/…` is a route of its own, and Next resolves it before the catch-all
123
+ * that serves content, so a page published at that address would become
124
+ * unreachable. Surfacing it is better than letting a page quietly disappear;
125
+ * the fix is to rename the file or the directory.
126
+ *
127
+ * @returns Paths that collide, empty when none do
128
+ */
129
+ export function findTagRouteCollisions(): string[] {
130
+ const { docs } = getContentRegistry();
131
+ const { urlMap } = getSite();
132
+
133
+ return docs
134
+ .filter((doc) => {
135
+ const url = docPathToUrl(urlMap, doc.path);
136
+ return url === TAGS_SEGMENT || url?.startsWith(`${TAGS_SEGMENT}/`);
137
+ })
138
+ .map((doc) => doc.path);
139
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getWikiHealth } from './health';
3
+ import { getLinkGraph } from './build';
4
+ import { getReadingOrder } from '../navigation/sequence';
5
+
6
+ describe('getWikiHealth', () => {
7
+ it('finds pages nothing links to', () => {
8
+ const graph = getLinkGraph();
9
+
10
+ for (const page of getWikiHealth().orphans) {
11
+ expect(graph.backlinks.get(page.path) ?? []).toHaveLength(0);
12
+ }
13
+ });
14
+
15
+ it('finds pages with no links out', () => {
16
+ const graph = getLinkGraph();
17
+
18
+ for (const page of getWikiHealth().deadEnds) {
19
+ expect(graph.outbound.get(page.path) ?? []).toHaveLength(0);
20
+ }
21
+ });
22
+
23
+ // Where a reader starts needs nothing pointing at it. Reporting it on every
24
+ // build would teach everyone to ignore the report.
25
+ it('never calls the entry page an orphan', () => {
26
+ const [entry] = getReadingOrder();
27
+
28
+ expect(getWikiHealth().orphans.map((page) => page.path)).not.toContain(entry);
29
+ });
30
+
31
+ it('reports only pages that are in the graph', () => {
32
+ const paths = new Set(getLinkGraph().nodes.map((node) => node.path));
33
+ const { orphans, deadEnds } = getWikiHealth();
34
+
35
+ for (const page of [...orphans, ...deadEnds]) {
36
+ expect(paths.has(page.path)).toBe(true);
37
+ }
38
+ });
39
+
40
+ // Hidden pages are absent from the graph entirely, so an unlisted page is
41
+ // not reported as disconnected — it is unlisted on purpose.
42
+ it('says nothing about hidden pages', () => {
43
+ const { orphans, deadEnds } = getWikiHealth();
44
+ const reported = [...orphans, ...deadEnds].map((page) => page.path);
45
+ const visible = new Set(getLinkGraph().nodes.map((node) => node.path));
46
+
47
+ for (const path of reported) {
48
+ expect(visible.has(path)).toBe(true);
49
+ }
50
+ });
51
+
52
+ it('gives every reported page a title and a link', () => {
53
+ const { orphans, deadEnds } = getWikiHealth();
54
+
55
+ for (const page of [...orphans, ...deadEnds]) {
56
+ expect(page.title).toBeTruthy();
57
+ expect(page.url).toBeTruthy();
58
+ }
59
+ });
60
+ });
@@ -0,0 +1,58 @@
1
+ import { getLinkGraph, type GraphNode } from './build';
2
+ import { getReadingOrder } from '../navigation/sequence';
3
+ import { cached } from '../cache';
4
+
5
+ /**
6
+ * What the link graph says about the state of the wiki.
7
+ *
8
+ * A broken link is an error and already reported. These are not errors — a
9
+ * wiki can be perfectly correct and still have them — but they are the shapes
10
+ * a collection of documents falls into when it stops being a wiki: pages
11
+ * nothing leads to, and pages nothing leads on from. Neither is visible from
12
+ * inside a single document, and neither shows up in a link check, which only
13
+ * asks whether the links that exist resolve.
14
+ *
15
+ * Server-only.
16
+ */
17
+
18
+ /** Pages the graph flags as worth a second look. */
19
+ export interface WikiHealth {
20
+ /** Pages nothing links to, so a reader can only arrive through the sidebar */
21
+ orphans: GraphNode[];
22
+ /** Pages with no links out, where a reader arrives and has nowhere to go */
23
+ deadEnds: GraphNode[];
24
+ }
25
+
26
+ let memo: WikiHealth | null = null;
27
+
28
+ /**
29
+ * Finds pages that are disconnected from the rest of the wiki.
30
+ *
31
+ * The first page in reading order is never an orphan. It is where a reader
32
+ * starts, so nothing needs to point at it, and reporting it every build would
33
+ * teach everyone to ignore the report.
34
+ *
35
+ * @returns The pages worth looking at
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const { orphans, deadEnds } = getWikiHealth();
40
+ * orphans.map((page) => page.path); // ['examples/api-docs', …]
41
+ * ```
42
+ */
43
+ export function getWikiHealth(): WikiHealth {
44
+ const hit = cached(memo);
45
+ if (hit) return hit;
46
+
47
+ const graph = getLinkGraph();
48
+ const [entry] = getReadingOrder();
49
+
50
+ const orphans = graph.nodes.filter(
51
+ (node) => node.path !== entry && (graph.backlinks.get(node.path) ?? []).length === 0,
52
+ );
53
+
54
+ const deadEnds = graph.nodes.filter((node) => (graph.outbound.get(node.path) ?? []).length === 0);
55
+
56
+ memo = { orphans, deadEnds };
57
+ return memo;
58
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderMarkdown } from './render';
3
+
4
+ /** Renders a callout and reports what it became. */
5
+ async function render(markdown: string) {
6
+ const { html } = await renderMarkdown(`${markdown}\n`);
7
+
8
+ return {
9
+ html,
10
+ kind: (html.match(/ezw-callout--(\w+)/) ?? [])[1] ?? null,
11
+ tag: (html.match(/<(details|div)[^>]*ezw-callout/) ?? [])[1] ?? null,
12
+ title: (html.match(/ezw-callout__title">([^<]*)/) ?? [])[1] ?? null,
13
+ };
14
+ }
15
+
16
+ describe('callouts', () => {
17
+ it('turns a marked blockquote into a callout', async () => {
18
+ const { kind, tag, title, html } = await render('> [!NOTE]\n> Useful information.');
19
+
20
+ expect(kind).toBe('note');
21
+ expect(tag).toBe('div');
22
+ expect(title).toBe('Note');
23
+ expect(html).toContain('Useful information.');
24
+ });
25
+
26
+ it('uses a title given on the marker line', async () => {
27
+ expect((await render('> [!WARNING] Mind the gap\n> Careful.')).title).toBe('Mind the gap');
28
+ });
29
+
30
+ it('recognises the kinds case-insensitively', async () => {
31
+ expect((await render('> [!note]\n> x')).kind).toBe('note');
32
+ expect((await render('> [!NoTe]\n> x')).kind).toBe('note');
33
+ });
34
+
35
+ // Obsidian defines more kinds than GitHub and vaults use them, so they map
36
+ // onto the nearest one instead of losing their formatting.
37
+ it('maps the extra Obsidian kinds onto the nearest one', async () => {
38
+ expect((await render('> [!danger]\n> x')).kind).toBe('caution');
39
+ expect((await render('> [!success]\n> x')).kind).toBe('tip');
40
+ expect((await render('> [!question]\n> x')).kind).toBe('important');
41
+ });
42
+
43
+ it('leaves an unknown kind as an ordinary quote', async () => {
44
+ const { kind, html } = await render('> [!nonsense]\n> x');
45
+
46
+ expect(kind).toBeNull();
47
+ expect(html).toContain('<blockquote>');
48
+ });
49
+
50
+ it('leaves a plain quote alone', async () => {
51
+ const { kind, html } = await render('> Just a quote.');
52
+
53
+ expect(kind).toBeNull();
54
+ expect(html).toContain('<blockquote>');
55
+ });
56
+
57
+ // `<details>` opens and closes without script, so a disclosure keeps working
58
+ // with JavaScript disabled.
59
+ it('folds with a trailing - or +', async () => {
60
+ const closed = await render('> [!TIP]- Optional\n> Hidden.');
61
+ const open = await render('> [!TIP]+ Shown\n> Visible.');
62
+
63
+ expect(closed.tag).toBe('details');
64
+ expect(closed.html).not.toMatch(/<details[^>]*\sopen/);
65
+ expect(open.tag).toBe('details');
66
+ expect(open.html).toMatch(/<details[^>]*\sopen/);
67
+ });
68
+
69
+ // The body passes through the rest of the pipeline, so nothing inside a
70
+ // callout behaves differently from the same text outside one.
71
+ it('renders links, wiki links and code inside the body', async () => {
72
+ // `intro` is the one page both this repository and a scaffolded project
73
+ // have, so the test travels with the engine.
74
+ const wiki = await render('> [!NOTE]\n> See [[intro]].');
75
+ const code = await render('> [!TIP]\n> Run `npm i`.');
76
+
77
+ expect(wiki.html).toContain('ezw-wikilink');
78
+ expect(code.html).toContain('<code');
79
+ });
80
+
81
+ it('keeps a multi-line body together', async () => {
82
+ const { html } = await render('> [!NOTE]\n> First line.\n> Second line.');
83
+
84
+ expect(html).toContain('First line.');
85
+ expect(html).toContain('Second line.');
86
+ });
87
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderMarkdown } from './render';
3
+
4
+ /** Wraps a diagram in a fence tagged `mermaid`. */
5
+ function fence(body: string): string {
6
+ return ['```mermaid', body, '```', ''].join('\n');
7
+ }
8
+
9
+ describe('mermaid diagrams', () => {
10
+ it('draws a fence as SVG during the build', async () => {
11
+ const { html } = await renderMarkdown(fence('flowchart TD\n A[One] --> B[Two]'));
12
+
13
+ expect(html).toContain('class="ezw-mermaid"');
14
+ expect(html).toContain('<svg');
15
+ expect(html).toContain('One');
16
+ });
17
+
18
+ // The site self-hosts and subsets its fonts; the renderer inlines a Google
19
+ // Fonts import that would undo that on every page carrying a diagram.
20
+ it('carries no third-party request', async () => {
21
+ const { html } = await renderMarkdown(fence('flowchart TD\n A --> B'));
22
+
23
+ expect(html).not.toContain('fonts.googleapis.com');
24
+ expect(html).not.toContain('@import');
25
+ });
26
+
27
+ // Fixed inline, the colours would survive a switch to dark; left to CSS, the
28
+ // diagram follows the theme like everything else.
29
+ it('leaves its colours to the stylesheet', async () => {
30
+ const { html } = await renderMarkdown(fence('flowchart TD\n A --> B'));
31
+
32
+ expect(html).toMatch(/var\(--(bg|fg)\)/);
33
+ expect(html).not.toMatch(/<svg[^>]*style="[^"]*--bg/);
34
+ });
35
+
36
+ // Nothing is drawn in the browser, so a crawler and a reader without
37
+ // JavaScript see the same diagram everyone else does.
38
+ it('needs no script to appear', async () => {
39
+ const { html } = await renderMarkdown(fence('flowchart TD\n A --> B'));
40
+
41
+ expect(html).not.toContain('<script');
42
+ });
43
+
44
+ it('draws the kinds a wiki actually uses', async () => {
45
+ const kinds = [
46
+ 'sequenceDiagram\n A->>B: hello',
47
+ 'stateDiagram-v2\n [*] --> Draft',
48
+ 'classDiagram\n class Page',
49
+ 'erDiagram\n PAGE ||--o{ LINK : has',
50
+ ];
51
+
52
+ for (const source of kinds) {
53
+ expect((await renderMarkdown(fence(source))).html).toContain('ezw-mermaid');
54
+ }
55
+ });
56
+
57
+ // A kind the renderer cannot draw leaves the reader exactly what they had
58
+ // before diagrams existed, rather than stopping the build.
59
+ it('falls back to a code block on an unsupported diagram', async () => {
60
+ const { html } = await renderMarkdown(fence('pie title X\n "a" : 1'));
61
+
62
+ expect(html).not.toContain('ezw-mermaid');
63
+ expect(html).toContain('data-language="mermaid"');
64
+ });
65
+
66
+ it('leaves ordinary code fences alone', async () => {
67
+ const { html } = await renderMarkdown('```js\nconst a = 1;\n```\n');
68
+
69
+ expect(html).toContain('ezw-code');
70
+ expect(html).not.toContain('ezw-mermaid');
71
+ });
72
+ });