@usequeek/theme-check 0.1.0 → 0.2.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/context.js +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/rules/static.d.ts +16 -0
- package/dist/rules/static.js +266 -7
- package/dist/types.d.ts +2 -0
- package/dist/utils/business-vocabulary.d.ts +3 -1
- package/dist/utils/business-vocabulary.js +7 -1
- package/dist/utils/business-vocabulary.json +6 -2
- package/dist/utils/template-copy.d.ts +41 -0
- package/dist/utils/template-copy.js +190 -0
- package/dist/utils/theme-templates.js +7 -2
- package/package.json +1 -1
package/dist/context.js
CHANGED
|
@@ -71,6 +71,7 @@ export async function loadContext(themeDir, env = {}) {
|
|
|
71
71
|
declaredDemos: config === null ? null : Array.isArray(declared) ? declared : [],
|
|
72
72
|
defaultDescription: typeof primary?.description === 'string' ? primary.description : null,
|
|
73
73
|
defaultFor: primary?.for ?? null,
|
|
74
|
+
themeDescription: typeof config?.description === 'string' ? config.description : null,
|
|
74
75
|
manifest,
|
|
75
76
|
pageBased: PAGE_BLOCK_SCOPES.some((scope) => (variants[scope] ?? []).length > 0),
|
|
76
77
|
file,
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { checkTheme, rejects, RULES, AT_SUBMISSION, type CheckOptions, type Chec
|
|
|
7
7
|
export { loadContext, localEnv, CONTRACT_URL } from './context.js';
|
|
8
8
|
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf, type Level, type Summary } from './format.js';
|
|
9
9
|
export type { CheckEnv, Finding, Rule, Severity, ThemeContext, DemoStore, DeclaredDemo } from './types.js';
|
|
10
|
-
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
10
|
+
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
11
|
+
export { TEMPLATE_COPY_PLACES, copyViolations, isTestimonialSection, storeNameForms } from './utils/template-copy.js';
|
|
11
12
|
export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
|
|
12
13
|
export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, declaredFieldsByVariant } from './utils/theme-templates.js';
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
export { checkTheme, rejects, RULES, AT_SUBMISSION } from './run.js';
|
|
7
7
|
export { loadContext, localEnv, CONTRACT_URL } from './context.js';
|
|
8
8
|
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf } from './format.js';
|
|
9
|
-
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
9
|
+
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
10
|
+
export { TEMPLATE_COPY_PLACES, copyViolations, isTestimonialSection, storeNameForms } from './utils/template-copy.js';
|
|
10
11
|
export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
|
|
11
12
|
export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, declaredFieldsByVariant } from './utils/theme-templates.js';
|
package/dist/rules/static.d.ts
CHANGED
|
@@ -38,4 +38,20 @@ export declare const templateVersionsRule: Rule;
|
|
|
38
38
|
/** The designed pages every template ships (contract R2.3); versions `-2`… count. */
|
|
39
39
|
export declare const TEMPLATE_PAGES: readonly ["about", "sales", "landing"];
|
|
40
40
|
export declare const templatePagesRule: Rule;
|
|
41
|
+
export declare const templateCopyRule: Rule;
|
|
42
|
+
/**
|
|
43
|
+
* Where a vendor's own fact is shown with words of the theme's for when the
|
|
44
|
+
* vendor has none — `vendor.address ?? 'Lagos, Nigeria'`, `tagline || 'Quiet
|
|
45
|
+
* luxury…'`, `brandTagline ? … : <p>Refined wardrobe…</p>` (through a variable
|
|
46
|
+
* assigned from the fact). A store without one would show those words as its own.
|
|
47
|
+
*/
|
|
48
|
+
export declare function vendorFactFallbacks(source: string): Array<{
|
|
49
|
+
fact: string;
|
|
50
|
+
words: string;
|
|
51
|
+
}>;
|
|
52
|
+
export declare const vendorFactsRule: Rule;
|
|
53
|
+
export declare const fontsSelfHostedRule: Rule;
|
|
54
|
+
/** The starter's own demo photos, rehosted under themes/_bare — never a real theme's. */
|
|
55
|
+
export declare const STARTER_PLACEHOLDER_IMAGES: readonly string[];
|
|
56
|
+
export declare const placeholderContentRule: Rule;
|
|
41
57
|
export declare const STATIC_RULES: Rule[];
|
package/dist/rules/static.js
CHANGED
|
@@ -2,8 +2,9 @@ import { join, relative } from 'node:path';
|
|
|
2
2
|
import { readdirSync, existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { foreignImageRefs } from '../utils/theme-demo-images.js';
|
|
4
4
|
import { DEMO_ID_FORMAT, PRIMARY_DEMO_ID } from '../utils/theme-demos.js';
|
|
5
|
-
import { SCREENSHOT_LOCK, TEMPLATE_DESCRIPTION_MAX, TEMPLATE_DESCRIPTION_PLACEHOLDER, isPresentationalField, screenshotFile, screenshotUrl } from '../utils/theme-templates.js';
|
|
6
|
-
import {
|
|
5
|
+
import { SCREENSHOT_LOCK, TEMPLATE_DESCRIPTION_MAX, TEMPLATE_DESCRIPTION_PLACEHOLDER, declaredFieldsByVariant, isPresentationalField, screenshotFile, screenshotUrl, sectionCopy } from '../utils/theme-templates.js';
|
|
6
|
+
import { copyViolations, isTestimonialSection } from '../utils/template-copy.js';
|
|
7
|
+
import { BUSINESS_KEYS, SERVICE_SLUGS, isBusinessKey } from '../utils/business-vocabulary.js';
|
|
7
8
|
import { themeSourceFiles } from '../context.js';
|
|
8
9
|
import { finding } from '../types.js';
|
|
9
10
|
const REQUIRED_FILES = [
|
|
@@ -151,7 +152,7 @@ export const demoStoresRule = {
|
|
|
151
152
|
add(at, `demos[] "${id}" has no label`, 'Give it the name a merchant sees, e.g. "Restaurant & takeaway".');
|
|
152
153
|
}
|
|
153
154
|
if (!Array.isArray(demo.for) || demo.for.length === 0 || demo.for.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
154
|
-
add(at, `demos[] "${id}" has no \`for\` business list`, `List the businesses this store is for
|
|
155
|
+
add(at, `demos[] "${id}" has no \`for\` business list`, `List the businesses this store is for — its business category first (or, for a niche, only catalogue keys) — from ${context.env.vocabulary} (service slugs like "foods", catalogue keys like "wigs-extensions-hair-accessories"). The backend offers it to matching vendors.`);
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
for (const store of secondaries) {
|
|
@@ -831,12 +832,26 @@ export const templateBusinessRule = {
|
|
|
831
832
|
continue;
|
|
832
833
|
}
|
|
833
834
|
const unknown = keys.filter((key) => typeof key !== 'string' || !isBusinessKey(key));
|
|
834
|
-
if (unknown.length === 0)
|
|
835
|
+
if (unknown.length === 0) {
|
|
836
|
+
// R2.7: the backend matches the business category a merchant picked at setup
|
|
837
|
+
// first. A general template leads with its category; a niche one (hair, shoes,
|
|
838
|
+
// jewellery) names none, or it competes as a general template.
|
|
839
|
+
const named = keys.filter((key) => SERVICE_SLUGS.includes(key));
|
|
840
|
+
if (named.length > 0 && !SERVICE_SLUGS.includes(keys[0])) {
|
|
841
|
+
const general = [...named, ...keys.filter((key) => !named.includes(key))];
|
|
842
|
+
findings.push(finding(context, 'theme/template-business', 'reject', {
|
|
843
|
+
where,
|
|
844
|
+
found: `template "${id}" names the business category ${named.map((key) => JSON.stringify(key)).join(', ')} but leads with ${JSON.stringify(keys[0])}`,
|
|
845
|
+
fix: `A template for a whole business leads with its category: ${JSON.stringify(general)}. A template for one kind of product (hair, shoes, jewellery, coffee) names only its catalogue keys — drop ${named.map((key) => JSON.stringify(key)).join(', ')}.`,
|
|
846
|
+
docs: `${context.env.docs}#templates`,
|
|
847
|
+
}));
|
|
848
|
+
}
|
|
835
849
|
continue;
|
|
850
|
+
}
|
|
836
851
|
findings.push(finding(context, 'theme/template-business', 'reject', {
|
|
837
852
|
where,
|
|
838
853
|
found: `template "${id}" is for ${unknown.map((key) => JSON.stringify(key)).join(', ')}, not in the business vocabulary`,
|
|
839
|
-
fix: `Use service slugs or catalogue keys from ${context.env.vocabulary} (${BUSINESS_KEYS.size} keys),
|
|
854
|
+
fix: `Use service slugs or catalogue keys from ${context.env.vocabulary} (${BUSINESS_KEYS.size} keys): a whole business leads with its category, a niche names only catalogue keys. The backend matches vendors on these keys only; any other key matches no one.`,
|
|
840
855
|
docs: `${context.env.docs}#templates`,
|
|
841
856
|
}));
|
|
842
857
|
}
|
|
@@ -955,7 +970,251 @@ export const templatePagesRule = {
|
|
|
955
970
|
return findings;
|
|
956
971
|
},
|
|
957
972
|
};
|
|
958
|
-
|
|
973
|
+
/* ── Round 2.6: copy a real store can publish unchanged ───────────────── */
|
|
974
|
+
/** Every string in a section's copy, with where it sits (`items[2].text`). */
|
|
975
|
+
function copyStrings(value, path = '') {
|
|
976
|
+
if (typeof value === 'string')
|
|
977
|
+
return [[path, value]];
|
|
978
|
+
if (Array.isArray(value))
|
|
979
|
+
return value.flatMap((entry, i) => copyStrings(entry, `${path}[${i}]`));
|
|
980
|
+
if (value && typeof value === 'object')
|
|
981
|
+
return Object.entries(value).flatMap(([key, entry]) => copyStrings(entry, path ? `${path}.${key}` : key));
|
|
982
|
+
return [];
|
|
983
|
+
}
|
|
984
|
+
export const templateCopyRule = {
|
|
985
|
+
id: 'theme/template-copy',
|
|
986
|
+
summary: 'Template copy is true of any store in its business: no store name, place, naira amount, promise or founding date',
|
|
987
|
+
kind: 'static',
|
|
988
|
+
run(context) {
|
|
989
|
+
if (context.retired || !context.manifest)
|
|
990
|
+
return [];
|
|
991
|
+
const declared = declaredFieldsByVariant(context.manifest.variants);
|
|
992
|
+
const findings = [];
|
|
993
|
+
for (const store of context.demos) {
|
|
994
|
+
const name = store.data?.profile?.name;
|
|
995
|
+
for (const [pageKey, page] of Object.entries(pagesOf(store))) {
|
|
996
|
+
(page?.content ?? []).forEach((section, index) => {
|
|
997
|
+
if (typeof section?.type !== 'string')
|
|
998
|
+
return;
|
|
999
|
+
const variant = section.variant ?? 'default';
|
|
1000
|
+
if (isTestimonialSection(section.type, variant, declared[section.type]?.[variant]))
|
|
1001
|
+
return;
|
|
1002
|
+
const lines = copyStrings(sectionCopy(section, declared) ?? {}).flatMap(([path, text]) => {
|
|
1003
|
+
const why = copyViolations(text, typeof name === 'string' ? name : null);
|
|
1004
|
+
return why.length > 0 ? [`${path}: ${why.join('; ')} — "${text.length > 80 ? `${text.slice(0, 77)}…` : text}"`] : [];
|
|
1005
|
+
});
|
|
1006
|
+
if (lines.length === 0)
|
|
1007
|
+
return;
|
|
1008
|
+
findings.push(finding(context, 'theme/template-copy', 'reject', {
|
|
1009
|
+
where: `${context.env.root}${store.file} → pages.${pageKey}.content[${index}] (${section.type}.${variant})`,
|
|
1010
|
+
found: lines.join('\n'),
|
|
1011
|
+
fix: 'The setup wizard publishes this copy onto real stores unchanged. Write it for any store in the business: the store\'s name becomes a role ("our kitchen", "the studio"), a place becomes generic ("across the city") or goes, and prices, delivery windows, guarantees and founding dates go — they are the vendor’s to state. Testimonials and reviews are exempt.',
|
|
1012
|
+
docs: `${context.env.docs}#templates`,
|
|
1013
|
+
}));
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return findings;
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
/* ── BE8: a vendor's facts never fall back to the theme author's words ── */
|
|
1021
|
+
const VENDOR_FACTS = ['tagline', 'address', 'phone', 'email', 'description'];
|
|
1022
|
+
/** `vendor.tagline`, `store?.address`, `profile.phone`… */
|
|
1023
|
+
const FACT_REF = new RegExp(`\\b(?:vendor|store|profile)\\??\\.(${VENDOR_FACTS.join('|')})\\b`, 'g');
|
|
1024
|
+
/**
|
|
1025
|
+
* The rest of the expression that starts at `from`: up to the `}`, `)` or `]`
|
|
1026
|
+
* that closes it, or a `;`/`,` at its own depth — across lines, skipping
|
|
1027
|
+
* strings. So `{vendor.tagline ?? ''}…{label || 'Visit us'}` stops at the
|
|
1028
|
+
* first `}` and never reads the second expression as the first's fallback.
|
|
1029
|
+
*/
|
|
1030
|
+
function expressionAfter(source, from) {
|
|
1031
|
+
let depth = 0;
|
|
1032
|
+
let quote = null;
|
|
1033
|
+
const end = Math.min(source.length, from + 400);
|
|
1034
|
+
for (let i = from; i < end; i++) {
|
|
1035
|
+
const ch = source[i];
|
|
1036
|
+
if (quote) {
|
|
1037
|
+
if (ch === '\\')
|
|
1038
|
+
i++;
|
|
1039
|
+
else if (ch === quote)
|
|
1040
|
+
quote = null;
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (ch === '"' || ch === "'" || ch === '`')
|
|
1044
|
+
quote = ch;
|
|
1045
|
+
else if ('([{'.includes(ch))
|
|
1046
|
+
depth++;
|
|
1047
|
+
else if (')]}'.includes(ch)) {
|
|
1048
|
+
if (depth === 0)
|
|
1049
|
+
return source.slice(from, i);
|
|
1050
|
+
depth--;
|
|
1051
|
+
}
|
|
1052
|
+
else if ((ch === ';' || ch === ',') && depth === 0)
|
|
1053
|
+
return source.slice(from, i);
|
|
1054
|
+
}
|
|
1055
|
+
return source.slice(from, end);
|
|
1056
|
+
}
|
|
1057
|
+
const WORDS = String.raw `([^'"\x60\n]*[^'"\x60\s][^'"\x60\n]*)`;
|
|
1058
|
+
/** `?? 'words'`, `|| 'words'` */
|
|
1059
|
+
const OR_WORDS = new RegExp(String.raw `(?:\?\?|\|\|)\s*(['"\x60])${WORDS}\1`);
|
|
1060
|
+
/** A ternary's other branch: `: 'words'` or `: <p>Words` */
|
|
1061
|
+
const ELSE_WORDS = new RegExp(String.raw `:\s*(?:(['"\x60])${WORDS}\1|<[a-z][\w.-]*[^>]*>\s*([A-Za-z][^<{]{2,}))`);
|
|
1062
|
+
/** A ternary `?` — not `?.` or `??`. */
|
|
1063
|
+
const TERNARY = /(?:^|[^?.])\?(?![?.])/;
|
|
1064
|
+
/**
|
|
1065
|
+
* Where a vendor's own fact is shown with words of the theme's for when the
|
|
1066
|
+
* vendor has none — `vendor.address ?? 'Lagos, Nigeria'`, `tagline || 'Quiet
|
|
1067
|
+
* luxury…'`, `brandTagline ? … : <p>Refined wardrobe…</p>` (through a variable
|
|
1068
|
+
* assigned from the fact). A store without one would show those words as its own.
|
|
1069
|
+
*/
|
|
1070
|
+
export function vendorFactFallbacks(source) {
|
|
1071
|
+
const code = stripComments(source);
|
|
1072
|
+
const found = new Map();
|
|
1073
|
+
const check = (fact, rest) => {
|
|
1074
|
+
const or = OR_WORDS.exec(rest);
|
|
1075
|
+
const other = TERNARY.test(rest) ? ELSE_WORDS.exec(rest) : null;
|
|
1076
|
+
const hit = or ?? other;
|
|
1077
|
+
if (!hit)
|
|
1078
|
+
return;
|
|
1079
|
+
// `… ?? vendor.name ?? 'Our story'` never shows: every vendor has a name (backend, BE9).
|
|
1080
|
+
if (/\b(?:vendor|store|profile)\??\.name\b/.test(rest.slice(0, hit.index)))
|
|
1081
|
+
return;
|
|
1082
|
+
const words = (hit[2] ?? hit[3] ?? '').trim();
|
|
1083
|
+
if (words)
|
|
1084
|
+
found.set(`${fact}:${words}`, { fact, words });
|
|
1085
|
+
};
|
|
1086
|
+
for (const match of code.matchAll(FACT_REF))
|
|
1087
|
+
check(match[1], expressionAfter(code, match.index + match[0].length));
|
|
1088
|
+
// Facts held in a variable: `const t = stripMarkdown(vendor.tagline ?? '') || 'Quiet luxury';`
|
|
1089
|
+
// (the declaration itself), then every later `t ?? …` / `t ? … : …`.
|
|
1090
|
+
const locals = [];
|
|
1091
|
+
for (const match of code.matchAll(/\b(?:const|let)\s+(\w+)\s*=\s*([^;\n]*)/g)) {
|
|
1092
|
+
const fact = VENDOR_FACTS.find((name) => new RegExp(`\\b(?:vendor|store|profile)\\??\\.${name}\\b`).test(match[2]));
|
|
1093
|
+
if (!fact)
|
|
1094
|
+
continue;
|
|
1095
|
+
check(fact, match[2].slice(match[2].search(new RegExp(`\\.${fact}\\b`)) + fact.length + 1));
|
|
1096
|
+
locals.push({ name: match[1], fact });
|
|
1097
|
+
}
|
|
1098
|
+
// `const { tagline, address: where } = vendor;`
|
|
1099
|
+
for (const match of code.matchAll(/\b(?:const|let)\s*\{([^}]*)\}\s*=\s*(?:vendor|store|profile)\b/g)) {
|
|
1100
|
+
for (const part of match[1].split(',')) {
|
|
1101
|
+
const [key, alias] = part.split(':').map((text) => text.trim().split(/\s*=/)[0]);
|
|
1102
|
+
if (VENDOR_FACTS.includes(key))
|
|
1103
|
+
locals.push({ name: alias || key, fact: key });
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
for (const { name, fact } of locals) {
|
|
1107
|
+
for (const use of code.matchAll(new RegExp(`(?<![.\\w])${name}\\b(?!\\s*[=:](?!=))`, 'g')))
|
|
1108
|
+
check(fact, expressionAfter(code, use.index + use[0].length));
|
|
1109
|
+
}
|
|
1110
|
+
return [...found.values()];
|
|
1111
|
+
}
|
|
1112
|
+
export const vendorFactsRule = {
|
|
1113
|
+
id: 'theme/vendor-facts',
|
|
1114
|
+
summary: "A vendor's tagline, address, phone, email or description never falls back to the theme's own words",
|
|
1115
|
+
kind: 'static',
|
|
1116
|
+
run(context) {
|
|
1117
|
+
if (context.retired)
|
|
1118
|
+
return [];
|
|
1119
|
+
const findings = [];
|
|
1120
|
+
for (const path of themeSourceFiles(context.dir)) {
|
|
1121
|
+
for (const { fact, words } of vendorFactFallbacks(readFileSync(path, 'utf8'))) {
|
|
1122
|
+
findings.push(finding(context, 'theme/vendor-facts', 'reject', {
|
|
1123
|
+
where: relative(context.dir, path),
|
|
1124
|
+
found: `vendor.${fact} falls back to "${words}"`,
|
|
1125
|
+
fix: `A store with no ${fact} would show these words as its own. Render nothing when it is empty (\`vendor.${fact} ?? ''\`, or leave the element out).`,
|
|
1126
|
+
docs: `${context.env.docs}#component-rules`,
|
|
1127
|
+
}));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return findings;
|
|
1131
|
+
},
|
|
1132
|
+
};
|
|
1133
|
+
/* ── Fonts ship as files in the theme (storefront 0d66ed0) ─────────────── */
|
|
1134
|
+
function themeCssFiles(dir) {
|
|
1135
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
1136
|
+
const full = join(dir, entry.name);
|
|
1137
|
+
if (entry.isDirectory())
|
|
1138
|
+
return entry.name === 'node_modules' ? [] : themeCssFiles(full);
|
|
1139
|
+
return entry.name.endsWith('.css') ? [full] : [];
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
export const fontsSelfHostedRule = {
|
|
1143
|
+
id: 'theme/fonts-self-hosted',
|
|
1144
|
+
summary: 'Theme fonts ship as files in the theme: next/font/google is rejected (it fails builds), a Google Fonts @import is flagged',
|
|
1145
|
+
kind: 'static',
|
|
1146
|
+
run(context) {
|
|
1147
|
+
const findings = [];
|
|
1148
|
+
const fix = `Put the font's .woff2 files in ${context.env.root}fonts/ and declare them in fonts/fonts.css with @font-face (font-display: swap), imported from layout.tsx — or use next/font/local, which reads a file in the repo. next/font/google downloads fonts DURING THE BUILD, and Google intermittently serves URLs Turbopack cannot parse, failing the build ("next/font/google queries have exactly one entry", vercel/next.js#99114).`;
|
|
1149
|
+
for (const path of themeSourceFiles(context.dir)) {
|
|
1150
|
+
const source = stripComments(readFileSync(path, 'utf8'));
|
|
1151
|
+
if (/from\s+['"]next\/font\/google['"]/.test(source)) {
|
|
1152
|
+
findings.push(finding(context, 'theme/fonts-self-hosted', 'reject', {
|
|
1153
|
+
where: relative(context.dir, path),
|
|
1154
|
+
found: 'imports next/font/google',
|
|
1155
|
+
fix,
|
|
1156
|
+
docs: `${context.env.docs}#fonts`,
|
|
1157
|
+
}));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
for (const path of themeCssFiles(context.dir)) {
|
|
1161
|
+
const css = readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
1162
|
+
if (/@import\s+(?:url\()?['"]?https?:\/\/fonts\.googleapis\.com/.test(css)) {
|
|
1163
|
+
findings.push(finding(context, 'theme/fonts-self-hosted', 'warn', {
|
|
1164
|
+
where: relative(context.dir, path),
|
|
1165
|
+
found: 'loads a font with @import from fonts.googleapis.com',
|
|
1166
|
+
fix: `Either the bundler drops it (it only survives as the very first rule of the compiled stylesheet), so the font never loads, or it survives and every page load waits on an extra render-blocking request to Google before the text can paint. ${fix}`,
|
|
1167
|
+
docs: `${context.env.docs}#fonts`,
|
|
1168
|
+
}));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return findings;
|
|
1172
|
+
},
|
|
1173
|
+
};
|
|
1174
|
+
/** The starter's own demo photos, rehosted under themes/_bare — never a real theme's. */
|
|
1175
|
+
export const STARTER_PLACEHOLDER_IMAGES = [
|
|
1176
|
+
'https://media.usequeek.com/theme-assets/_bare/0e21e31030533d79.jpg',
|
|
1177
|
+
'https://media.usequeek.com/theme-assets/_bare/201b6247be904af4.jpg',
|
|
1178
|
+
'https://media.usequeek.com/theme-assets/_bare/377756cea202d263.jpg',
|
|
1179
|
+
'https://media.usequeek.com/theme-assets/_bare/57e4183c303aab31.jpg',
|
|
1180
|
+
'https://media.usequeek.com/theme-assets/_bare/67913acc7ef19523.jpg',
|
|
1181
|
+
'https://media.usequeek.com/theme-assets/_bare/cd48af92c911f8b6.jpg',
|
|
1182
|
+
];
|
|
1183
|
+
export const placeholderContentRule = {
|
|
1184
|
+
id: 'theme/placeholder-content',
|
|
1185
|
+
summary: "No demo store still carries the starter's placeholder products or photos, and the theme's description is its own",
|
|
1186
|
+
kind: 'static',
|
|
1187
|
+
run(context) {
|
|
1188
|
+
if (context.retired)
|
|
1189
|
+
return [];
|
|
1190
|
+
const findings = [];
|
|
1191
|
+
for (const store of context.demos) {
|
|
1192
|
+
const products = (Array.isArray(store.data?.products) ? store.data.products : []);
|
|
1193
|
+
const placeholders = products.filter((product) => typeof product.slug === 'string' && product.slug.startsWith('placeholder-')).length;
|
|
1194
|
+
const text = JSON.stringify(store.data ?? {});
|
|
1195
|
+
const photos = STARTER_PLACEHOLDER_IMAGES.filter((url) => text.includes(url)).length;
|
|
1196
|
+
if (placeholders === 0 && photos === 0)
|
|
1197
|
+
continue;
|
|
1198
|
+
findings.push(finding(context, 'theme/placeholder-content', 'reject', {
|
|
1199
|
+
where: `${context.env.root}${store.file}`,
|
|
1200
|
+
found: [placeholders ? `${placeholders} placeholder product(s)` : '', photos ? `${photos} of the starter's photos` : ''].filter(Boolean).join(' and '),
|
|
1201
|
+
fix: "Replace the starter's products and photos with your own for this business. Every theme's preview must look like itself; shared placeholder photos would make every theme's store look the same.",
|
|
1202
|
+
docs: `${context.env.docs}#placeholder-content`,
|
|
1203
|
+
}));
|
|
1204
|
+
}
|
|
1205
|
+
// The starter writes a placeholder theme description too; no template rule reads it.
|
|
1206
|
+
if ((context.themeDescription?.trim() ?? '').startsWith(TEMPLATE_DESCRIPTION_PLACEHOLDER)) {
|
|
1207
|
+
findings.push(finding(context, 'theme/placeholder-content', 'reject', {
|
|
1208
|
+
where: `${context.env.root}theme.config.ts → description`,
|
|
1209
|
+
found: "the theme's description is still the starter's placeholder",
|
|
1210
|
+
fix: 'Write what this theme is, in one sentence. It is the first thing a merchant, and the AI choosing on their behalf, reads about it.',
|
|
1211
|
+
docs: `${context.env.docs}#placeholder-content`,
|
|
1212
|
+
}));
|
|
1213
|
+
}
|
|
1214
|
+
return findings;
|
|
1215
|
+
},
|
|
1216
|
+
};
|
|
1217
|
+
export const STATIC_RULES = [moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule,
|
|
959
1218
|
templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule,
|
|
960
|
-
templateBusinessRule, templateVersionsRule, templatePagesRule,
|
|
1219
|
+
templateBusinessRule, templateVersionsRule, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule,
|
|
961
1220
|
];
|
package/dist/types.d.ts
CHANGED
|
@@ -112,6 +112,8 @@ export interface ThemeContext {
|
|
|
112
112
|
defaultDescription: string | null;
|
|
113
113
|
/** `default_demo.for` from theme.config.ts, unvalidated — the primary template's business. */
|
|
114
114
|
defaultFor: unknown;
|
|
115
|
+
/** `description` from theme.config.ts — the theme's own, not a template's. */
|
|
116
|
+
themeDescription: string | null;
|
|
115
117
|
manifest: ThemeManifest | null;
|
|
116
118
|
/** Declares page-block variants, so the page-based structure applies. */
|
|
117
119
|
pageBased: boolean;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export declare const SERVICE_SLUGS: readonly string[];
|
|
2
2
|
export declare const CATALOGUE: Readonly<Record<string, readonly string[]>>;
|
|
3
|
+
/** A branch's own children, a third level (`bags-accessories` → `jewelry`). */
|
|
4
|
+
export declare const SUBCATEGORIES: Readonly<Record<string, readonly string[]>>;
|
|
3
5
|
export declare const BUSINESS_KEYS: ReadonlySet<string>;
|
|
4
6
|
export declare function isBusinessKey(key: string): boolean;
|
|
5
|
-
/** A catalogue
|
|
7
|
+
/** A catalogue key's root (`wigs-extensions-hair-accessories` → `beauty-personal-care`, `jewelry` → `fashion`); any other key is its own. */
|
|
6
8
|
export declare function businessRoot(key: string): string;
|
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
import vocabulary from './business-vocabulary.json' with { type: 'json' };
|
|
8
8
|
export const SERVICE_SLUGS = vocabulary.services;
|
|
9
9
|
export const CATALOGUE = vocabulary.catalogue;
|
|
10
|
+
/** A branch's own children, a third level (`bags-accessories` → `jewelry`). */
|
|
11
|
+
export const SUBCATEGORIES = vocabulary.subcategories;
|
|
10
12
|
const ROOT_OF = new Map(Object.entries(CATALOGUE).flatMap(([root, branches]) => [[root, root], ...branches.map((branch) => [branch, root])]));
|
|
13
|
+
for (const [branch, children] of Object.entries(SUBCATEGORIES)) {
|
|
14
|
+
for (const child of children)
|
|
15
|
+
ROOT_OF.set(child, ROOT_OF.get(branch) ?? branch);
|
|
16
|
+
}
|
|
11
17
|
export const BUSINESS_KEYS = new Set([...SERVICE_SLUGS, ...ROOT_OF.keys()]);
|
|
12
18
|
export function isBusinessKey(key) {
|
|
13
19
|
return BUSINESS_KEYS.has(key);
|
|
14
20
|
}
|
|
15
|
-
/** A catalogue
|
|
21
|
+
/** A catalogue key's root (`wigs-extensions-hair-accessories` → `beauty-personal-care`, `jewelry` → `fashion`); any other key is its own. */
|
|
16
22
|
export function businessRoot(key) {
|
|
17
23
|
return ROOT_OF.get(key) ?? key;
|
|
18
24
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "The only keys a template's `for` may use (theme.config.ts default_demo.for / demos[].for). `services` are the slugs a vendor registers as; `catalogue` is the marketplace product taxonomy, root -> branches, each key the Str::slug of the category name. The backend reads a vendor's business from what they actually sell and ranks a specific (branch) match above a broad one. Agreed with queek_backend 24/9/26 (storefront-theme-templates-contract.md, R2.1); change it only together with the backend.",
|
|
2
|
+
"$comment": "The only keys a template's `for` may use (theme.config.ts default_demo.for / demos[].for). `services` are the slugs a vendor registers as; `catalogue` is the marketplace product taxonomy, root -> branches, and `subcategories` a branch -> its own children; each key is the Str::slug of the category name. The backend reads a vendor's business from what they actually sell and ranks a specific (branch) match above a broad one. Agreed with queek_backend 24/9/26 (storefront-theme-templates-contract.md, R2.1; jewelry and beverages R2.5); change it only together with the backend.",
|
|
3
3
|
"services": [
|
|
4
4
|
"foods", "local-meals", "shawarma-pizza-snacks", "suya", "boli", "fruits-fresh-natural",
|
|
5
5
|
"meat-fish", "supermarket", "groceries", "shop-groceries", "local-market", "pharmacy",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"home-living": ["furniture", "home-decor", "kitchen-dining", "bedding-bath", "lighting-electrical", "storage-organization"],
|
|
16
16
|
"baby-kids": ["baby-gear", "feeding-nursing", "diapering-potty", "baby-clothing", "kids-clothing", "toys-games", "school-essentials", "nursery-safety"],
|
|
17
17
|
"health-wellness": ["vitamins-supplements", "personal-care-hygiene", "fitness-recovery", "sexual-wellness", "medical-supplies", "health-devices", "healthy-living"],
|
|
18
|
-
"office-school": ["stationery-writing", "office-supplies", "school-supplies", "bags-lunch-gear", "office-furniture", "printers-accessories", "tech-for-school-office"]
|
|
18
|
+
"office-school": ["stationery-writing", "office-supplies", "school-supplies", "bags-lunch-gear", "office-furniture", "printers-accessories", "tech-for-school-office"],
|
|
19
|
+
"supermarket": ["beverages"]
|
|
20
|
+
},
|
|
21
|
+
"subcategories": {
|
|
22
|
+
"bags-accessories": ["jewelry"]
|
|
19
23
|
}
|
|
20
24
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a template's `copy` may not say (queek_backend contract R2.6).
|
|
3
|
+
*
|
|
4
|
+
* The merchant setup wizard publishes a template's homepage onto a real store
|
|
5
|
+
* without rewriting it, so its copy must be true of ANY store in that business:
|
|
6
|
+
* no demo store name ("…at Mama Tee's"), no place ("delivered across Lekki"),
|
|
7
|
+
* no naira amount ("free delivery over ₦5,000"), no offer or coupon code, no
|
|
8
|
+
* opening hours, no promise only the vendor can make (a delivery window, a
|
|
9
|
+
* return period, a guarantee), no founding date or store age, and no email or
|
|
10
|
+
* phone number. Testimonials and reviews are exempt; the backend never places
|
|
11
|
+
* their copy on a real store.
|
|
12
|
+
*
|
|
13
|
+
* Each pattern is shaped by what it must NOT catch as much as what it must: a
|
|
14
|
+
* review ran real demo lines through it (24/9/26) — "Cold brewed for 12–24
|
|
15
|
+
* hours" is process, "Delivered in 2 hours" is a promise; "London Dry gin" is a
|
|
16
|
+
* style, "Made in Lagos" is a place. tests/theme-templates.test.ts holds both lists.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Places the demo copy named when it was measured (24/9/26), plus the rest of
|
|
20
|
+
* the country's big cities and Lagos and Abuja districts a new template is
|
|
21
|
+
* likely to reach for. Matched case-sensitively as whole words, so "the island"
|
|
22
|
+
* or "delta" in a sentence are not places. Left out on purpose: "Ankara" (in
|
|
23
|
+
* this copy it is the fabric) and cities that name product styles — London Dry
|
|
24
|
+
* gin, New York cheesecake, Dubai chocolate, Milan and Florence cuts.
|
|
25
|
+
*/
|
|
26
|
+
export declare const TEMPLATE_COPY_PLACES: readonly string[];
|
|
27
|
+
/**
|
|
28
|
+
* The forms of a store's name that copy would use: the full name, without a
|
|
29
|
+
* leading "The", without the generic words it ends in ("Mama Tee's Buka" →
|
|
30
|
+
* "Mama Tee"), and each in capitals. A name that is nothing but generic words
|
|
31
|
+
* ("Fashion House") keeps only its full form — "Fashion" alone is the business.
|
|
32
|
+
*/
|
|
33
|
+
export declare function storeNameForms(name: string | null | undefined): string[];
|
|
34
|
+
/** Why one copy string could not go live on another store unchanged; empty when it can. */
|
|
35
|
+
export declare function copyViolations(text: string, storeName: string | null | undefined): string[];
|
|
36
|
+
/**
|
|
37
|
+
* Testimonials and reviews, as the backend reads them (R2.6.3): a variant
|
|
38
|
+
* whose id or section type names them, or whose items declare quote, author or
|
|
39
|
+
* rating. Their copy is never placed on a real store, so the demo keeps it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isTestimonialSection(type: string, variant: string, fields: Record<string, unknown> | undefined): boolean;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a template's `copy` may not say (queek_backend contract R2.6).
|
|
3
|
+
*
|
|
4
|
+
* The merchant setup wizard publishes a template's homepage onto a real store
|
|
5
|
+
* without rewriting it, so its copy must be true of ANY store in that business:
|
|
6
|
+
* no demo store name ("…at Mama Tee's"), no place ("delivered across Lekki"),
|
|
7
|
+
* no naira amount ("free delivery over ₦5,000"), no offer or coupon code, no
|
|
8
|
+
* opening hours, no promise only the vendor can make (a delivery window, a
|
|
9
|
+
* return period, a guarantee), no founding date or store age, and no email or
|
|
10
|
+
* phone number. Testimonials and reviews are exempt; the backend never places
|
|
11
|
+
* their copy on a real store.
|
|
12
|
+
*
|
|
13
|
+
* Each pattern is shaped by what it must NOT catch as much as what it must: a
|
|
14
|
+
* review ran real demo lines through it (24/9/26) — "Cold brewed for 12–24
|
|
15
|
+
* hours" is process, "Delivered in 2 hours" is a promise; "London Dry gin" is a
|
|
16
|
+
* style, "Made in Lagos" is a place. tests/theme-templates.test.ts holds both lists.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Places the demo copy named when it was measured (24/9/26), plus the rest of
|
|
20
|
+
* the country's big cities and Lagos and Abuja districts a new template is
|
|
21
|
+
* likely to reach for. Matched case-sensitively as whole words, so "the island"
|
|
22
|
+
* or "delta" in a sentence are not places. Left out on purpose: "Ankara" (in
|
|
23
|
+
* this copy it is the fabric) and cities that name product styles — London Dry
|
|
24
|
+
* gin, New York cheesecake, Dubai chocolate, Milan and Florence cuts.
|
|
25
|
+
*/
|
|
26
|
+
export const TEMPLATE_COPY_PLACES = [
|
|
27
|
+
// Nigeria, its states and big cities
|
|
28
|
+
'Nigeria', 'Lagos', 'Abuja', 'Port Harcourt', 'Ibadan', 'Kano', 'Kaduna', 'Enugu', 'Benin City', 'Abeokuta',
|
|
29
|
+
'Owerri', 'Uyo', 'Calabar', 'Jos', 'Warri', 'Asaba', 'Onitsha', 'Aba', 'Ilorin', 'Akure', 'Osogbo', 'Ile-Ife',
|
|
30
|
+
'Sokoto', 'Maiduguri', 'Kwara', 'Ogun', 'Oyo', 'Anambra', 'Akwa Ibom', 'Cross River', 'Bayelsa', 'Ekiti', 'Ondo',
|
|
31
|
+
'Osun', 'Kogi', 'Benue', 'Katsina', 'Bauchi', 'Gombe', 'Borno', 'Adamawa', 'Taraba', 'Nasarawa', 'Zamfara',
|
|
32
|
+
'Kebbi', 'Jigawa', 'Ebonyi', 'Delta State', 'Rivers State', 'Plateau State', 'Niger State', 'Imo State', 'Edo State',
|
|
33
|
+
// Lagos
|
|
34
|
+
'Lekki', 'Ikoyi', 'Victoria Island', 'VI', 'Lagos Island', 'Banana Island', 'Eko Atlantic', 'Eko', 'Oniru', 'Ajah',
|
|
35
|
+
'Sangotedo', 'Yaba', 'Surulere', 'Ikeja', 'Computer Village', 'GRA', 'Maryland', 'Magodo', 'Gbagada', 'Ogudu',
|
|
36
|
+
'Ojodu', 'Ojota', 'Ketu', 'Agege', 'Isolo', 'Oshodi', 'Mushin', 'Ikorodu', 'Epe', 'Badagry', 'Festac', 'Apapa',
|
|
37
|
+
'Obalende', 'Ebute Metta', 'Ebute-Metta', 'Idumota', 'Balogun', 'Marina', 'Ilupeju', 'Opebi', 'Oregun',
|
|
38
|
+
'Allen Avenue', 'Admiralty Way', 'Awolowo Road', 'Odeku Street', 'Akoka', 'Bariga', 'Shomolu', 'Ogba', 'Omole',
|
|
39
|
+
'Ikota', 'Osapa', 'Chevron Drive',
|
|
40
|
+
// Abuja and Port Harcourt
|
|
41
|
+
'Wuse', 'Maitama', 'Garki', 'Asokoro', 'Gwarinpa', 'Jabi', 'Utako', 'Kubwa', 'Lugbe', 'Katampe', 'Guzape',
|
|
42
|
+
'Trans-Amadi', 'Rumuola',
|
|
43
|
+
// Abroad, as the demo stores claimed origins
|
|
44
|
+
'Accra', 'Nairobi', 'Johannesburg', 'Birmingham', 'Paris', 'Italy', 'France', 'Japan', 'China', 'Tokyo', 'Jaipur',
|
|
45
|
+
'Istanbul', 'Guangzhou',
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Words a store name ends in that say what it is, not who: stripped to find
|
|
49
|
+
* the name a copy line would use ("Bloom Bakehouse" → "Bloom").
|
|
50
|
+
*/
|
|
51
|
+
const GENERIC_NAME_WORDS = new Set([
|
|
52
|
+
'co', 'co.', 'company', 'ltd', 'limited', 'enterprises', 'ventures', 'global', 'supply', 'house', 'kitchen',
|
|
53
|
+
'studio', 'studios', 'bakehouse', 'bakery', 'grillhouse', 'grill', 'buka', 'cafe', 'café', 'coffee', 'roasters',
|
|
54
|
+
'lagos', 'skin', 'skincare', 'cosmetics', 'beauty', 'hair', 'wigs', 'salon', 'fine', 'jewellery', 'jewelry',
|
|
55
|
+
'fashion', 'occasion', 'tailoring', 'couture', 'wear', 'shoes', 'sneakers', 'store', 'stores', 'shop', 'mart',
|
|
56
|
+
'boutique', 'collective', 'hub', 'foods', 'food', 'laundry', 'laundromat', 'pharmacy',
|
|
57
|
+
]);
|
|
58
|
+
/**
|
|
59
|
+
* The forms of a store's name that copy would use: the full name, without a
|
|
60
|
+
* leading "The", without the generic words it ends in ("Mama Tee's Buka" →
|
|
61
|
+
* "Mama Tee"), and each in capitals. A name that is nothing but generic words
|
|
62
|
+
* ("Fashion House") keeps only its full form — "Fashion" alone is the business.
|
|
63
|
+
*/
|
|
64
|
+
export function storeNameForms(name) {
|
|
65
|
+
const full = (name ?? '').trim();
|
|
66
|
+
if (!full)
|
|
67
|
+
return [];
|
|
68
|
+
const bare = full.replace(/^The\s+/, '');
|
|
69
|
+
const forms = new Set([full, bare]);
|
|
70
|
+
const words = bare.split(/\s+/);
|
|
71
|
+
while (words.length > 1 && GENERIC_NAME_WORDS.has(words[words.length - 1].toLowerCase()))
|
|
72
|
+
words.pop();
|
|
73
|
+
const core = words.join(' ').replace(/['’]s$/, '');
|
|
74
|
+
const allGeneric = words.every((word) => GENERIC_NAME_WORDS.has(word.toLowerCase().replace(/['’]s$/, '')));
|
|
75
|
+
if (core.length >= 3 && !allGeneric)
|
|
76
|
+
forms.add(core);
|
|
77
|
+
for (const form of [...forms])
|
|
78
|
+
forms.add(form.toUpperCase());
|
|
79
|
+
return [...forms];
|
|
80
|
+
}
|
|
81
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
82
|
+
/** A whole word or phrase, case-sensitive: letters on either side make it part of another word. */
|
|
83
|
+
const wordPattern = (phrase) => new RegExp(`(?<![\\p{L}\\p{N}])${escape(phrase)}(?![\\p{L}\\p{N}])`, 'u');
|
|
84
|
+
/** "VI" is Victoria Island — unless it numbers something ("Collection VI"). */
|
|
85
|
+
const ROMAN_CONTEXT = '(?<!(?:Collection|Volume|Vol\\.|Part|Chapter|Edition|Series|Season|Phase|No\\.|Act|Book) )';
|
|
86
|
+
const PLACE_PATTERNS = TEMPLATE_COPY_PLACES.map((place) => [
|
|
87
|
+
place,
|
|
88
|
+
place === 'VI' ? new RegExp(`${ROMAN_CONTEXT}(?<![\\p{L}\\p{N}])VI(?![\\p{L}\\p{N}])`, 'u') : wordPattern(place),
|
|
89
|
+
]);
|
|
90
|
+
/** ₦5,000 · NGN 5000 · N5,000 · N5000 · 5,000 naira — any naira amount. */
|
|
91
|
+
const NAIRA_AMOUNT = [
|
|
92
|
+
/₦\s?\d|\bNGN\s?\d/,
|
|
93
|
+
/(?<![\p{L}\p{N}])N\d{1,3}(,\d{3})+(?![\p{N}])|(?<![\p{L}\p{N}])N\d{3,}(?![\p{L}\p{N}])/u,
|
|
94
|
+
/\d[\d,.]*\s?k?\s?naira\b/i,
|
|
95
|
+
];
|
|
96
|
+
/** A discount or a coupon code: "Save 20% with code RAINS20", "Up to 25% off". */
|
|
97
|
+
const OFFER = [
|
|
98
|
+
/\b(?:code|coupon|promo(?: code)?)\s*:?\s*[A-Z][A-Z0-9]{3,}\b/,
|
|
99
|
+
/\b\d{1,3}\s?%\s?(?:off|discount)\b|\bsave\s+(?:up to\s+)?\d{1,3}\s?%|\bup to\s+\d{1,3}\s?%/i,
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* Opening hours: "Open daily", "11am–10pm", "Doors open 11am", "from 6 AM",
|
|
103
|
+
* "till 11 PM", "08:00–19:00". A time on its own is not hours ("Glow before
|
|
104
|
+
* 8am", "trace your feet after 6pm").
|
|
105
|
+
*/
|
|
106
|
+
const TIME = String.raw `\d{1,2}(?::\d{2})?\s?(?:am|pm)\b`;
|
|
107
|
+
const HOURS = new RegExp([
|
|
108
|
+
String.raw `\bopen\s+(?:daily|every\s+day|all\s+week|24\/7|early|late)\b`,
|
|
109
|
+
String.raw `\b\d{1,2}(?::\d{2})?\s?(?:am|pm)?\s?(?:–|-|to)\s?${TIME}`,
|
|
110
|
+
String.raw `\b(?:opens?|opening|doors|from|till|until|last\s+orders|closes?|closing)\s+(?:at\s+)?${TIME}`,
|
|
111
|
+
String.raw `\b(?:[01]?\d|2[0-3]):[0-5]\d\s?(?:–|-|to)\s?(?:[01]?\d|2[0-3]):[0-5]\d\b`,
|
|
112
|
+
].join('|'), 'i');
|
|
113
|
+
/**
|
|
114
|
+
* Commitments only the vendor can make. A time window is a promise only next
|
|
115
|
+
* to a service — delivered, ships, answered, fitted, returned — so a recipe's
|
|
116
|
+
* or a process's time ("marinated 24–48 hours", "ready in 3 minutes") is not.
|
|
117
|
+
*/
|
|
118
|
+
const SERVICE = String.raw `(?:deliver\w*|ship(?:s|ped|ping)?|dispatch\w*|arriv\w*|collect\w*|pick(?:ed|s)?[- ]?up|turnaround|answer\w*|repl(?:y|ies|ied)|respond\w*|install\w*|fit(?:ted|ting)?|resiz\w*|repair\w*|replace\w*|exchang\w*|return\w*|refund\w*)`;
|
|
119
|
+
const WINDOW = String.raw `(?:(?:within|in|under)\s+(?:about\s+)?(?:\d+|an?|one|two|three|four)[\s-]?(?:min(?:ute)?s?|hours?|hrs?|days?|weeks?)|\d+\s*(?:–|-|to)\s*\d+[\s-]*(?:working\s+|business\s+)?(?:hours?|days?|weeks?)|(?:same|next)[- ](?:day|evening|morning))`;
|
|
120
|
+
const PROMISE = [
|
|
121
|
+
new RegExp(String.raw `\b${SERVICE}\b[^.!?\n]{0,40}?${WINDOW}\b|${WINDOW}\b[^.!?\n]{0,40}?\b${SERVICE}\b`, 'i'),
|
|
122
|
+
/\b\d+[\s-](?:minute|min|hour|hr|day|week)s?\s+(?:delivery|dispatch|shipping|turnaround|returns?|exchanges?|refunds?|adjustments?|service)\b/i,
|
|
123
|
+
/\bfree\s+(?:\w+\s+)?(?:delivery|shipping|returns?|pick[- ]?up|collection|alterations?|installation|install|resizing|exchanges?)\b/i,
|
|
124
|
+
/\b(?:guarantee[ds]?|money[- ]back|warrant(?:y|ies)|no questions asked)\b/i,
|
|
125
|
+
];
|
|
126
|
+
/** The store's history, not the business's: "since 2014", "started in 2019", "six years, two stores". */
|
|
127
|
+
const NUMBER = '(?:\\d+|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|fifteen|twenty)';
|
|
128
|
+
const STORE_HISTORY = [
|
|
129
|
+
/\b(?:since|est\.?|established(?:\s+in)?)\s+(?:19|20)\d\d\b/i,
|
|
130
|
+
/\b(?:founded|started|opened|established|began)\b(?:\s+[\w’'-]+){0,6}?\s+in\s+(?:19|20)\d\d\b/i,
|
|
131
|
+
/\bin\s+(?:19|20)\d\d,?\s+(?:we|our|the\s+(?:shop|store|kitchen|studio|house|bakery))\b/i,
|
|
132
|
+
new RegExp(`\\b${NUMBER}\\s+years?(?:,|\\s+(?:on|later|ago|in\\s+business|of\\s+(?:experience|service|serving|trading)))`, 'i'),
|
|
133
|
+
];
|
|
134
|
+
/** The demo store's email or phone written into the words ("Email hello@zuri.ng", "wa.me/234…"). */
|
|
135
|
+
const CONTACT = /[\w.+-]+@[\w-]+\.[a-z]{2,}|\+234[\s\d-]{6,}|\b0[789][01]\d(?:[\s-]?\d){7}\b|\bwa\.me\/\d+/i;
|
|
136
|
+
const first = (patterns, text) => {
|
|
137
|
+
for (const pattern of patterns) {
|
|
138
|
+
const match = pattern.exec(text);
|
|
139
|
+
if (match)
|
|
140
|
+
return match[0];
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
/** Why one copy string could not go live on another store unchanged; empty when it can. */
|
|
145
|
+
export function copyViolations(text, storeName) {
|
|
146
|
+
const found = [];
|
|
147
|
+
const name = storeNameForms(storeName).find((form) => wordPattern(form).test(text));
|
|
148
|
+
if (name)
|
|
149
|
+
found.push(`names the store ("${name}")`);
|
|
150
|
+
const places = PLACE_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([place]) => place);
|
|
151
|
+
if (places.length > 0)
|
|
152
|
+
found.push(`names ${places.length === 1 ? 'a place' : 'places'} (${places.join(', ')})`);
|
|
153
|
+
if (first(NAIRA_AMOUNT, text))
|
|
154
|
+
found.push('states a naira amount');
|
|
155
|
+
const offer = first(OFFER, text);
|
|
156
|
+
if (offer)
|
|
157
|
+
found.push(`states an offer ("${offer}")`);
|
|
158
|
+
const hours = HOURS.exec(text);
|
|
159
|
+
if (hours)
|
|
160
|
+
found.push(`states opening hours ("${hours[0]}")`);
|
|
161
|
+
const promise = first(PROMISE, text);
|
|
162
|
+
if (promise)
|
|
163
|
+
found.push(`makes a promise ("${promise}")`);
|
|
164
|
+
const history = first(STORE_HISTORY, text);
|
|
165
|
+
if (history)
|
|
166
|
+
found.push(`dates the store ("${history}")`);
|
|
167
|
+
const contact = CONTACT.exec(text);
|
|
168
|
+
if (contact)
|
|
169
|
+
found.push(`gives the store’s contact details ("${contact[0]}")`);
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
172
|
+
/** A section whose type or variant id is a testimonials/reviews one — whole words, so "preview" is not. */
|
|
173
|
+
const TESTIMONIAL_NAME = /(?:^|[\s_-])(?:testimonials?|reviews?)(?:$|[\s_-])/i;
|
|
174
|
+
/** Keys that make a list a set of customer quotes. */
|
|
175
|
+
const TESTIMONIAL_KEYS = /\b(quote|author|rating)\b/;
|
|
176
|
+
/**
|
|
177
|
+
* Testimonials and reviews, as the backend reads them (R2.6.3): a variant
|
|
178
|
+
* whose id or section type names them, or whose items declare quote, author or
|
|
179
|
+
* rating. Their copy is never placed on a real store, so the demo keeps it.
|
|
180
|
+
*/
|
|
181
|
+
export function isTestimonialSection(type, variant, fields) {
|
|
182
|
+
if (TESTIMONIAL_NAME.test(type) || TESTIMONIAL_NAME.test(variant))
|
|
183
|
+
return true;
|
|
184
|
+
return Object.values(fields ?? {}).some((spec) => {
|
|
185
|
+
if (typeof spec === 'string')
|
|
186
|
+
return TESTIMONIAL_KEYS.test(spec);
|
|
187
|
+
const of = spec && typeof spec === 'object' ? spec.of : undefined;
|
|
188
|
+
return !!of && typeof of === 'object' && Object.keys(of).some((key) => TESTIMONIAL_KEYS.test(key));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -163,8 +163,13 @@ const ENTRY_KEYS = /\[\{([^}\]]+)\}\]/;
|
|
|
163
163
|
* hotspot coordinates, icons — belongs to the demo store, not the design.
|
|
164
164
|
*/
|
|
165
165
|
const ENTRY_TEXT_KEYS = new Set(['title', 'subtitle', 'caption', 'text', 'heading', 'eyebrow', 'label', 'cta_label', 'question', 'answer', 'quote', 'author', 'name', 'role', 'body', 'description', 'content', 'kicker']);
|
|
166
|
-
/**
|
|
167
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Links, references, prices, media, alt text (it describes one photo) and icon
|
|
168
|
+
* names are never copy, whatever their declared type. Nor are the demo store's
|
|
169
|
+
* own facts — its email, phone, address, opening hours and coupon code — or
|
|
170
|
+
* hotspot coordinates (`spots`): the vendor's profile supplies the facts.
|
|
171
|
+
*/
|
|
172
|
+
const NOT_COPY = /(^|_)(url|link|href|slug|id|ids|price|amount|currency|video|image|icon|alt|email|phone|whatsapp|address|hours|code|spots)$/;
|
|
168
173
|
/** `[the balm](/products/balm)` → `the balm`: copy keeps a link's words, never where it pointed. */
|
|
169
174
|
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
|
170
175
|
function textOf(value) {
|