@usequeek/theme-check 0.4.0 → 0.5.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/format.js +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/rules/static.d.ts +11 -0
- package/dist/rules/static.js +88 -6
- package/dist/run.d.ts +13 -0
- package/dist/run.js +7 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.js +0 -9
- package/dist/utils/business-vocabulary.d.ts +21 -0
- package/dist/utils/business-vocabulary.js +55 -11
- package/dist/utils/business-vocabulary.json +421 -16
- package/dist/utils/template-copy.js +18 -4
- package/dist/vocabulary.d.ts +70 -0
- package/dist/vocabulary.js +249 -0
- package/package.json +1 -1
package/dist/format.js
CHANGED
|
@@ -16,6 +16,7 @@ export function formatJson(result) {
|
|
|
16
16
|
return JSON.stringify({
|
|
17
17
|
theme: result.context.slug,
|
|
18
18
|
summary: summarize(result.findings),
|
|
19
|
+
vocabulary: result.vocabulary,
|
|
19
20
|
findings: result.findings.map((finding) => ({
|
|
20
21
|
rule: finding.rule,
|
|
21
22
|
level: levelOf(finding),
|
package/dist/index.d.ts
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
* against, as a library. The `queek-theme check` command of
|
|
4
4
|
* @usequeek/theme-cli is its command-line front end.
|
|
5
5
|
*/
|
|
6
|
-
export { checkTheme, rejects, RULES, AT_SUBMISSION, type CheckOptions, type CheckResult } from './run.js';
|
|
6
|
+
export { checkTheme, rejects, RULES, AT_SUBMISSION, type CheckOptions, type CheckResult, type CheckVocabulary } from './run.js';
|
|
7
|
+
export { resolveVocabulary, vocabularyCacheDir, readCachedVocabulary, bundledVocabulary, bundledVersion, parseEndpointPayload, parseVocabularyData, VOCABULARY_URL, VOCABULARY_CACHE_TTL_MS, type BusinessVocabularyData, type ResolveVocabularyOptions, type ResolvedVocabulary, type VocabularySource, } from './vocabulary.js';
|
|
7
8
|
export { loadContext, localEnv, CONTRACT_URL } from './context.js';
|
|
8
9
|
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf, type Level, type Summary } from './format.js';
|
|
9
10
|
export type { CheckEnv, Finding, Rule, Severity, ThemeContext, DemoStore, DeclaredDemo } from './types.js';
|
|
10
|
-
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
11
|
+
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, ROOT_SERVICE, BUNDLED_VERSION, bundledVocabularyView, vocabularyViewOf, isBusinessKey, businessRoot, type VocabularyView } from './utils/business-vocabulary.js';
|
|
11
12
|
export { TEMPLATE_COPY_PLACES, copyViolations, isTestimonialSection, storeNameForms } from './utils/template-copy.js';
|
|
12
13
|
export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
|
|
13
14
|
export { designsOf, groupTemplates, mainTemplateKey, composeLabel, type DesignDeclaration, type ThemeDesignsConfig, type ThemeDesign, type ThemeTemplate, } from './utils/theme-designs.js';
|
|
14
15
|
export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, declaredFieldsByVariant } from './utils/theme-templates.js';
|
|
15
|
-
export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templateDesignsRule, TEMPLATE_DESIGNS_MAX, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, frameworkImport, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
|
|
16
|
+
export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templateDesignsRule, TEMPLATE_DESIGNS_MAX, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, frameworkImport, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
|
|
16
17
|
export { ANALYSIS_RULES, variantParityRule, fieldParityRule, designTokensRule } from './rules/analysis.js';
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* @usequeek/theme-cli is its command-line front end.
|
|
5
5
|
*/
|
|
6
6
|
export { checkTheme, rejects, RULES, AT_SUBMISSION } from './run.js';
|
|
7
|
+
export { resolveVocabulary, vocabularyCacheDir, readCachedVocabulary, bundledVocabulary, bundledVersion, parseEndpointPayload, parseVocabularyData, VOCABULARY_URL, VOCABULARY_CACHE_TTL_MS, } from './vocabulary.js';
|
|
7
8
|
export { loadContext, localEnv, CONTRACT_URL } from './context.js';
|
|
8
9
|
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf } from './format.js';
|
|
9
|
-
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
10
|
+
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, ROOT_SERVICE, BUNDLED_VERSION, bundledVocabularyView, vocabularyViewOf, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
10
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
|
// Theme → template → design (contract R2.8): the resolver the registry, the
|
|
@@ -16,5 +17,5 @@ export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, de
|
|
|
16
17
|
// Per-kind rule arrays and the individual rule constants, for rule-level unit
|
|
17
18
|
// tests that want to run one rule against a hand-built ThemeContext instead
|
|
18
19
|
// of a whole theme directory through checkTheme().
|
|
19
|
-
export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templateDesignsRule, TEMPLATE_DESIGNS_MAX, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, frameworkImport, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
|
|
20
|
+
export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templateDesignsRule, TEMPLATE_DESIGNS_MAX, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, frameworkImport, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
|
|
20
21
|
export { ANALYSIS_RULES, variantParityRule, fieldParityRule, designTokensRule } from './rules/analysis.js';
|
package/dist/rules/static.d.ts
CHANGED
|
@@ -32,6 +32,17 @@ export declare const selectionMetadataRule: Rule;
|
|
|
32
32
|
export declare const demoCompletenessRule: Rule;
|
|
33
33
|
export declare const subscribeScopeRule: Rule;
|
|
34
34
|
export declare const demoBlockTypesRule: Rule;
|
|
35
|
+
/**
|
|
36
|
+
* A composition's variants are ones the theme implements (backend BE19: every
|
|
37
|
+
* beaurify store's blog named gallery/grid, which beaurify does not implement,
|
|
38
|
+
* and the backend refused three templates over it). "Implemented" is the
|
|
39
|
+
* registry's `implemented_variants`: the manifest's declared variants per
|
|
40
|
+
* scope — theme/variant-parity enforces they match the theme's
|
|
41
|
+
* `variantImplementations` map 1:1, so the manifest list is the implemented
|
|
42
|
+
* set. A separate id from theme/demo-block-types: that rule owns type names
|
|
43
|
+
* against core's list, this one variant ids against the theme's own.
|
|
44
|
+
*/
|
|
45
|
+
export declare const compositionVariantsRule: Rule;
|
|
35
46
|
export declare const identityRule: Rule;
|
|
36
47
|
export declare const productMetafieldsRule: Rule;
|
|
37
48
|
export declare const poweredByRule: Rule;
|
package/dist/rules/static.js
CHANGED
|
@@ -5,7 +5,7 @@ import { DEMO_ID_FORMAT, PRIMARY_DEMO_ID } from '../utils/theme-demos.js';
|
|
|
5
5
|
import { designsOf, groupTemplates, mainTemplateKey } from '../utils/theme-designs.js';
|
|
6
6
|
import { SCREENSHOT_LOCK, TEMPLATE_DESCRIPTION_MAX, TEMPLATE_DESCRIPTION_PLACEHOLDER, declaredFieldsByVariant, isPresentationalField, screenshotFile, screenshotUrl, sectionCopy } from '../utils/theme-templates.js';
|
|
7
7
|
import { copyViolations, isTestimonialSection } from '../utils/template-copy.js';
|
|
8
|
-
import {
|
|
8
|
+
import { bundledVocabularyView } from '../utils/business-vocabulary.js';
|
|
9
9
|
import { themeSourceFiles } from '../context.js';
|
|
10
10
|
import { finding } from '../types.js';
|
|
11
11
|
/** A theme file as a finding names it: relative to the theme, with `/` on every OS (Windows gave `styles\type.css`). */
|
|
@@ -550,6 +550,58 @@ export const demoBlockTypesRule = {
|
|
|
550
550
|
return findings;
|
|
551
551
|
},
|
|
552
552
|
};
|
|
553
|
+
/**
|
|
554
|
+
* A composition's variants are ones the theme implements (backend BE19: every
|
|
555
|
+
* beaurify store's blog named gallery/grid, which beaurify does not implement,
|
|
556
|
+
* and the backend refused three templates over it). "Implemented" is the
|
|
557
|
+
* registry's `implemented_variants`: the manifest's declared variants per
|
|
558
|
+
* scope — theme/variant-parity enforces they match the theme's
|
|
559
|
+
* `variantImplementations` map 1:1, so the manifest list is the implemented
|
|
560
|
+
* set. A separate id from theme/demo-block-types: that rule owns type names
|
|
561
|
+
* against core's list, this one variant ids against the theme's own.
|
|
562
|
+
*/
|
|
563
|
+
export const compositionVariantsRule = {
|
|
564
|
+
id: 'theme/composition-variants',
|
|
565
|
+
summary: 'Every section names a variant its theme implements',
|
|
566
|
+
kind: 'static',
|
|
567
|
+
run(context) {
|
|
568
|
+
if (context.retired || !context.manifest)
|
|
569
|
+
return [];
|
|
570
|
+
const declared = context.manifest.variants ?? {};
|
|
571
|
+
const implemented = new Map();
|
|
572
|
+
for (const [scope, list] of Object.entries(declared)) {
|
|
573
|
+
if (Array.isArray(list))
|
|
574
|
+
implemented.set(scope, list.map((variant) => String(variant?.id)));
|
|
575
|
+
}
|
|
576
|
+
const findings = [];
|
|
577
|
+
for (const store of context.demos) {
|
|
578
|
+
if (!store.data)
|
|
579
|
+
continue;
|
|
580
|
+
const pages = store.data.pages ?? {};
|
|
581
|
+
const list = Array.isArray(pages)
|
|
582
|
+
? pages
|
|
583
|
+
: Object.entries(pages).map(([slug, page]) => ({ slug, ...page }));
|
|
584
|
+
for (const page of list) {
|
|
585
|
+
(page?.content ?? []).forEach((section, index) => {
|
|
586
|
+
const type = section?.type;
|
|
587
|
+
const variant = section?.variant;
|
|
588
|
+
if (typeof type !== 'string' || typeof variant !== 'string' || variant === '')
|
|
589
|
+
return;
|
|
590
|
+
const ids = implemented.get(type);
|
|
591
|
+
if (!ids || ids.includes(variant))
|
|
592
|
+
return;
|
|
593
|
+
findings.push(finding(context, 'theme/composition-variants', 'reject', {
|
|
594
|
+
where: `${context.env.root}${store.file} → pages.${page.slug ?? '?'}[${index}]`,
|
|
595
|
+
found: `${type}/${variant}`,
|
|
596
|
+
fix: `${type}/${variant} is not a ${type} variant ${context.slug} implements (one of: ${ids.join(', ') || 'none'}). A store built from this page carries the section verbatim; a variant the theme cannot render comes out blank.`,
|
|
597
|
+
docs: `${context.env.docs}#manifest`,
|
|
598
|
+
}));
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return findings;
|
|
603
|
+
},
|
|
604
|
+
};
|
|
553
605
|
export const identityRule = {
|
|
554
606
|
id: 'theme/identity',
|
|
555
607
|
summary: 'manifest, config, demo and directory all agree on the slug',
|
|
@@ -860,6 +912,10 @@ export const templateBusinessRule = {
|
|
|
860
912
|
run(context) {
|
|
861
913
|
if (context.retired || context.declaredDemos === null)
|
|
862
914
|
return [];
|
|
915
|
+
// The resolved vocabulary on the context (checkTheme puts it there:
|
|
916
|
+
// bundled unless a live, cached or pinned copy was passed), never the
|
|
917
|
+
// module-level bundled import — hand-built contexts fall back to bundled.
|
|
918
|
+
const vocabulary = context.vocabulary ?? bundledVocabularyView;
|
|
863
919
|
const config = `${context.env.root}theme.config.ts`;
|
|
864
920
|
// Each template once, by its design 1: `for` is the template's, and a later
|
|
865
921
|
// design that repeats it is theme/template-designs' to compare.
|
|
@@ -882,13 +938,13 @@ export const templateBusinessRule = {
|
|
|
882
938
|
}));
|
|
883
939
|
continue;
|
|
884
940
|
}
|
|
885
|
-
const unknown = keys.filter((key) => typeof key !== 'string' || !isBusinessKey(key));
|
|
941
|
+
const unknown = keys.filter((key) => typeof key !== 'string' || !vocabulary.isBusinessKey(key));
|
|
886
942
|
if (unknown.length === 0) {
|
|
887
943
|
// R2.7: the backend matches the business category a merchant picked at setup
|
|
888
944
|
// first. A general template leads with its category; a niche one (hair, shoes,
|
|
889
945
|
// jewellery) names none, or it competes as a general template.
|
|
890
|
-
const named = keys.filter((key) =>
|
|
891
|
-
if (named.length > 0 && !
|
|
946
|
+
const named = keys.filter((key) => vocabulary.services.includes(key));
|
|
947
|
+
if (named.length > 0 && !vocabulary.services.includes(keys[0])) {
|
|
892
948
|
const general = [...named, ...keys.filter((key) => !named.includes(key))];
|
|
893
949
|
findings.push(finding(context, 'theme/template-business', 'reject', {
|
|
894
950
|
where,
|
|
@@ -897,12 +953,24 @@ export const templateBusinessRule = {
|
|
|
897
953
|
docs: `${context.env.docs}#templates`,
|
|
898
954
|
}));
|
|
899
955
|
}
|
|
956
|
+
// R2.9: `shop` is for a general store only. A template dressed as a
|
|
957
|
+
// specific business that names only `shop` matches every general store
|
|
958
|
+
// and no one in particular — advisory (warn), never a reject, so the
|
|
959
|
+
// starter and existing general themes keep passing.
|
|
960
|
+
if (keys.length === 1 && keys[0] === 'shop') {
|
|
961
|
+
findings.push(finding(context, 'theme/template-business', 'warn', {
|
|
962
|
+
where,
|
|
963
|
+
found: `template "${id}" is for only "shop"`,
|
|
964
|
+
fix: 'Name the specific business this template serves in `for` (its business category first) — "shop" is for a general store only.',
|
|
965
|
+
docs: `${context.env.docs}#templates`,
|
|
966
|
+
}));
|
|
967
|
+
}
|
|
900
968
|
continue;
|
|
901
969
|
}
|
|
902
970
|
findings.push(finding(context, 'theme/template-business', 'reject', {
|
|
903
971
|
where,
|
|
904
972
|
found: `template "${id}" is for ${unknown.map((key) => JSON.stringify(key)).join(', ')}, not in the business vocabulary`,
|
|
905
|
-
fix: `Use service slugs or catalogue keys from ${context.env.vocabulary} (${
|
|
973
|
+
fix: `Use service slugs or catalogue keys from ${context.env.vocabulary} (${vocabulary.businessKeys.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.`,
|
|
906
974
|
docs: `${context.env.docs}#templates`,
|
|
907
975
|
}));
|
|
908
976
|
}
|
|
@@ -1135,6 +1203,20 @@ export const templateCopyRule = {
|
|
|
1135
1203
|
const findings = [];
|
|
1136
1204
|
for (const store of context.demos) {
|
|
1137
1205
|
const name = store.data?.profile?.name;
|
|
1206
|
+
// The header announcement is copied onto real stores like section copy,
|
|
1207
|
+
// whether the bar is enabled or not — so it reads by the same rules.
|
|
1208
|
+
const announcement = store.data?.config?.header?.announcement?.text;
|
|
1209
|
+
if (typeof announcement === 'string' && announcement !== '') {
|
|
1210
|
+
const why = copyViolations(announcement, typeof name === 'string' ? name : null);
|
|
1211
|
+
if (why.length > 0) {
|
|
1212
|
+
findings.push(finding(context, 'theme/template-copy', 'reject', {
|
|
1213
|
+
where: `${context.env.root}${store.file} → config.header.announcement.text`,
|
|
1214
|
+
found: `text: ${why.join('; ')} — "${announcement.length > 80 ? `${announcement.slice(0, 77)}…` : announcement}"`,
|
|
1215
|
+
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.',
|
|
1216
|
+
docs: `${context.env.docs}#templates`,
|
|
1217
|
+
}));
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1138
1220
|
for (const [pageKey, page] of Object.entries(pagesOf(store))) {
|
|
1139
1221
|
(page?.content ?? []).forEach((section, index) => {
|
|
1140
1222
|
if (typeof section?.type !== 'string')
|
|
@@ -1361,7 +1443,7 @@ export const placeholderContentRule = {
|
|
|
1361
1443
|
return findings;
|
|
1362
1444
|
},
|
|
1363
1445
|
};
|
|
1364
|
-
export const STATIC_RULES = [moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule,
|
|
1446
|
+
export const STATIC_RULES = [moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule,
|
|
1365
1447
|
templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule,
|
|
1366
1448
|
templateBusinessRule, templateVersionsRule, templateDesignsRule, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule,
|
|
1367
1449
|
];
|
package/dist/run.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CheckEnv, Finding, Rule, ThemeContext } from './types.js';
|
|
2
|
+
import type { BusinessVocabularyData, VocabularySource } from './vocabulary.js';
|
|
2
3
|
/** Every rule that runs on a developer's machine, in the order they report. */
|
|
3
4
|
export declare const RULES: Rule[];
|
|
4
5
|
/**
|
|
@@ -10,15 +11,27 @@ export declare const AT_SUBMISSION: ReadonlyArray<{
|
|
|
10
11
|
id: string;
|
|
11
12
|
summary: string;
|
|
12
13
|
}>;
|
|
14
|
+
/** The vocabulary a check runs against: resolved data plus where it came from (for `check --json`). */
|
|
15
|
+
export interface CheckVocabulary {
|
|
16
|
+
data: BusinessVocabularyData;
|
|
17
|
+
/** Where the data came from. Defaults to bundled for bundled data, else file. */
|
|
18
|
+
source?: VocabularySource;
|
|
19
|
+
}
|
|
13
20
|
export interface CheckOptions {
|
|
14
21
|
/** Override how findings name files and where they link (see CheckEnv). */
|
|
15
22
|
env?: Partial<CheckEnv>;
|
|
16
23
|
/** Run only these rule ids. */
|
|
17
24
|
only?: string[];
|
|
25
|
+
/** The vocabulary the rules read from the context. Default: the bundled snapshot, so existing callers keep working. */
|
|
26
|
+
vocabulary?: CheckVocabulary;
|
|
18
27
|
}
|
|
19
28
|
export interface CheckResult {
|
|
20
29
|
context: ThemeContext;
|
|
21
30
|
findings: Finding[];
|
|
31
|
+
vocabulary: {
|
|
32
|
+
source: VocabularySource;
|
|
33
|
+
version: string;
|
|
34
|
+
};
|
|
22
35
|
}
|
|
23
36
|
/** Every finding for the theme in `themeDir`. No `reject` finding means it will pass these checks on submission. */
|
|
24
37
|
export declare function checkTheme(themeDir: string, options?: CheckOptions): Promise<CheckResult>;
|
package/dist/run.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadContext } from './context.js';
|
|
2
2
|
import { ANALYSIS_RULES } from './rules/analysis.js';
|
|
3
3
|
import { STATIC_RULES } from './rules/static.js';
|
|
4
|
+
import { bundledVocabularyView, vocabularyViewOf } from './utils/business-vocabulary.js';
|
|
4
5
|
/** Every rule that runs on a developer's machine, in the order they report. */
|
|
5
6
|
export const RULES = [...STATIC_RULES, ...ANALYSIS_RULES];
|
|
6
7
|
/**
|
|
@@ -18,6 +19,11 @@ export const AT_SUBMISSION = [
|
|
|
18
19
|
/** Every finding for the theme in `themeDir`. No `reject` finding means it will pass these checks on submission. */
|
|
19
20
|
export async function checkTheme(themeDir, options = {}) {
|
|
20
21
|
const context = await loadContext(themeDir, options.env);
|
|
22
|
+
const view = options.vocabulary
|
|
23
|
+
? vocabularyViewOf(options.vocabulary.data)
|
|
24
|
+
: bundledVocabularyView;
|
|
25
|
+
const source = options.vocabulary?.source ?? (options.vocabulary ? 'file' : 'bundled');
|
|
26
|
+
context.vocabulary = view;
|
|
21
27
|
const rules = RULES.filter((rule) => !options.only || options.only.includes(rule.id));
|
|
22
28
|
const findings = [];
|
|
23
29
|
for (const rule of rules) {
|
|
@@ -37,6 +43,6 @@ export async function checkTheme(themeDir, options = {}) {
|
|
|
37
43
|
});
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
|
-
return { context, findings };
|
|
46
|
+
return { context, findings, vocabulary: { source, version: view.version } };
|
|
41
47
|
}
|
|
42
48
|
export const rejects = (findings) => findings.filter((finding) => finding.severity === 'reject');
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { VocabularyView } from './utils/business-vocabulary.js';
|
|
1
2
|
/**
|
|
2
3
|
* One definition of "is this theme valid", shared by three consumers: the
|
|
3
4
|
* author's `yarn theme:check`, this repo's CI, and (later) the pull/publish
|
|
@@ -108,6 +109,8 @@ export interface CheckEnv {
|
|
|
108
109
|
}
|
|
109
110
|
export interface ThemeContext {
|
|
110
111
|
env: CheckEnv;
|
|
112
|
+
/** The vocabulary the rules check `for` against. Set by checkTheme (bundled unless a vocabulary is passed); hand-built contexts fall back to bundled. */
|
|
113
|
+
vocabulary?: VocabularyView;
|
|
111
114
|
slug: string;
|
|
112
115
|
dir: string;
|
|
113
116
|
/** `active: false` in theme.config.ts — retired, exempt from publish gates. */
|
package/dist/types.js
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One definition of "is this theme valid", shared by three consumers: the
|
|
3
|
-
* author's `yarn theme:check`, this repo's CI, and (later) the pull/publish
|
|
4
|
-
* command. The moment validity has two implementations they drift, and an
|
|
5
|
-
* author gets a green locally and a rejection here.
|
|
6
|
-
*
|
|
7
|
-
* Rules therefore live in this library and the tests assert over it — not the
|
|
8
|
-
* other way round, which is where five of these gates started.
|
|
9
|
-
*/
|
|
10
1
|
export function finding(context, rule, severity, parts) {
|
|
11
2
|
return { rule, severity, theme: context.slug, ...parts };
|
|
12
3
|
}
|
|
@@ -1,7 +1,28 @@
|
|
|
1
|
+
import type { BusinessVocabularyData } from '../vocabulary.js';
|
|
2
|
+
/** One vocabulary copy as the rules read it: sets plus the helpers. */
|
|
3
|
+
export interface VocabularyView {
|
|
4
|
+
version: string;
|
|
5
|
+
services: readonly string[];
|
|
6
|
+
catalogue: Readonly<Record<string, readonly string[]>>;
|
|
7
|
+
subcategories: Readonly<Record<string, readonly string[]>>;
|
|
8
|
+
rootService: Readonly<Record<string, string>>;
|
|
9
|
+
labels: Readonly<Record<string, string>>;
|
|
10
|
+
businessKeys: ReadonlySet<string>;
|
|
11
|
+
isBusinessKey: (key: string) => boolean;
|
|
12
|
+
businessRoot: (key: string) => string;
|
|
13
|
+
}
|
|
14
|
+
/** Any vocabulary copy (resolved, bundled, pinned) as the rules read it. */
|
|
15
|
+
export declare function vocabularyViewOf(data: BusinessVocabularyData): VocabularyView;
|
|
16
|
+
/** The bundled snapshot as the rules read it: what `checkTheme` uses unless a vocabulary is passed. */
|
|
17
|
+
export declare const bundledVocabularyView: VocabularyView;
|
|
1
18
|
export declare const SERVICE_SLUGS: readonly string[];
|
|
2
19
|
export declare const CATALOGUE: Readonly<Record<string, readonly string[]>>;
|
|
3
20
|
/** A branch's own children, a third level (`bags-accessories` → `jewelry`). */
|
|
4
21
|
export declare const SUBCATEGORIES: Readonly<Record<string, readonly string[]>>;
|
|
22
|
+
/** A catalogue root's business category where they differ (`phones-tablets` → `phones-accessories`). */
|
|
23
|
+
export declare const ROOT_SERVICE: Readonly<Record<string, string>>;
|
|
24
|
+
/** The bundled snapshot's version (the synced S1 snapshot). */
|
|
25
|
+
export declare const BUNDLED_VERSION: string;
|
|
5
26
|
export declare const BUSINESS_KEYS: ReadonlySet<string>;
|
|
6
27
|
export declare function isBusinessKey(key: string): boolean;
|
|
7
28
|
/** A catalogue key's root (`wigs-extensions-hair-accessories` → `beauty-personal-care`, `jewelry` → `fashion`); any other key is its own. */
|
|
@@ -3,22 +3,66 @@
|
|
|
3
3
|
* the service slugs a vendor registers as, plus the marketplace catalogue's
|
|
4
4
|
* roots and branches — what tells a wig seller from a makeup seller when both
|
|
5
5
|
* register as beauty. Agreed with the backend; theme-check rejects any other key.
|
|
6
|
+
*
|
|
7
|
+
* The module-level sets describe the bundled snapshot (what offline callers
|
|
8
|
+
* and the storefront's offline tests check against). A resolved vocabulary —
|
|
9
|
+
* live, cache or file, via `resolveVocabulary()` — travels as a
|
|
10
|
+
* VocabularyView: `vocabularyViewOf(data)`, carried on the ThemeContext so
|
|
11
|
+
* rules never read the module-level import.
|
|
6
12
|
*/
|
|
7
13
|
import vocabulary from './business-vocabulary.json' with { type: 'json' };
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
function rootsOf(data) {
|
|
15
|
+
const roots = new Map();
|
|
16
|
+
for (const [root, branches] of Object.entries(data.catalogue)) {
|
|
17
|
+
roots.set(root, root);
|
|
18
|
+
for (const branch of branches)
|
|
19
|
+
roots.set(branch, root);
|
|
20
|
+
}
|
|
21
|
+
for (const [branch, children] of Object.entries(data.subcategories)) {
|
|
22
|
+
for (const child of children)
|
|
23
|
+
roots.set(child, roots.get(branch) ?? branch);
|
|
24
|
+
}
|
|
25
|
+
return roots;
|
|
26
|
+
}
|
|
27
|
+
/** Any vocabulary copy (resolved, bundled, pinned) as the rules read it. */
|
|
28
|
+
export function vocabularyViewOf(data) {
|
|
29
|
+
const roots = rootsOf(data);
|
|
30
|
+
const services = [...data.services];
|
|
31
|
+
const businessKeys = new Set([...services, ...roots.keys()]);
|
|
32
|
+
return {
|
|
33
|
+
version: data.version ?? 'unknown',
|
|
34
|
+
services,
|
|
35
|
+
catalogue: data.catalogue,
|
|
36
|
+
subcategories: data.subcategories,
|
|
37
|
+
rootService: data.root_service ?? {},
|
|
38
|
+
labels: data.labels ?? {},
|
|
39
|
+
businessKeys,
|
|
40
|
+
isBusinessKey: (key) => businessKeys.has(key),
|
|
41
|
+
businessRoot: (key) => roots.get(key) ?? key,
|
|
42
|
+
};
|
|
16
43
|
}
|
|
17
|
-
|
|
44
|
+
const BUNDLED = vocabularyViewOf({
|
|
45
|
+
version: vocabulary.version,
|
|
46
|
+
services: vocabulary.services,
|
|
47
|
+
catalogue: vocabulary.catalogue,
|
|
48
|
+
subcategories: vocabulary.subcategories,
|
|
49
|
+
root_service: vocabulary.root_service,
|
|
50
|
+
});
|
|
51
|
+
/** The bundled snapshot as the rules read it: what `checkTheme` uses unless a vocabulary is passed. */
|
|
52
|
+
export const bundledVocabularyView = BUNDLED;
|
|
53
|
+
export const SERVICE_SLUGS = BUNDLED.services;
|
|
54
|
+
export const CATALOGUE = BUNDLED.catalogue;
|
|
55
|
+
/** A branch's own children, a third level (`bags-accessories` → `jewelry`). */
|
|
56
|
+
export const SUBCATEGORIES = BUNDLED.subcategories;
|
|
57
|
+
/** A catalogue root's business category where they differ (`phones-tablets` → `phones-accessories`). */
|
|
58
|
+
export const ROOT_SERVICE = BUNDLED.rootService;
|
|
59
|
+
/** The bundled snapshot's version (the synced S1 snapshot). */
|
|
60
|
+
export const BUNDLED_VERSION = BUNDLED.version;
|
|
61
|
+
export const BUSINESS_KEYS = BUNDLED.businessKeys;
|
|
18
62
|
export function isBusinessKey(key) {
|
|
19
|
-
return
|
|
63
|
+
return BUNDLED.isBusinessKey(key);
|
|
20
64
|
}
|
|
21
65
|
/** A catalogue key's root (`wigs-extensions-hair-accessories` → `beauty-personal-care`, `jewelry` → `fashion`); any other key is its own. */
|
|
22
66
|
export function businessRoot(key) {
|
|
23
|
-
return
|
|
67
|
+
return BUNDLED.businessRoot(key);
|
|
24
68
|
}
|
|
@@ -1,24 +1,429 @@
|
|
|
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, 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
|
|
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 (storefront-theme-templates-contract.md, R2.1; jewelry R2.5; production snapshot R2.9); change it only together with the backend.",
|
|
3
|
+
"version": "d1f9c8eefc91ed3b2c834345458c62343f465ce6",
|
|
3
4
|
"services": [
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
5
|
+
"baby-kids",
|
|
6
|
+
"beauty-cosmetics",
|
|
7
|
+
"electronics",
|
|
8
|
+
"fashion",
|
|
9
|
+
"foods",
|
|
10
|
+
"gas-refill",
|
|
11
|
+
"groceries",
|
|
12
|
+
"health-wellness-store",
|
|
13
|
+
"home-living",
|
|
14
|
+
"laundry",
|
|
15
|
+
"office-school",
|
|
16
|
+
"pharmacy",
|
|
17
|
+
"phones-accessories",
|
|
18
|
+
"shop",
|
|
19
|
+
"shop-groceries",
|
|
20
|
+
"supermarket"
|
|
9
21
|
],
|
|
10
22
|
"catalogue": {
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
"baby-kids": [
|
|
24
|
+
"baby-clothing",
|
|
25
|
+
"baby-gear",
|
|
26
|
+
"diapering-potty",
|
|
27
|
+
"feeding-nursing",
|
|
28
|
+
"kids-clothing",
|
|
29
|
+
"nursery-safety",
|
|
30
|
+
"school-essentials",
|
|
31
|
+
"toys-games"
|
|
32
|
+
],
|
|
33
|
+
"beauty-personal-care": [
|
|
34
|
+
"beauty-tools-devices",
|
|
35
|
+
"fragrance",
|
|
36
|
+
"hair-care-styling",
|
|
37
|
+
"makeup",
|
|
38
|
+
"personal-care",
|
|
39
|
+
"skincare",
|
|
40
|
+
"wigs-extensions-hair-accessories"
|
|
41
|
+
],
|
|
42
|
+
"electronics": [
|
|
43
|
+
"cameras-drones",
|
|
44
|
+
"computers-laptops",
|
|
45
|
+
"gaming-consoles",
|
|
46
|
+
"power-solar",
|
|
47
|
+
"smart-home-security",
|
|
48
|
+
"tv-home-entertainment"
|
|
49
|
+
],
|
|
50
|
+
"fashion": [
|
|
51
|
+
"bags-accessories",
|
|
52
|
+
"kids-fashion",
|
|
53
|
+
"mens-fashion",
|
|
54
|
+
"modest-occasion-wear",
|
|
55
|
+
"shoes",
|
|
56
|
+
"womens-fashion"
|
|
57
|
+
],
|
|
58
|
+
"health-wellness": [
|
|
59
|
+
"fitness-recovery",
|
|
60
|
+
"health-devices",
|
|
61
|
+
"healthy-living",
|
|
62
|
+
"medical-supplies",
|
|
63
|
+
"personal-care-hygiene",
|
|
64
|
+
"sexual-wellness",
|
|
65
|
+
"vitamins-supplements"
|
|
66
|
+
],
|
|
67
|
+
"home-living": [
|
|
68
|
+
"bedding-bath",
|
|
69
|
+
"furniture",
|
|
70
|
+
"home-decor",
|
|
71
|
+
"kitchen-dining",
|
|
72
|
+
"lighting-electrical",
|
|
73
|
+
"storage-organization"
|
|
74
|
+
],
|
|
75
|
+
"office-school": [
|
|
76
|
+
"bags-lunch-gear",
|
|
77
|
+
"office-furniture",
|
|
78
|
+
"office-supplies",
|
|
79
|
+
"printers-accessories",
|
|
80
|
+
"school-supplies",
|
|
81
|
+
"stationery-writing",
|
|
82
|
+
"tech-for-school-office"
|
|
83
|
+
],
|
|
84
|
+
"phones-tablets": [
|
|
85
|
+
"cases-screen-protection",
|
|
86
|
+
"chargers-power-banks",
|
|
87
|
+
"earbuds-headsets",
|
|
88
|
+
"phone-repair-replacement",
|
|
89
|
+
"smartphones",
|
|
90
|
+
"smartwatches-wearables",
|
|
91
|
+
"tablets-e-readers"
|
|
92
|
+
]
|
|
20
93
|
},
|
|
21
94
|
"subcategories": {
|
|
22
|
-
"
|
|
95
|
+
"baby-clothing": [
|
|
96
|
+
"newborn-clothing",
|
|
97
|
+
"sets",
|
|
98
|
+
"sleepsuits",
|
|
99
|
+
"socks-caps"
|
|
100
|
+
],
|
|
101
|
+
"baby-gear": [
|
|
102
|
+
"car-seats",
|
|
103
|
+
"carriers",
|
|
104
|
+
"strollers",
|
|
105
|
+
"walkers"
|
|
106
|
+
],
|
|
107
|
+
"bags-accessories": [
|
|
108
|
+
"backpacks",
|
|
109
|
+
"handbags",
|
|
110
|
+
"jewelry",
|
|
111
|
+
"wallets",
|
|
112
|
+
"watches"
|
|
113
|
+
],
|
|
114
|
+
"bags-lunch-gear": [
|
|
115
|
+
"backpacks",
|
|
116
|
+
"lunch-bags",
|
|
117
|
+
"pencil-cases",
|
|
118
|
+
"water-bottles"
|
|
119
|
+
],
|
|
120
|
+
"beauty-tools-devices": [
|
|
121
|
+
"beauty-blenders",
|
|
122
|
+
"makeup-brushes",
|
|
123
|
+
"nail-tools",
|
|
124
|
+
"skincare-devices"
|
|
125
|
+
],
|
|
126
|
+
"bedding-bath": [
|
|
127
|
+
"bathroom-accessories",
|
|
128
|
+
"bedsheets",
|
|
129
|
+
"pillows-duvets",
|
|
130
|
+
"towels"
|
|
131
|
+
],
|
|
132
|
+
"cameras-drones": [
|
|
133
|
+
"cameras",
|
|
134
|
+
"drones",
|
|
135
|
+
"lenses",
|
|
136
|
+
"photography-accessories"
|
|
137
|
+
],
|
|
138
|
+
"cases-screen-protection": [
|
|
139
|
+
"phone-cases",
|
|
140
|
+
"screen-protectors",
|
|
141
|
+
"tablet-covers"
|
|
142
|
+
],
|
|
143
|
+
"chargers-power-banks": [
|
|
144
|
+
"cables",
|
|
145
|
+
"car-chargers",
|
|
146
|
+
"power-banks",
|
|
147
|
+
"wall-chargers",
|
|
148
|
+
"wireless-chargers"
|
|
149
|
+
],
|
|
150
|
+
"computers-laptops": [
|
|
151
|
+
"desktop-pcs",
|
|
152
|
+
"keyboards-mice",
|
|
153
|
+
"laptops",
|
|
154
|
+
"monitors",
|
|
155
|
+
"storage-devices"
|
|
156
|
+
],
|
|
157
|
+
"diapering-potty": [
|
|
158
|
+
"changing-accessories",
|
|
159
|
+
"diapers",
|
|
160
|
+
"potty-training",
|
|
161
|
+
"wipes"
|
|
162
|
+
],
|
|
163
|
+
"earbuds-headsets": [
|
|
164
|
+
"bluetooth-earbuds",
|
|
165
|
+
"headphones",
|
|
166
|
+
"phone-headsets",
|
|
167
|
+
"speakers"
|
|
168
|
+
],
|
|
169
|
+
"feeding-nursing": [
|
|
170
|
+
"bibs",
|
|
171
|
+
"bottles",
|
|
172
|
+
"breast-pumps",
|
|
173
|
+
"feeding-accessories"
|
|
174
|
+
],
|
|
175
|
+
"fitness-recovery": [
|
|
176
|
+
"exercise-equipment",
|
|
177
|
+
"massage-recovery",
|
|
178
|
+
"support-braces",
|
|
179
|
+
"yoga-pilates"
|
|
180
|
+
],
|
|
181
|
+
"fragrance": [
|
|
182
|
+
"body-mists",
|
|
183
|
+
"gift-sets",
|
|
184
|
+
"perfumes"
|
|
185
|
+
],
|
|
186
|
+
"furniture": [
|
|
187
|
+
"beds-mattresses",
|
|
188
|
+
"chairs-stools",
|
|
189
|
+
"sofas",
|
|
190
|
+
"tables-desks",
|
|
191
|
+
"wardrobes-storage"
|
|
192
|
+
],
|
|
193
|
+
"gaming-consoles": [
|
|
194
|
+
"consoles",
|
|
195
|
+
"gaming-accessories",
|
|
196
|
+
"gaming-chairs"
|
|
197
|
+
],
|
|
198
|
+
"hair-care-styling": [
|
|
199
|
+
"hair-tools",
|
|
200
|
+
"hair-treatments",
|
|
201
|
+
"shampoo-conditioner",
|
|
202
|
+
"styling-products"
|
|
203
|
+
],
|
|
204
|
+
"health-devices": [
|
|
205
|
+
"nebulizers",
|
|
206
|
+
"oximeters",
|
|
207
|
+
"posture-wellness-devices",
|
|
208
|
+
"weighing-scales"
|
|
209
|
+
],
|
|
210
|
+
"healthy-living": [
|
|
211
|
+
"digestive-health",
|
|
212
|
+
"healthy-snacks",
|
|
213
|
+
"herbal-products",
|
|
214
|
+
"sleep-support"
|
|
215
|
+
],
|
|
216
|
+
"home-decor": [
|
|
217
|
+
"decorative-accessories",
|
|
218
|
+
"mirrors",
|
|
219
|
+
"rugs-curtains",
|
|
220
|
+
"wall-art"
|
|
221
|
+
],
|
|
222
|
+
"kids-clothing": [
|
|
223
|
+
"boys-clothing",
|
|
224
|
+
"girls-clothing",
|
|
225
|
+
"school-wear"
|
|
226
|
+
],
|
|
227
|
+
"kids-fashion": [
|
|
228
|
+
"baby-clothing",
|
|
229
|
+
"boys-clothing",
|
|
230
|
+
"girls-clothing",
|
|
231
|
+
"school-wear"
|
|
232
|
+
],
|
|
233
|
+
"kitchen-dining": [
|
|
234
|
+
"cookware",
|
|
235
|
+
"dinnerware",
|
|
236
|
+
"kitchen-storage",
|
|
237
|
+
"small-kitchen-appliances"
|
|
238
|
+
],
|
|
239
|
+
"lighting-electrical": [
|
|
240
|
+
"ceiling-lights",
|
|
241
|
+
"electrical-fittings",
|
|
242
|
+
"lamps",
|
|
243
|
+
"outdoor-lighting"
|
|
244
|
+
],
|
|
245
|
+
"makeup": [
|
|
246
|
+
"eye-makeup",
|
|
247
|
+
"face-makeup",
|
|
248
|
+
"lip-makeup",
|
|
249
|
+
"makeup-sets"
|
|
250
|
+
],
|
|
251
|
+
"medical-supplies": [
|
|
252
|
+
"blood-pressure-monitors",
|
|
253
|
+
"first-aid",
|
|
254
|
+
"glucose-monitors",
|
|
255
|
+
"thermometers"
|
|
256
|
+
],
|
|
257
|
+
"mens-fashion": [
|
|
258
|
+
"native-wear",
|
|
259
|
+
"shirts",
|
|
260
|
+
"t-shirts-polos",
|
|
261
|
+
"trousers-jeans",
|
|
262
|
+
"underwear-sleepwear"
|
|
263
|
+
],
|
|
264
|
+
"modest-occasion-wear": [
|
|
265
|
+
"aso-ebi",
|
|
266
|
+
"bridal-occasion",
|
|
267
|
+
"kaftans-abayas",
|
|
268
|
+
"suits-blazers"
|
|
269
|
+
],
|
|
270
|
+
"nursery-safety": [
|
|
271
|
+
"baby-monitors",
|
|
272
|
+
"cots-cribs",
|
|
273
|
+
"nursery-bedding",
|
|
274
|
+
"safety-gates"
|
|
275
|
+
],
|
|
276
|
+
"office-furniture": [
|
|
277
|
+
"desks",
|
|
278
|
+
"office-chairs",
|
|
279
|
+
"shelves",
|
|
280
|
+
"whiteboards"
|
|
281
|
+
],
|
|
282
|
+
"office-supplies": [
|
|
283
|
+
"desk-accessories",
|
|
284
|
+
"filing-organization",
|
|
285
|
+
"laminating-binding",
|
|
286
|
+
"paper-products"
|
|
287
|
+
],
|
|
288
|
+
"personal-care": [
|
|
289
|
+
"bath-body",
|
|
290
|
+
"feminine-care",
|
|
291
|
+
"grooming",
|
|
292
|
+
"oral-care"
|
|
293
|
+
],
|
|
294
|
+
"personal-care-hygiene": [
|
|
295
|
+
"bath-body",
|
|
296
|
+
"deodorants",
|
|
297
|
+
"feminine-care",
|
|
298
|
+
"oral-care"
|
|
299
|
+
],
|
|
300
|
+
"phone-repair-replacement": [
|
|
301
|
+
"phone-batteries",
|
|
302
|
+
"repair-tools",
|
|
303
|
+
"replacement-screens"
|
|
304
|
+
],
|
|
305
|
+
"power-solar": [
|
|
306
|
+
"batteries-ups",
|
|
307
|
+
"extension-surge-protectors",
|
|
308
|
+
"inverters",
|
|
309
|
+
"solar-panels"
|
|
310
|
+
],
|
|
311
|
+
"printers-accessories": [
|
|
312
|
+
"ink-toner",
|
|
313
|
+
"printer-accessories",
|
|
314
|
+
"printers",
|
|
315
|
+
"printing-paper"
|
|
316
|
+
],
|
|
317
|
+
"school-essentials": [
|
|
318
|
+
"lunch-boxes",
|
|
319
|
+
"school-bags",
|
|
320
|
+
"stationery",
|
|
321
|
+
"water-bottles"
|
|
322
|
+
],
|
|
323
|
+
"school-supplies": [
|
|
324
|
+
"classroom-supplies",
|
|
325
|
+
"exam-essentials",
|
|
326
|
+
"exercise-books",
|
|
327
|
+
"geometry-sets"
|
|
328
|
+
],
|
|
329
|
+
"sexual-wellness": [
|
|
330
|
+
"condoms",
|
|
331
|
+
"intimate-care",
|
|
332
|
+
"lubricants",
|
|
333
|
+
"performance-support"
|
|
334
|
+
],
|
|
335
|
+
"shoes": [
|
|
336
|
+
"kids-shoes",
|
|
337
|
+
"men-shoes",
|
|
338
|
+
"slides-sandals",
|
|
339
|
+
"women-shoes"
|
|
340
|
+
],
|
|
341
|
+
"skincare": [
|
|
342
|
+
"cleansers",
|
|
343
|
+
"face-masks",
|
|
344
|
+
"moisturizers",
|
|
345
|
+
"serums",
|
|
346
|
+
"sunscreens"
|
|
347
|
+
],
|
|
348
|
+
"smart-home-security": [
|
|
349
|
+
"cctv-security",
|
|
350
|
+
"networking",
|
|
351
|
+
"smart-home-devices"
|
|
352
|
+
],
|
|
353
|
+
"smartphones": [
|
|
354
|
+
"android-phones",
|
|
355
|
+
"iphones",
|
|
356
|
+
"refurbished-phones"
|
|
357
|
+
],
|
|
358
|
+
"smartwatches-wearables": [
|
|
359
|
+
"fitness-bands",
|
|
360
|
+
"gps-wearables",
|
|
361
|
+
"smartwatches"
|
|
362
|
+
],
|
|
363
|
+
"stationery-writing": [
|
|
364
|
+
"art-supplies",
|
|
365
|
+
"markers-highlighters",
|
|
366
|
+
"notebooks",
|
|
367
|
+
"pens-pencils"
|
|
368
|
+
],
|
|
369
|
+
"storage-organization": [
|
|
370
|
+
"closet-organizers",
|
|
371
|
+
"laundry-storage",
|
|
372
|
+
"shelving",
|
|
373
|
+
"utility-storage"
|
|
374
|
+
],
|
|
375
|
+
"tablets-e-readers": [
|
|
376
|
+
"e-readers",
|
|
377
|
+
"kids-tablets",
|
|
378
|
+
"tablets"
|
|
379
|
+
],
|
|
380
|
+
"tech-for-school-office": [
|
|
381
|
+
"calculators",
|
|
382
|
+
"computer-accessories",
|
|
383
|
+
"laptops",
|
|
384
|
+
"projectors"
|
|
385
|
+
],
|
|
386
|
+
"toys-games": [
|
|
387
|
+
"board-games",
|
|
388
|
+
"dolls-action-figures",
|
|
389
|
+
"learning-toys",
|
|
390
|
+
"outdoor-toys"
|
|
391
|
+
],
|
|
392
|
+
"tv-home-entertainment": [
|
|
393
|
+
"home-audio",
|
|
394
|
+
"projectors",
|
|
395
|
+
"streaming-devices",
|
|
396
|
+
"televisions",
|
|
397
|
+
"tv-accessories"
|
|
398
|
+
],
|
|
399
|
+
"vitamins-supplements": [
|
|
400
|
+
"immunity-support",
|
|
401
|
+
"multivitamins",
|
|
402
|
+
"sports-nutrition",
|
|
403
|
+
"weight-management"
|
|
404
|
+
],
|
|
405
|
+
"wigs-extensions-hair-accessories": [
|
|
406
|
+
"extensions",
|
|
407
|
+
"hair-accessories",
|
|
408
|
+
"human-hair-wigs",
|
|
409
|
+
"synthetic-wigs",
|
|
410
|
+
"wigs"
|
|
411
|
+
],
|
|
412
|
+
"womens-fashion": [
|
|
413
|
+
"dresses",
|
|
414
|
+
"lingerie-sleepwear",
|
|
415
|
+
"skirts-shorts",
|
|
416
|
+
"tops-tees"
|
|
417
|
+
]
|
|
418
|
+
},
|
|
419
|
+
"root_service": {
|
|
420
|
+
"baby-kids": "baby-kids",
|
|
421
|
+
"beauty-personal-care": "beauty-cosmetics",
|
|
422
|
+
"electronics": "electronics",
|
|
423
|
+
"fashion": "fashion",
|
|
424
|
+
"health-wellness": "health-wellness-store",
|
|
425
|
+
"home-living": "home-living",
|
|
426
|
+
"office-school": "office-school",
|
|
427
|
+
"phones-tablets": "phones-accessories"
|
|
23
428
|
}
|
|
24
429
|
}
|
|
@@ -93,10 +93,19 @@ const NAIRA_AMOUNT = [
|
|
|
93
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
94
|
/\d[\d,.]*\s?k?\s?naira\b/i,
|
|
95
95
|
];
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* A discount or a coupon code: "Save 20% with code RAINS20", "Up to 25% off".
|
|
98
|
+
* "Half-price" and a bare "sale" are offers too ("the clearance sale is on",
|
|
99
|
+
* "shop the sale", "sale ends Sunday") — but only as their own word, so
|
|
100
|
+
* "wholesale" and "salesperson" are not, and never the trade phrase "for sale
|
|
101
|
+
* by the kilo". "Discount" and "coupon" alone name an offer no store can keep.
|
|
102
|
+
*/
|
|
97
103
|
const OFFER = [
|
|
98
104
|
/\b(?:code|coupon|promo(?: code)?)\s*:?\s*[A-Z][A-Z0-9]{3,}\b/,
|
|
99
105
|
/\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,
|
|
106
|
+
/\bhalf[\s-]price\b/i,
|
|
107
|
+
/\b(?<!for )sale\b(?! by the kilo)/i,
|
|
108
|
+
/\b(?:coupon|discount)s?\b/i,
|
|
100
109
|
];
|
|
101
110
|
/**
|
|
102
111
|
* Opening hours: "Open daily", "11am–10pm", "Doors open 11am", "from 6 AM",
|
|
@@ -109,18 +118,23 @@ const HOURS = new RegExp([
|
|
|
109
118
|
String.raw `\b\d{1,2}(?::\d{2})?\s?(?:am|pm)?\s?(?:–|-|to)\s?${TIME}`,
|
|
110
119
|
String.raw `\b(?:opens?|opening|doors|from|till|until|last\s+orders|closes?|closing)\s+(?:at\s+)?${TIME}`,
|
|
111
120
|
String.raw `\b(?:[01]?\d|2[0-3]):[0-5]\d\s?(?:–|-|to)\s?(?:[01]?\d|2[0-3]):[0-5]\d\b`,
|
|
121
|
+
// a weekday at a clock time is the store's schedule: "New drop every Friday, 7pm"
|
|
122
|
+
String.raw `\b(?:mon|tues|wednes|thurs|fri|satur|sun)days?\b,?\s+(?:at\s+)?${TIME}`,
|
|
112
123
|
].join('|'), 'i');
|
|
113
124
|
/**
|
|
114
125
|
* Commitments only the vendor can make. A time window is a promise only next
|
|
115
126
|
* to a service — delivered, ships, answered, fitted, returned — so a recipe's
|
|
116
127
|
* or a process's time ("marinated 24–48 hours", "ready in 3 minutes") is not.
|
|
117
128
|
*/
|
|
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))`;
|
|
129
|
+
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*|measur\w*|tailor\w*|alter(?:s|ed|ing|ations?)?)`;
|
|
130
|
+
const WINDOW = String.raw `(?:(?:within|in|under)\s+(?:about\s+)?(?:\d+|an?|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|fourteen|fifteen|twenty)[\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
131
|
const PROMISE = [
|
|
121
132
|
new RegExp(String.raw `\b${SERVICE}\b[^.!?\n]{0,40}?${WINDOW}\b|${WINDOW}\b[^.!?\n]{0,40}?\b${SERVICE}\b`, 'i'),
|
|
122
133
|
/\b\d+[\s-](?:minute|min|hour|hr|day|week)s?\s+(?:delivery|dispatch|shipping|turnaround|returns?|exchanges?|refunds?|adjustments?|service)\b/i,
|
|
123
|
-
/\
|
|
134
|
+
/\b(?:free|complimentary)\s+(?:\w+\s+)?(?:delivery|shipping|returns?|pick[- ]?up|collection|alterations?|installation|install|resizing|exchanges?|samples?|styling|gifts?|consultations?|fittings?|refills?)\b/i,
|
|
135
|
+
// a service promised without end, or a reply promised fast
|
|
136
|
+
/\b(?:returns?|exchanges?|refunds?|delivery|shipping)\b[^.!?\n|]{0,20}\balways\b/i,
|
|
137
|
+
/\b(?:answer\w*|repl(?:y|ies|ied)|respond\w*)\s+(?:fast|quickly|promptly|right away)\b|\b(?:fast|quick|prompt)\s+(?:repl(?:y|ies)|answers?|responses?)\b/i,
|
|
124
138
|
/\b(?:guarantee[ds]?|money[- ]back|warrant(?:y|ies)|no questions asked)\b/i,
|
|
125
139
|
];
|
|
126
140
|
/** The store's history, not the business's: "since 2014", "started in 2019", "six years, two stores". */
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Where the live vocabulary is fetched from. */
|
|
2
|
+
export declare const VOCABULARY_URL = "https://api.usequeek.com/api/v1/client/theme-vocabulary";
|
|
3
|
+
/** A cached copy younger than this is used without a network request (the endpoint sends `Cache-Control: public, max-age=3600`). */
|
|
4
|
+
export declare const VOCABULARY_CACHE_TTL_MS = 3600000;
|
|
5
|
+
export type VocabularySource = 'live' | 'cache' | 'bundled' | 'file';
|
|
6
|
+
/** The vocabulary itself: the endpoint's `data`, the bundled JSON, or a pinned file. */
|
|
7
|
+
export interface BusinessVocabularyData {
|
|
8
|
+
version?: string;
|
|
9
|
+
services: string[];
|
|
10
|
+
catalogue: Record<string, string[]>;
|
|
11
|
+
subcategories: Record<string, string[]>;
|
|
12
|
+
root_service?: Record<string, string>;
|
|
13
|
+
labels?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
export interface ResolveVocabularyOptions {
|
|
16
|
+
/** `live` (default) refreshes through the cache; `offline` uses the bundled snapshot, no network. */
|
|
17
|
+
mode?: 'live' | 'offline';
|
|
18
|
+
/** A pinned vocabulary file: wins over everything, and is validated. */
|
|
19
|
+
file?: string;
|
|
20
|
+
/** Ignore a fresh cache, but still revalidate with `If-None-Match`. Queek's submission step uses it. */
|
|
21
|
+
force?: boolean;
|
|
22
|
+
/** Fetch timeout in ms. A timeout falls back like any network error. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
/** Injectable fetch, for tests. Defaults to the global fetch at call time. */
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
/** Injectable cache dir, for tests. Defaults to vocabularyCacheDir(). */
|
|
27
|
+
cacheDir?: string;
|
|
28
|
+
/** Injectable clock, for tests. Defaults to Date.now(). */
|
|
29
|
+
now?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface ResolvedVocabulary {
|
|
32
|
+
vocabulary: BusinessVocabularyData;
|
|
33
|
+
source: VocabularySource;
|
|
34
|
+
version: string;
|
|
35
|
+
/** One line explaining a fallback (cache/bundled used because live failed). Absent on a clean resolve. */
|
|
36
|
+
notice?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The vocabulary's shape, strictly: non-empty service slugs, catalogue roots
|
|
40
|
+
* to branch lists, branches to children. `version` is required when it must
|
|
41
|
+
* identify the copy (endpoint payloads, the cache); a pinned file may omit it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseVocabularyData(value: unknown, options: {
|
|
44
|
+
requireVersion: boolean;
|
|
45
|
+
}): BusinessVocabularyData;
|
|
46
|
+
/** The endpoint's `{ status, data }` envelope, unwrapped and strictly validated. */
|
|
47
|
+
export declare function parseEndpointPayload(value: unknown): BusinessVocabularyData;
|
|
48
|
+
/** The bundled snapshot, as data. Its version comes from the synced file. */
|
|
49
|
+
export declare function bundledVocabulary(): BusinessVocabularyData;
|
|
50
|
+
/** The bundled snapshot's version (the synced S1 snapshot). */
|
|
51
|
+
export declare function bundledVersion(): string;
|
|
52
|
+
export declare function vocabularyCacheDir(options?: {
|
|
53
|
+
env?: NodeJS.ProcessEnv;
|
|
54
|
+
platform?: NodeJS.Platform;
|
|
55
|
+
}): string;
|
|
56
|
+
/** The cached copy, synchronously, for prompts that must ask before any fetch returns. Null when absent or invalid. */
|
|
57
|
+
export declare function readCachedVocabulary(cacheDir?: string): BusinessVocabularyData | null;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the vocabulary regulators and prompts check against.
|
|
60
|
+
*
|
|
61
|
+
* - `file` wins, and is validated (an unreadable or invalid file throws).
|
|
62
|
+
* - `offline` uses the bundled snapshot, with no network and no cache read.
|
|
63
|
+
* - `live` uses a fresh cache as-is, else a conditional GET with
|
|
64
|
+
* `If-None-Match: "<cached version>"`: 304 keeps the cache, 200 validates,
|
|
65
|
+
* caches atomically, and uses the fresh copy.
|
|
66
|
+
* - A network error, timeout, unexpected status or bad payload falls back to
|
|
67
|
+
* the cache, else the bundled snapshot, with a one-line `notice`.
|
|
68
|
+
* - `force` skips the fresh-cache shortcut but still sends `If-None-Match`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function resolveVocabulary(options?: ResolveVocabularyOptions): Promise<ResolvedVocabulary>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live business vocabulary: one resolver, one cache, shared by
|
|
3
|
+
* theme-check, theme-cli and create-theme.
|
|
4
|
+
*
|
|
5
|
+
* Production serves `{ status, data }` at VOCABULARY_URL, where `data` holds
|
|
6
|
+
* the vocabulary (`version`, `services`, `catalogue`, `subcategories`,
|
|
7
|
+
* `root_service`, `labels`). The cache lives under vocabularyCacheDir() and
|
|
8
|
+
* holds the last good `data` with its version. Reads never throw for `live`
|
|
9
|
+
* (they fall back to the cache, then the bundled snapshot, with a one-line
|
|
10
|
+
* notice); only an explicitly pinned `file` that cannot be read or validated
|
|
11
|
+
* throws.
|
|
12
|
+
*
|
|
13
|
+
* Production weakens the ETag to `W/"<v>-br"` (backend 216e0df3 pending), so
|
|
14
|
+
* the conditional GET is built from the stored version, never the raw header:
|
|
15
|
+
* `If-None-Match: "<version>"` (quoted, no `W/`, no suffix).
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import bundled from './utils/business-vocabulary.json' with { type: 'json' };
|
|
21
|
+
/** Where the live vocabulary is fetched from. */
|
|
22
|
+
export const VOCABULARY_URL = 'https://api.usequeek.com/api/v1/client/theme-vocabulary';
|
|
23
|
+
/** A cached copy younger than this is used without a network request (the endpoint sends `Cache-Control: public, max-age=3600`). */
|
|
24
|
+
export const VOCABULARY_CACHE_TTL_MS = 3_600_000;
|
|
25
|
+
/** The file in the cache dir holding the last good vocabulary. */
|
|
26
|
+
const CACHE_FILE = 'vocabulary.json';
|
|
27
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
28
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
29
|
+
const isStringArrayMap = (value) => isRecord(value) && Object.values(value).every(isStringArray);
|
|
30
|
+
const isStringMap = (value) => isRecord(value) && Object.values(value).every((item) => typeof item === 'string');
|
|
31
|
+
/**
|
|
32
|
+
* The vocabulary's shape, strictly: non-empty service slugs, catalogue roots
|
|
33
|
+
* to branch lists, branches to children. `version` is required when it must
|
|
34
|
+
* identify the copy (endpoint payloads, the cache); a pinned file may omit it.
|
|
35
|
+
*/
|
|
36
|
+
export function parseVocabularyData(value, options) {
|
|
37
|
+
if (!isRecord(value))
|
|
38
|
+
throw new Error('the vocabulary is not an object');
|
|
39
|
+
const { version, services, catalogue, subcategories, root_service: rootService, labels } = value;
|
|
40
|
+
if (options.requireVersion && (typeof version !== 'string' || version.length === 0)) {
|
|
41
|
+
throw new Error('the vocabulary has no version');
|
|
42
|
+
}
|
|
43
|
+
if (version !== undefined && typeof version !== 'string')
|
|
44
|
+
throw new Error('the vocabulary version is not a string');
|
|
45
|
+
if (!isStringArray(services) || services.length === 0 || services.some((slug) => slug.length === 0)) {
|
|
46
|
+
throw new Error('the vocabulary has no services list');
|
|
47
|
+
}
|
|
48
|
+
if (!isStringArrayMap(catalogue))
|
|
49
|
+
throw new Error('the vocabulary has no catalogue map');
|
|
50
|
+
if (!isStringArrayMap(subcategories))
|
|
51
|
+
throw new Error('the vocabulary has no subcategories map');
|
|
52
|
+
if (rootService !== undefined && !isStringMap(rootService))
|
|
53
|
+
throw new Error('the vocabulary root_service is not a map');
|
|
54
|
+
if (labels !== undefined && !isStringMap(labels))
|
|
55
|
+
throw new Error('the vocabulary labels are not a map');
|
|
56
|
+
return { version, services, catalogue, subcategories, root_service: rootService, labels };
|
|
57
|
+
}
|
|
58
|
+
/** The endpoint's `{ status, data }` envelope, unwrapped and strictly validated. */
|
|
59
|
+
export function parseEndpointPayload(value) {
|
|
60
|
+
if (!isRecord(value) || !isRecord(value.data))
|
|
61
|
+
throw new Error('the vocabulary endpoint did not return { status, data }');
|
|
62
|
+
return parseVocabularyData(value.data, { requireVersion: true });
|
|
63
|
+
}
|
|
64
|
+
/** The bundled snapshot, as data. Its version comes from the synced file. */
|
|
65
|
+
export function bundledVocabulary() {
|
|
66
|
+
return parseVocabularyData(bundled, { requireVersion: true });
|
|
67
|
+
}
|
|
68
|
+
/** The bundled snapshot's version (the synced S1 snapshot). */
|
|
69
|
+
export function bundledVersion() {
|
|
70
|
+
return bundled.version;
|
|
71
|
+
}
|
|
72
|
+
export function vocabularyCacheDir(options = {}) {
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const platform = options.platform ?? process.platform;
|
|
75
|
+
if (platform === 'win32') {
|
|
76
|
+
const base = env.LOCALAPPDATA;
|
|
77
|
+
if (typeof base === 'string' && base.length > 0)
|
|
78
|
+
return join(base, 'usequeek');
|
|
79
|
+
}
|
|
80
|
+
if (platform === 'darwin')
|
|
81
|
+
return join(homedir(), 'Library', 'Caches', 'usequeek');
|
|
82
|
+
const xdg = env.XDG_CACHE_HOME;
|
|
83
|
+
if (typeof xdg === 'string' && xdg.length > 0)
|
|
84
|
+
return join(xdg, 'usequeek');
|
|
85
|
+
return join(homedir(), '.cache', 'usequeek');
|
|
86
|
+
}
|
|
87
|
+
function readCache(dir) {
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = readFileSync(join(dir, CACHE_FILE), 'utf8');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
// The envelope this resolver writes; a raw vocabulary (hand-placed) is
|
|
98
|
+
// accepted too, but counts as stale so it is revalidated.
|
|
99
|
+
if (isRecord(parsed) && typeof parsed.fetchedAt === 'number' && parsed.vocabulary !== undefined) {
|
|
100
|
+
const vocabulary = parseVocabularyData(parsed.vocabulary, { requireVersion: true });
|
|
101
|
+
if (typeof parsed.version !== 'string' || parsed.version !== vocabulary.version)
|
|
102
|
+
return null;
|
|
103
|
+
return { version: vocabulary.version, fetchedAt: parsed.fetchedAt, vocabulary };
|
|
104
|
+
}
|
|
105
|
+
const vocabulary = parseVocabularyData(parsed, { requireVersion: true });
|
|
106
|
+
return { version: vocabulary.version, fetchedAt: 0, vocabulary };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** The cached copy, synchronously, for prompts that must ask before any fetch returns. Null when absent or invalid. */
|
|
113
|
+
export function readCachedVocabulary(cacheDir) {
|
|
114
|
+
try {
|
|
115
|
+
return readCache(cacheDir ?? vocabularyCacheDir())?.vocabulary ?? null;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Write the cache atomically (temp file + rename), so a crash never leaves half a vocabulary. */
|
|
122
|
+
function writeCache(dir, vocabulary, now) {
|
|
123
|
+
mkdirSync(dir, { recursive: true });
|
|
124
|
+
const entry = { version: vocabulary.version, fetchedAt: now, vocabulary };
|
|
125
|
+
const staging = join(dir, `.${CACHE_FILE}.${process.pid}.tmp`);
|
|
126
|
+
writeFileSync(staging, `${JSON.stringify(entry, null, 2)}\n`);
|
|
127
|
+
renameSync(staging, join(dir, CACHE_FILE));
|
|
128
|
+
}
|
|
129
|
+
/** Fetch with a timeout that also aborts the request (an injected fake that ignores the signal still loses the race). */
|
|
130
|
+
async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
let timer;
|
|
133
|
+
try {
|
|
134
|
+
const result = await Promise.race([
|
|
135
|
+
fetchImpl(url, { ...init, signal: controller.signal }),
|
|
136
|
+
new Promise((_, reject) => {
|
|
137
|
+
timer = setTimeout(() => {
|
|
138
|
+
controller.abort();
|
|
139
|
+
reject(new Error(`timed out after ${timeoutMs}ms`));
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
}),
|
|
142
|
+
]);
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const cacheNotice = (version, reason) => `theme vocabulary: using the cached copy (version ${version}) — live refresh failed (${reason}).`;
|
|
150
|
+
const bundledNotice = (reason) => `theme vocabulary: using the bundled copy (version ${bundledVersion()}) — ${reason}.`;
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the vocabulary regulators and prompts check against.
|
|
153
|
+
*
|
|
154
|
+
* - `file` wins, and is validated (an unreadable or invalid file throws).
|
|
155
|
+
* - `offline` uses the bundled snapshot, with no network and no cache read.
|
|
156
|
+
* - `live` uses a fresh cache as-is, else a conditional GET with
|
|
157
|
+
* `If-None-Match: "<cached version>"`: 304 keeps the cache, 200 validates,
|
|
158
|
+
* caches atomically, and uses the fresh copy.
|
|
159
|
+
* - A network error, timeout, unexpected status or bad payload falls back to
|
|
160
|
+
* the cache, else the bundled snapshot, with a one-line `notice`.
|
|
161
|
+
* - `force` skips the fresh-cache shortcut but still sends `If-None-Match`.
|
|
162
|
+
*/
|
|
163
|
+
export async function resolveVocabulary(options = {}) {
|
|
164
|
+
const { mode = 'live', file, force = false, timeoutMs = 4000, now = Date.now() } = options;
|
|
165
|
+
if (file !== undefined) {
|
|
166
|
+
let raw;
|
|
167
|
+
try {
|
|
168
|
+
raw = readFileSync(resolve(file), 'utf8');
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw new Error(`cannot read --vocabulary ${file} (${error.message})`);
|
|
172
|
+
}
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(raw);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new Error(`--vocabulary ${file} is not valid JSON`);
|
|
179
|
+
}
|
|
180
|
+
const vocabulary = isRecord(parsed) && parsed.data !== undefined
|
|
181
|
+
? parseEndpointPayload(parsed)
|
|
182
|
+
: parseVocabularyData(parsed, { requireVersion: false });
|
|
183
|
+
return { vocabulary, source: 'file', version: vocabulary.version ?? 'unknown' };
|
|
184
|
+
}
|
|
185
|
+
if (mode === 'offline') {
|
|
186
|
+
const vocabulary = bundledVocabulary();
|
|
187
|
+
return { vocabulary, source: 'bundled', version: vocabulary.version };
|
|
188
|
+
}
|
|
189
|
+
const dir = options.cacheDir ?? vocabularyCacheDir();
|
|
190
|
+
const cached = readCache(dir);
|
|
191
|
+
if (cached && !force && now - cached.fetchedAt < VOCABULARY_CACHE_TTL_MS) {
|
|
192
|
+
return { vocabulary: cached.vocabulary, source: 'cache', version: cached.version };
|
|
193
|
+
}
|
|
194
|
+
const fetchImpl = (options.fetch ?? globalThis.fetch);
|
|
195
|
+
const headers = { Accept: 'application/json' };
|
|
196
|
+
if (cached)
|
|
197
|
+
headers['If-None-Match'] = `"${cached.version}"`;
|
|
198
|
+
const fallback = (reason) => {
|
|
199
|
+
if (cached)
|
|
200
|
+
return { vocabulary: cached.vocabulary, source: 'cache', version: cached.version, notice: cacheNotice(cached.version, reason) };
|
|
201
|
+
const vocabulary = bundledVocabulary();
|
|
202
|
+
return { vocabulary, source: 'bundled', version: vocabulary.version, notice: bundledNotice(reason) };
|
|
203
|
+
};
|
|
204
|
+
let response;
|
|
205
|
+
try {
|
|
206
|
+
response = await fetchWithTimeout(fetchImpl, VOCABULARY_URL, { headers }, timeoutMs);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
return fallback(error instanceof Error ? error.message : String(error));
|
|
210
|
+
}
|
|
211
|
+
if (response.status === 304) {
|
|
212
|
+
if (!cached) {
|
|
213
|
+
// No copy to keep: a 304 with nothing cached is a server surprise, not a vocabulary.
|
|
214
|
+
const vocabulary = bundledVocabulary();
|
|
215
|
+
return { vocabulary, source: 'bundled', version: vocabulary.version, notice: bundledNotice('the server sent 304 with nothing cached') };
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
writeCache(dir, cached.vocabulary, now);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// The copy in hand is what matters; a timestamp refresh that fails is not a failure.
|
|
222
|
+
}
|
|
223
|
+
return { vocabulary: cached.vocabulary, source: 'cache', version: cached.version };
|
|
224
|
+
}
|
|
225
|
+
if (response.status !== 200) {
|
|
226
|
+
return fallback(`the server sent ${response.status}`);
|
|
227
|
+
}
|
|
228
|
+
let payload;
|
|
229
|
+
try {
|
|
230
|
+
payload = await response.json();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return fallback('the response was not JSON');
|
|
234
|
+
}
|
|
235
|
+
let vocabulary;
|
|
236
|
+
try {
|
|
237
|
+
vocabulary = parseEndpointPayload(payload);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
return fallback(`the payload was invalid (${error.message})`);
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
writeCache(dir, vocabulary, now);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
return { vocabulary, source: 'live', version: vocabulary.version, notice: `theme vocabulary: fetched live (version ${vocabulary.version}) but the cache could not be written (${error.message}).` };
|
|
247
|
+
}
|
|
248
|
+
return { vocabulary, source: 'live', version: vocabulary.version };
|
|
249
|
+
}
|