story-theme-mcp 1.0.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.
@@ -0,0 +1,258 @@
1
+ import { plainText, headingLevels } from './richtext.js';
2
+ import { auditSchemes } from './color.js';
3
+ import { normalizeText, templateTypeOf } from '../theme/catalog.js';
4
+ import { EXPECTED_MAIN } from './template.js';
5
+
6
+ // Design review: what makes a store look amateur even when every file is valid.
7
+ // Levels: important (fix before launch), suggestion (worth doing), info (to know).
8
+
9
+ const HERO_TYPES = new Set(['banner', 'slideshow', 'video', 'scroll-zoom', 'featured-product']);
10
+ const TEXT_SETTING_TYPES = new Set(['text', 'textarea', 'richtext', 'inline_richtext']);
11
+ const PLACEHOLDER_RE = /lorem ipsum|\[(?:à|a) personnaliser|\btodo\b|\bxxx\b|votre (?:titre|texte) ici|your (?:title|text) here|tell your customers|describe what|add a personal message|parlez de votre|décrivez ici/i;
12
+ // Sample texts that speak to the merchant ("Présentez…", "Tell your customers…").
13
+ const INSTRUCTION_RE = /^(présentez|presentez|dites|racontez|décrivez|decrivez|ajoutez|parlez|invitez|partagez|expliquez|utilisez|indiquez|montrez|mettez|tell|describe|share|add|use|explain|introduce|show|give|invite)\b/i;
14
+ // Blocks whose sample content states facts about the business (reviews, figures, policies).
15
+ const CLAIM_BLOCKS = {
16
+ testimonial: "témoignage d'exemple : remplacez-le par un vrai avis client, ou supprimez-le",
17
+ counter: "chiffre d'exemple : uniquement un chiffre réel et vérifiable, sinon supprimez-le",
18
+ accordion: "réponse d'exemple : elle doit décrire vos vraies conditions (délais, retours, paiement)",
19
+ checklist: "promesses d'exemple (livraison, retours…) : elles doivent correspondre à vos vraies conditions",
20
+ _announcement: "annonce d'exemple : elle doit correspondre à une vraie offre ou condition",
21
+ 'marquee-text': "messages d'exemple : ils doivent correspondre à vos vraies conditions",
22
+ };
23
+ const EN_WORDS = new Set(['the', 'and', 'your', 'you', 'with', 'our', 'for', 'shop', 'now', 'free', 'are', 'this', 'that', 'from', 'more', 'what', 'how', 'we', 'all', 'new', 'discover', 'learn', 'about', 'customers', 'products', 'best', 'is', 'it', 'of', 'to', 'in']);
24
+ const FR_WORDS = new Set(['le', 'la', 'les', 'des', 'et', 'vos', 'votre', 'nous', 'pour', 'avec', 'une', 'un', 'du', 'est', 'sur', 'dans', 'nos', 'plus', 'qui', 'que', 'decouvrir', 'livraison', 'offerte', 'produits', 'boutique', 'au', 'aux', 'en', 'de']);
25
+
26
+ export function looksEnglish(text) {
27
+ const words = normalizeText(text).split(' ').filter(Boolean);
28
+ if (words.length < 3) return false;
29
+ const en = words.filter((word) => EN_WORDS.has(word)).length;
30
+ const fr = words.filter((word) => FR_WORDS.has(word)).length;
31
+ return en >= 2 && en > fr * 2;
32
+ }
33
+
34
+ // Texts shipped as samples: every translated preset string and every shipped template text.
35
+ export function sampleTexts(catalog) {
36
+ if (catalog._samples) return catalog._samples;
37
+ const samples = new Set();
38
+ const addValue = (value) => {
39
+ if (typeof value !== 'string') return;
40
+ const text = normalizeText(plainText(value));
41
+ if (text.length >= 3) samples.add(text);
42
+ };
43
+ const walkPreset = (node, languages) => {
44
+ for (const value of Object.values(node?.settings || {})) {
45
+ if (typeof value === 'string' && value.startsWith('t:')) languages.forEach((language) => addValue(catalog.resolveKey(value.slice(2), language)));
46
+ else addValue(value);
47
+ }
48
+ const blocks = Array.isArray(node?.blocks) ? node.blocks : Object.values(node?.blocks || {});
49
+ blocks.forEach((block) => walkPreset(block, languages));
50
+ };
51
+ const languages = ['en', 'fr'];
52
+ for (const map of [catalog.theme.sections, catalog.theme.blocks]) {
53
+ for (const entry of map.values()) for (const preset of entry.schema?.presets || []) walkPreset(preset, languages);
54
+ }
55
+ const walkTemplate = (node) => {
56
+ for (const value of Object.values(node?.settings || {})) addValue(value);
57
+ for (const block of Object.values(node?.blocks || {})) walkTemplate(block);
58
+ };
59
+ for (const data of [...catalog.theme.templates.values(), ...catalog.theme.groups.values()]) {
60
+ for (const section of Object.values(data?.sections || {})) walkTemplate(section);
61
+ }
62
+ catalog._samples = samples;
63
+ return samples;
64
+ }
65
+
66
+ // The colour scheme painting a section's background: frame_palette on hero-frame sections
67
+ // (banner, slideshow, scroll sections), else color_palette, else color_scheme.
68
+ export function sectionPalette(catalog, section) {
69
+ const settings = catalog.section(section.type)?.schema?.settings || [];
70
+ for (const id of ['frame_palette', 'color_palette', 'color_scheme']) {
71
+ const setting = settings.find((item) => item.id === id && item.type === 'color_scheme');
72
+ if (setting) return section.settings?.[id] ?? setting.default ?? null;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function* walkBlocks(node, path) {
78
+ for (const [id, block] of Object.entries(node?.blocks || {})) {
79
+ const blockPath = `${path}/${id}`;
80
+ yield { id, block, path: blockPath };
81
+ yield* walkBlocks(block, blockPath);
82
+ }
83
+ }
84
+
85
+ function settingType(catalog, kind, type, key) {
86
+ const entry = kind === 'section' ? catalog.section(type) : catalog.block(type);
87
+ return (entry?.schema?.settings || []).find((setting) => setting.id === key)?.type;
88
+ }
89
+
90
+ export function lintTemplate(catalog, file, data, { language = 'fr' } = {}) {
91
+ const findings = [];
92
+ const add = (level, path, message) => findings.push({ level, file, path, message });
93
+ if (!data?.sections) return findings;
94
+ const templateType = file.startsWith('templates/') ? templateTypeOf(file) : null;
95
+ const order = (data.order || []).filter((id) => data.sections[id] && !data.sections[id].disabled);
96
+ const samples = sampleTexts(catalog);
97
+ let h1Count = 0;
98
+ let faqSchemas = 0;
99
+ let popups = 0;
100
+ let autoplayMedia = 0;
101
+
102
+ const checkTexts = (kind, type, settings, path) => {
103
+ for (const [key, value] of Object.entries(settings || {})) {
104
+ if (typeof value !== 'string' || !value) continue;
105
+ const settingKind = settingType(catalog, kind, type, key);
106
+ if (!TEXT_SETTING_TYPES.has(settingKind)) continue;
107
+ const text = plainText(value);
108
+ if (!text) continue;
109
+ h1Count += headingLevels(value).filter((level) => level === 1).length;
110
+ const english = language !== 'en' && looksEnglish(text);
111
+ const quoted = `« ${text.length > 60 ? text.slice(0, 57) + '…' : text} »`;
112
+ if (/\*\*[^*\s][^*]*\*\*/.test(text)) {
113
+ add('important', `${path}.${key}`, `gras écrit en markdown, affiché tel quel avec ses étoiles : ${quoted}. Écrivez <strong>mot</strong> (ou passez par compose_page, qui convertit **mot**)`);
114
+ }
115
+ if (PLACEHOLDER_RE.test(text) || INSTRUCTION_RE.test(text)) {
116
+ add('important', `${path}.${key}`, `texte provisoire (consigne du thème) à remplacer : ${quoted}`);
117
+ } else if (samples.has(normalizeText(text))) {
118
+ // Short labels ("Nos collections", "Entretien") can stay; claims and copy cannot.
119
+ const words = text.split(/\s+/).length;
120
+ // Years and plain numbers (timeline labels) are the merchant's; sample figures are not.
121
+ const label = (words <= 3 && !/\d/.test(text)) || (/^[\d\s.,/-]+$/.test(text) && type !== 'counter');
122
+ if (english) add('important', `${path}.${key}`, `texte d'exemple du thème en anglais : ${quoted}`);
123
+ else if (label) continue;
124
+ else if (CLAIM_BLOCKS[type]) add('important', `${path}.${key}`, `${CLAIM_BLOCKS[type]} : ${quoted}`);
125
+ else if (words > 4 || /\d/.test(text)) add('important', `${path}.${key}`, `texte d'exemple du thème, à écrire pour la marque : ${quoted}`);
126
+ } else if (english) add('important', `${path}.${key}`, `texte en anglais sur une boutique en ${language} : ${quoted}`);
127
+ }
128
+ };
129
+
130
+ for (const [position, id] of order.entries()) {
131
+ const section = data.sections[id];
132
+ const settings = section.settings || {};
133
+ checkTexts('section', section.type, settings, id);
134
+ if (settings.structured_data) faqSchemas += 1;
135
+ if (section.type === 'popup') popups += 1;
136
+ if ((section.type === 'banner' && settings.media_type === 'video') || section.type === 'video') autoplayMedia += 1;
137
+ if (settings.hide_desktop && settings.hide_mobile) add('info', id, 'section masquée sur ordinateur et sur mobile');
138
+ if (section.type === 'custom-liquid') add('suggestion', id, 'code Liquid personnalisé : le marchand ne pourra pas le modifier sans développeur');
139
+ if (position === 0 && templateType === 'index' && !HERO_TYPES.has(section.type)) {
140
+ add('suggestion', id, `la page d'accueil commence par « ${section.type} » : un héro (banner, slideshow, video) pose la promesse de la marque dès l'arrivée`);
141
+ }
142
+ if (section.type === 'banner' && !settings.image && !settings.video && !settings.video_url && settings.media_type !== 'none') {
143
+ add('important', id, "héro sans photo : il s'affiche comme un aplat vide. Donnez une photo (build_store images.hero, ou image: shopify://shop_images/…) ou passez en héro typographique (media_type none, color_palette = frame_palette)");
144
+ }
145
+ if (section.type === 'banner' && settings.media_type === 'none' && (settings.color_palette ?? 'inverse') !== (settings.frame_palette ?? 'background-1')) {
146
+ add('important', id, 'héro sans photo : le texte suit la palette prévue pour être posé sur une image et devient illisible sur le fond. Mettez color_palette égal à frame_palette');
147
+ }
148
+
149
+ for (const { block, path } of walkBlocks(section, id)) {
150
+ const blockSettings = block.settings || {};
151
+ if (block.disabled) continue;
152
+ checkTexts('block', block.type, blockSettings, path);
153
+ if (block.type === 'button' && blockSettings.button_text && plainText(blockSettings.button_text)) {
154
+ const label = plainText(blockSettings.button_text);
155
+ if (!blockSettings.button_link) add('important', path, `bouton « ${label} » sans lien`);
156
+ if (label.length > 28) add('suggestion', path, `libellé de bouton long (${label.length} caractères) : visez 2 à 4 mots`);
157
+ }
158
+ if (block.type === 'heading' && HERO_TYPES.has(section.type)) {
159
+ const title = plainText(blockSettings.title);
160
+ if (title.length > 70) add('suggestion', path, `titre de héro long (${title.length} caractères) : une promesse courte se lit en une seconde`);
161
+ }
162
+ if (block.type === 'countdown' && blockSettings.renew && blockSettings.renew !== '0') {
163
+ add('important', path, 'compte à rebours qui redémarre tout seul : urgence artificielle, pratique commerciale trompeuse (et contraire au positionnement premium). Visez une vraie date de fin.');
164
+ }
165
+ if (block.type === 'product-stock' && blockSettings.source === 'simulated') {
166
+ add('important', path, 'stock simulé : afficher un faux stock est une pratique commerciale trompeuse. Utilisez le stock Shopify.');
167
+ }
168
+ if (block.type === 'image' && !blockSettings.image) add('important', path, "image vide : un carré gris « image manquante » s'affiche. Placez une photo ou retirez le bloc");
169
+ if (block.type === 'rating' || (block.type === 'product-rating' && blockSettings.source === 'manual')) {
170
+ add('suggestion', path, 'note saisie à la main : elle doit refléter vos avis réels (sinon supprimez le bloc)');
171
+ }
172
+ }
173
+ }
174
+
175
+ // Page-level rules
176
+ if (templateType === 'index') {
177
+ if (order.length > 14) add('suggestion', '', `${order.length} sections sur l'accueil : au-delà d'une douzaine, le visiteur décroche. Gardez ce qui sert la vente.`);
178
+ if (order.length > 0 && order.length < 7) add('important', '', `accueil trop court (${order.length} sections) : visez 8 à 10. Remplacez chaque section retirée par une section honnête (bénéfices, FAQ, liste de produits, bandeau défilant, collections)`);
179
+ const types = order.map((id) => data.sections[id].type);
180
+ if (!types.some((type) => ['product-list', 'featured-product', 'collection-list', 'bundle-builder'].includes(type))) {
181
+ add('important', '', "aucune section produit sur l'accueil : montrez au moins vos meilleures ventes (product-list)");
182
+ }
183
+ if (!types.some((type) => ['testimonials', 'reviews', 'ugc', 'logo-list', 'stats'].includes(type))) {
184
+ add('suggestion', '', "pas de preuve sociale sur l'accueil (avis, témoignages, logos presse, photos clients)");
185
+ }
186
+ }
187
+ for (let index = 2; index < order.length; index++) {
188
+ const [a, b, c] = [order[index - 2], order[index - 1], order[index]].map((id) => data.sections[id].type);
189
+ if (a === b && b === c) add('suggestion', order[index], `trois sections « ${a} » d'affilée : variez les formats pour garder le rythme`);
190
+ }
191
+ // Hero sections are covered by their media: their own palette does not show as a background.
192
+ const palettes = order
193
+ .filter((id) => !HERO_TYPES.has(data.sections[id].type))
194
+ .map((id) => ({ id, palette: sectionPalette(catalog, data.sections[id]) }))
195
+ .filter((entry) => entry.palette);
196
+ if (templateType === 'index' && palettes.length >= 6 && new Set(palettes.map((entry) => entry.palette)).size <= 1) {
197
+ add('suggestion', '', 'toutes les sections sur la même palette : alternez avec background-2 ou inverse pour rythmer la page');
198
+ }
199
+ for (let index = 1; index < palettes.length; index++) {
200
+ const [previous, current] = [palettes[index - 1], palettes[index]];
201
+ if (current.palette === previous.palette && current.palette !== 'background-1' && order.indexOf(current.id) === order.indexOf(previous.id) + 1) {
202
+ add('suggestion', current.id, `même palette « ${current.palette} » que la section précédente : les deux fonds se confondent (passez l'une en background-1)`);
203
+ }
204
+ }
205
+ if (h1Count > 1) add('important', '', `${h1Count} titres <h1> sur la page : gardez-en un seul (SEO et accessibilité)`);
206
+ if (templateType === 'index' && h1Count === 0) add('suggestion', '', "aucun <h1> sur l'accueil : le titre du héro devrait l'être (<h1>…</h1> dans le bloc heading)");
207
+ if (faqSchemas > 1) add('important', '', 'plusieurs FAQ avec données structurées sur la même page : une seule FAQPage par page');
208
+ if (popups > 1) add('important', '', `${popups} pop-ups sur la même page : une seule, sinon le visiteur part`);
209
+ if (autoplayMedia > 2) add('suggestion', '', `${autoplayMedia} sections vidéo : chaque vidéo pèse sur le chargement, gardez les plus utiles`);
210
+
211
+ if (templateType === 'product') {
212
+ const main = order.map((id) => data.sections[id]).find((section) => section.type === EXPECTED_MAIN.product);
213
+ if (main) {
214
+ const types = new Set([...walkBlocks(main, 'main')].filter(({ block }) => !block.disabled).map(({ block }) => block.type));
215
+ for (const [type, label] of [['product-title', 'titre'], ['product-price', 'prix'], ['product-buy', "bouton d'achat"], ['product-media', 'galerie']]) {
216
+ if (!types.has(type)) add('important', 'main', `fiche produit sans ${label} (${type})`);
217
+ }
218
+ if (!['checklist', 'trust-badges', 'accordion', 'product-delivery', 'product-spin-message'].some((type) => types.has(type))) {
219
+ add('suggestion', 'main', 'aucune réassurance près du bouton (livraison, retours, paiement sécurisé) : ajoutez checklist, trust-badges ou product-delivery');
220
+ }
221
+ if (types.has('product-offers') || types.has('product-addons') || types.has('bundle-builder')) {
222
+ add('info', 'main', "offres présentes : le thème affiche les remises, mais c'est Shopify qui les facture. Créez la réduction automatique (ou le code) correspondant à chaque offre.");
223
+ }
224
+ if (types.has('product-rating') || types.has('review-summary')) {
225
+ add('info', 'main', "les notes produit lisent les métachamps d'avis (reviews.rating) : une application d'avis doit les remplir");
226
+ }
227
+ }
228
+ if (!order.some((id) => ['testimonials', 'reviews', 'ugc'].includes(data.sections[id].type))) {
229
+ add('suggestion', '', "pas d'avis clients sur la fiche produit : c'est le premier levier de conversion");
230
+ }
231
+ }
232
+ return findings;
233
+ }
234
+
235
+ export function lintSettings(catalog, settingsData) {
236
+ const findings = [];
237
+ const add = (level, path, message) => findings.push({ level, file: 'config/settings_data.json', path, message });
238
+ const current = typeof settingsData?.current === 'string' ? settingsData.presets?.[settingsData.current] : settingsData?.current;
239
+ if (!current) return findings;
240
+ const buttons = { 1: current.button_1_palette || 'accent-1', 2: current.button_2_palette || 'text-outline', 3: current.button_3_palette || 'text' };
241
+ for (const finding of auditSchemes(current.color_schemes || {}, buttons)) {
242
+ if (finding.level === 'ok') continue;
243
+ add(finding.level === 'error' ? 'important' : 'suggestion', `color_schemes.${finding.scheme}`, `contraste insuffisant — ${finding.message}`);
244
+ }
245
+ // Unknown and deprecated fonts are reported by the validator.
246
+ if (current.headings_font && current.headings_font === current.body_font) {
247
+ add('suggestion', 'headings_font', 'même police et même graisse pour les titres et le texte : donnez du contraste aux titres (graisse 5 à 7, ou une autre famille)');
248
+ }
249
+ const socials = Object.keys(current).filter((key) => /^social_.*_link$/.test(key) && current[key]);
250
+ const sampleSocial = socials.filter((key) => /shopify|example\.com/i.test(current[key]));
251
+ if (sampleSocial.length) add('important', 'social', `liens réseaux sociaux d'exemple : ${sampleSocial.join(', ')}`);
252
+ if (!socials.length) add('info', 'social', 'aucun réseau social renseigné (réglages du thème > Réseaux sociaux)');
253
+ if (!current.favicon) add('info', 'favicon', 'favicon à fournir (image carrée 32 × 32 px ou plus)');
254
+ if (current.disable_right_click || current.disable_selection_text) {
255
+ add('suggestion', 'security', "blocage du clic droit / de la sélection : gêne les clients (copier une référence, un code promo) sans protéger vraiment les images");
256
+ }
257
+ return findings;
258
+ }
@@ -0,0 +1,103 @@
1
+ const BLOCK_TAGS = new Set(['p', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
2
+ const RICHTEXT_TAGS = new Set([...BLOCK_TAGS, 'li', 'br', 'strong', 'b', 'em', 'i', 'u', 'span', 'a', 's', 'sub', 'sup']);
3
+ const INLINE_TAGS = new Set(['strong', 'b', 'em', 'i', 'u', 'span', 'a', 's', 'sub', 'sup']);
4
+ const VOID_TAGS = new Set(['br']);
5
+ const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*?(\/?)>/g;
6
+ const LIQUID_RE = /\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}/g;
7
+
8
+ function scan(html) {
9
+ const tokens = [];
10
+ let last = 0;
11
+ for (const match of html.matchAll(TAG_RE)) {
12
+ if (match.index > last) tokens.push({ text: html.slice(last, match.index) });
13
+ tokens.push({ tag: match[1].toLowerCase(), closing: match[0].startsWith('</'), selfClosing: match[2] === '/', raw: match[0] });
14
+ last = match.index + match[0].length;
15
+ }
16
+ if (last < html.length) tokens.push({ text: html.slice(last) });
17
+ return tokens;
18
+ }
19
+
20
+ // Checks a `richtext` value: only Shopify's rich text tags, every top-level node a block tag.
21
+ export function checkRichtext(value) {
22
+ const problems = [];
23
+ if (value === '' || value == null) return problems;
24
+ const html = String(value).replace(LIQUID_RE, 'x');
25
+ const stack = [];
26
+ for (const token of scan(html)) {
27
+ if (token.text !== undefined) {
28
+ if (!stack.length && token.text.trim()) {
29
+ problems.push({ level: 'error', message: `du texte hors balise en premier niveau (« ${token.text.trim().slice(0, 30)} ») : chaque paragraphe doit être dans <p>, <ul>, <ol> ou <h1>-<h6>` });
30
+ }
31
+ continue;
32
+ }
33
+ if (!RICHTEXT_TAGS.has(token.tag)) {
34
+ problems.push({ level: 'error', message: `balise <${token.tag}> refusée dans un texte riche (autorisées : p, h1-h6, ul, ol, li, br, strong, em, u, a, span)` });
35
+ continue;
36
+ }
37
+ if (VOID_TAGS.has(token.tag) || token.selfClosing) continue;
38
+ if (!token.closing) {
39
+ if (!stack.length && !BLOCK_TAGS.has(token.tag)) {
40
+ problems.push({ level: 'error', message: `<${token.tag}> en premier niveau : entourez-le d'un <p>` });
41
+ }
42
+ stack.push(token.tag);
43
+ } else {
44
+ const index = stack.lastIndexOf(token.tag);
45
+ if (index === -1) problems.push({ level: 'error', message: `</${token.tag}> fermé sans avoir été ouvert` });
46
+ else {
47
+ if (index !== stack.length - 1) problems.push({ level: 'error', message: `balises mal imbriquées autour de </${token.tag}>` });
48
+ stack.length = index;
49
+ }
50
+ }
51
+ }
52
+ if (stack.length) problems.push({ level: 'error', message: `balise(s) non fermée(s) : ${stack.map((tag) => `<${tag}>`).join(', ')}` });
53
+ return dedupe(problems);
54
+ }
55
+
56
+ // Checks an `inline_richtext` value: bold, italic, links; no paragraphs.
57
+ export function checkInlineRichtext(value) {
58
+ const problems = [];
59
+ if (value === '' || value == null) return problems;
60
+ const html = String(value).replace(LIQUID_RE, 'x');
61
+ const stack = [];
62
+ for (const token of scan(html)) {
63
+ if (token.text !== undefined) continue;
64
+ if (token.tag === 'br') {
65
+ problems.push({ level: 'warning', message: 'les retours à la ligne (<br>) ne sont pas pris en charge dans un texte riche en ligne' });
66
+ continue;
67
+ }
68
+ if (!INLINE_TAGS.has(token.tag)) {
69
+ problems.push({ level: 'error', message: `balise <${token.tag}> refusée dans un texte riche en ligne (autorisées : strong, em, u, a, span)` });
70
+ continue;
71
+ }
72
+ if (token.selfClosing) continue;
73
+ if (!token.closing) stack.push(token.tag);
74
+ else {
75
+ const index = stack.lastIndexOf(token.tag);
76
+ if (index === -1) problems.push({ level: 'error', message: `</${token.tag}> fermé sans avoir été ouvert` });
77
+ else stack.length = index;
78
+ }
79
+ }
80
+ if (stack.length) problems.push({ level: 'error', message: `balise(s) non fermée(s) : ${stack.map((tag) => `<${tag}>`).join(', ')}` });
81
+ return dedupe(problems);
82
+ }
83
+
84
+ export function plainText(value) {
85
+ return String(value ?? '')
86
+ .replace(/<br\s*\/?>/gi, ' ')
87
+ .replace(/<\/(p|h[1-6]|li)>/gi, ' ')
88
+ .replace(/<[^>]+>/g, '')
89
+ .replace(/&nbsp;/g, ' ')
90
+ .replace(/&amp;/g, '&')
91
+ .replace(/&#39;|&rsquo;/g, "'")
92
+ .replace(/\s+/g, ' ')
93
+ .trim();
94
+ }
95
+
96
+ export function headingLevels(value) {
97
+ return [...String(value ?? '').matchAll(/<h([1-6])\b/gi)].map((match) => Number(match[1]));
98
+ }
99
+
100
+ function dedupe(problems) {
101
+ const seen = new Set();
102
+ return problems.filter((problem) => (seen.has(problem.message) ? false : seen.add(problem.message)));
103
+ }