@usequeek/theme-check 0.3.7 → 0.4.1

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 CHANGED
@@ -68,6 +68,7 @@ export async function loadContext(themeDir, env = {}) {
68
68
  retired: config?.active === false,
69
69
  demo: demos.find((store) => store.id === 'default')?.data ?? null,
70
70
  demos,
71
+ themeConfig: config,
71
72
  declaredDemos: config === null ? null : Array.isArray(declared) ? declared : [],
72
73
  defaultDescription: typeof primary?.description === 'string' ? primary.description : null,
73
74
  defaultFor: primary?.for ?? null,
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export type { CheckEnv, Finding, Rule, Severity, ThemeContext, DemoStore, Declar
10
10
  export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
11
11
  export { TEMPLATE_COPY_PLACES, copyViolations, isTestimonialSection, storeNameForms } from './utils/template-copy.js';
12
12
  export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
13
+ export { designsOf, groupTemplates, mainTemplateKey, composeLabel, type DesignDeclaration, type ThemeDesignsConfig, type ThemeDesign, type ThemeTemplate, } from './utils/theme-designs.js';
13
14
  export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, declaredFieldsByVariant } from './utils/theme-templates.js';
14
- export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, frameworkImport, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.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';
15
16
  export { ANALYSIS_RULES, variantParityRule, fieldParityRule, designTokensRule } from './rules/analysis.js';
package/dist/index.js CHANGED
@@ -9,9 +9,12 @@ export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fil
9
9
  export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
10
10
  export { TEMPLATE_COPY_PLACES, copyViolations, isTestimonialSection, storeNameForms } from './utils/template-copy.js';
11
11
  export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
12
+ // Theme → template → design (contract R2.8): the resolver the registry, the
13
+ // preview and these rules share. Also published alone as `@usequeek/theme-check/designs`.
14
+ export { designsOf, groupTemplates, mainTemplateKey, composeLabel, } from './utils/theme-designs.js';
12
15
  export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, declaredFieldsByVariant } from './utils/theme-templates.js';
13
16
  // Per-kind rule arrays and the individual rule constants, for rule-level unit
14
17
  // tests that want to run one rule against a hand-built ThemeContext instead
15
18
  // of a whole theme directory through checkTheme().
16
- export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, frameworkImport, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
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';
17
20
  export { ANALYSIS_RULES, variantParityRule, fieldParityRule, designTokensRule } from './rules/analysis.js';
@@ -41,7 +41,16 @@ export declare const templateChromeRule: Rule;
41
41
  export declare const templateStyleRule: Rule;
42
42
  export declare const templateBusinessRule: Rule;
43
43
  export declare const templateVersionsRule: Rule;
44
- /** The designed pages every template ships (contract R2.3); versions `-2`… count. */
44
+ /** A template has at most this many designs (contract R2.2). */
45
+ export declare const TEMPLATE_DESIGNS_MAX = 3;
46
+ /**
47
+ * Theme → template → design (contract R2.8). Designs are grouped by the
48
+ * `template` key each one declares, exactly as the registry and the preview
49
+ * group them (utils/theme-designs.ts). The resolver is lenient, so a config
50
+ * written before R2.8 still previews; this rule is where it is strict.
51
+ */
52
+ export declare const templateDesignsRule: Rule;
53
+ /** The designed pages every template ships (contract R2.3); page versions `-2`… count. */
45
54
  export declare const TEMPLATE_PAGES: readonly ["about", "sales", "landing"];
46
55
  export declare const templatePagesRule: Rule;
47
56
  export declare const templateCopyRule: Rule;
@@ -2,6 +2,7 @@ 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 { designsOf, groupTemplates, mainTemplateKey } from '../utils/theme-designs.js';
5
6
  import { SCREENSHOT_LOCK, TEMPLATE_DESCRIPTION_MAX, TEMPLATE_DESCRIPTION_PLACEHOLDER, declaredFieldsByVariant, isPresentationalField, screenshotFile, screenshotUrl, sectionCopy } from '../utils/theme-templates.js';
6
7
  import { copyViolations, isTestimonialSection } from '../utils/template-copy.js';
7
8
  import { BUSINESS_KEYS, SERVICE_SLUGS, isBusinessKey } from '../utils/business-vocabulary.js';
@@ -66,6 +67,26 @@ function storesOf(context) {
66
67
  stores.unshift({ id: PRIMARY_DEMO_ID, file: 'demo.json', data: null });
67
68
  return stores;
68
69
  }
70
+ /**
71
+ * theme.config.ts as the design resolver reads it (contract R2.8): its own
72
+ * fields, with `demos` as the context declares them. Never null, so a config
73
+ * that did not load resolves to demo.json alone.
74
+ */
75
+ function designsConfig(context) {
76
+ const config = context.themeConfig !== null && typeof context.themeConfig === 'object' ? context.themeConfig : {};
77
+ return { ...config, demos: (context.declaredDemos ?? []) };
78
+ }
79
+ /** Every design, grouped by its explicit `template` exactly as the registry groups it, in declaration order. */
80
+ function designsOfContext(context) {
81
+ return designsOf(designsConfig(context));
82
+ }
83
+ /** A design's declaration as written: `default_demo`, or its (first) `demos[]` entry. */
84
+ function declarationOf(context, id) {
85
+ const config = designsConfig(context);
86
+ if (id === PRIMARY_DEMO_ID)
87
+ return config.default_demo ?? {};
88
+ return config.demos?.find((demo) => demo?.id === id) ?? {};
89
+ }
69
90
  export const demoStoreRule = {
70
91
  id: 'theme/demo-store',
71
92
  summary: 'Every demo store is a believable store that identifies itself as this theme',
@@ -139,6 +160,9 @@ export const demoStoresRule = {
139
160
  };
140
161
  const declared = context.declaredDemos ?? [];
141
162
  const secondaries = context.demos.filter((store) => store.id !== PRIMARY_DEMO_ID);
163
+ // `label` and `for` are the template's, declared once on its design 1; a
164
+ // later design inherits them (theme/template-designs checks a repeat).
165
+ const firsts = new Set(designsOfContext(context).filter((design) => design.designIndex === 1).map((design) => design.id));
142
166
  for (const demo of declared) {
143
167
  const id = String(demo?.id ?? '');
144
168
  if (id === PRIMARY_DEMO_ID) {
@@ -152,6 +176,8 @@ export const demoStoresRule = {
152
176
  if (!secondaries.some((store) => store.id === id)) {
153
177
  add(at, `demos[] declares "${id}" but there is no file ${context.env.root}demos/${id}.json`, 'Add the store, or drop the declaration. The registry advertises a preview URL for every declared store.');
154
178
  }
179
+ if (!firsts.has(id))
180
+ continue;
155
181
  if (typeof demo.label !== 'string' || demo.label.trim() === '') {
156
182
  add(at, `demos[] "${id}" has no label`, 'Give it the name a merchant sees, e.g. "Restaurant & takeaway".');
157
183
  }
@@ -169,7 +195,7 @@ export const demoStoresRule = {
169
195
  continue;
170
196
  }
171
197
  if (!declared.some((demo) => demo?.id === store.id)) {
172
- add(`${context.env.root}${store.file}`, `demos/${store.id}.json is not declared in theme.config.ts`, `Add \`{ id: '${store.id}', label, for: [...] }\` to \`demos\` in theme.config.ts, or remove the file. An undeclared store is previewable but never offered to anyone.`);
198
+ add(`${context.env.root}${store.file}`, `demos/${store.id}.json is not declared in theme.config.ts`, `Add \`{ id: '${store.id}', template, label, for, description }\` to \`demos\` in theme.config.ts, or remove the file. An undeclared store is previewable but never offered to anyone.`);
173
199
  }
174
200
  }
175
201
  const seen = new Map();
@@ -835,9 +861,12 @@ export const templateBusinessRule = {
835
861
  if (context.retired || context.declaredDemos === null)
836
862
  return [];
837
863
  const config = `${context.env.root}theme.config.ts`;
864
+ // Each template once, by its design 1: `for` is the template's, and a later
865
+ // design that repeats it is theme/template-designs' to compare.
866
+ const firsts = new Set(designsOfContext(context).filter((design) => design.designIndex === 1).map((design) => design.id));
838
867
  const templates = [
839
868
  { id: PRIMARY_DEMO_ID, keys: context.defaultFor, where: `${config} → default_demo.for` },
840
- ...context.declaredDemos.map((demo) => ({ id: demo.id, keys: demo.for, where: `${config} → demos[${demo.id}].for` })),
869
+ ...context.declaredDemos.filter((demo) => firsts.has(demo.id)).map((demo) => ({ id: demo.id, keys: demo.for, where: `${config} → demos[${demo.id}].for` })),
841
870
  ];
842
871
  const findings = [];
843
872
  for (const { id, keys, where } of templates) {
@@ -880,14 +909,20 @@ export const templateBusinessRule = {
880
909
  return findings;
881
910
  },
882
911
  };
883
- /** `food-2` → `food`; null for an id that is not a numbered version. */
884
- function versionBase(id) {
885
- const match = /^(.+)-([2-9])$/.exec(id);
912
+ /**
913
+ * A page slug's kind: `about-2` → `about`; null for a slug with no `-N`
914
+ * suffix. Only page slugs are numbered (R2.3). Designs are grouped by their
915
+ * explicit `template` (R2.8), never by an id's suffix.
916
+ */
917
+ function pageBase(slug) {
918
+ const match = /^(.+)-([2-9])$/.exec(slug);
886
919
  return match ? match[1] : null;
887
920
  }
921
+ // Keeps its id (`theme/template-versions` is API: tools and to-do lists name
922
+ // it); the R2.8 grouping checks are theme/template-designs'.
888
923
  export const templateVersionsRule = {
889
924
  id: 'theme/template-versions',
890
- summary: 'Every template has its own home design, and a version serves its original\'s business',
925
+ summary: 'Every design has its own home: no two list the same sections in the same order',
891
926
  kind: 'static',
892
927
  run(context) {
893
928
  if (context.retired)
@@ -896,7 +931,7 @@ export const templateVersionsRule = {
896
931
  const sequence = (store) => (pagesOf(store).home?.content ?? []).map((section) => `${section?.type}/${section?.variant ?? 'default'}`).join(' → ');
897
932
  const seen = new Map();
898
933
  // The single-page default theme draws one fixed layout whatever its home
899
- // lists, so its templates differ by business, not composition.
934
+ // lists, so its designs differ by business, not composition.
900
935
  for (const store of context.pageBased ? context.demos : []) {
901
936
  const home = sequence(store);
902
937
  if (!home)
@@ -905,8 +940,8 @@ export const templateVersionsRule = {
905
940
  if (first) {
906
941
  findings.push(finding(context, 'theme/template-versions', 'reject', {
907
942
  where: `${context.env.root}${store.file} → pages.home`,
908
- found: `template "${store.id}" has the same home sections, in the same order, as "${first}"`,
909
- fix: 'A template must be its own design, not a recolour: change the order and the section variants (and the header, footer and palette where the theme allows). Vendors are spread across versions; two identical ones make that a coin toss between the same page.',
943
+ found: `design "${store.id}" has the same home sections, in the same order, as "${first}"`,
944
+ fix: 'Each design must be its own, not a recolour: change the order and the section variants (and the header, footer and palette where the theme allows). Vendors of one business are spread across its template\'s designs; two identical ones make that a coin toss between the same page.',
910
945
  docs: `${context.env.docs}#templates`,
911
946
  }));
912
947
  }
@@ -914,23 +949,109 @@ export const templateVersionsRule = {
914
949
  seen.set(home, store.id);
915
950
  }
916
951
  }
917
- const declared = context.declaredDemos ?? [];
918
- const forOf = new Map(declared.map((demo) => [demo.id, JSON.stringify(demo.for ?? [])]));
919
- for (const demo of declared) {
920
- const base = versionBase(demo.id);
921
- if (!base || !forOf.has(base) || forOf.get(base) === forOf.get(demo.id))
952
+ return findings;
953
+ },
954
+ };
955
+ /** A template has at most this many designs (contract R2.2). */
956
+ export const TEMPLATE_DESIGNS_MAX = 3;
957
+ /**
958
+ * Theme → template → design (contract R2.8). Designs are grouped by the
959
+ * `template` key each one declares, exactly as the registry and the preview
960
+ * group them (utils/theme-designs.ts). The resolver is lenient, so a config
961
+ * written before R2.8 still previews; this rule is where it is strict.
962
+ */
963
+ export const templateDesignsRule = {
964
+ id: 'theme/template-designs',
965
+ summary: "Every design names its template; a template's designs share its label and business and are told apart by design_label",
966
+ kind: 'static',
967
+ run(context) {
968
+ if (context.retired || context.declaredDemos === null)
969
+ return [];
970
+ const findings = [];
971
+ const config = designsConfig(context);
972
+ const where = (id, field) => `${context.env.root}theme.config.ts → ${id === PRIMARY_DEMO_ID ? 'default_demo' : `demos[${id}]`}.${field}`;
973
+ const add = (at, found, fix) => {
974
+ findings.push(finding(context, 'theme/template-designs', 'reject', { where: at, found, fix, docs: `${context.env.docs}#templates` }));
975
+ };
976
+ const designs = designsOfContext(context);
977
+ // Every design names its template, by a key that can be one.
978
+ for (const { id } of designs) {
979
+ const key = declarationOf(context, id).template;
980
+ if (key === undefined || key === null || key === '') {
981
+ if (id === PRIMARY_DEMO_ID) {
982
+ add(where(id, 'template'), 'the main template has no key (default_demo.template)', "Add `template` to `default_demo`: a lowercase slug naming its business (medley's main template is `beauty`). On a theme with two or more templates the main store moves to `/<slug>~<key>`, and `/<slug>` becomes the template gallery.");
983
+ }
984
+ else {
985
+ add(where(id, 'template'), `design "${id}" names no template`, `Add \`template\` to it: \`template: '${id}'\` when it is the first design of its business, or the key of the template it is another design of. Designs are grouped by this key, never by an id's \`-2\`.`);
986
+ }
987
+ }
988
+ else if (typeof key !== 'string' || !DEMO_ID_FORMAT.test(key)) {
989
+ add(where(id, 'template'), `template key "${String(key)}" is not a slug`, 'Use lowercase letters, digits and single hyphens: the key is a URL segment (`/<slug>~<key>`). Never rename a key once shipped; old links redirect by it.');
990
+ }
991
+ else if (key === PRIMARY_DEMO_ID) {
992
+ add(where(id, 'template'), `template key "${key}" is reserved`, '"default" is demo.json\'s design id, never a template key. Name the business: `beauty`, `food`, `laundry`.');
993
+ }
994
+ }
995
+ // Template keys and design ids share one namespace: `/<slug>~<segment>` opens exactly one store.
996
+ const main = mainTemplateKey(config);
997
+ if (main !== null && designs.some((design) => design.id === main)) {
998
+ add(where(PRIMARY_DEMO_ID, 'template'), `the main template's key "${main}" is also a design id`, `\`/<slug>~${main}\` must open one store. Give the main template another key; design ids are never renamed once shipped, keys neither, so settle it before the theme ships.`);
999
+ }
1000
+ for (const template of groupTemplates(designs)) {
1001
+ const { key } = template;
1002
+ if (key === null)
1003
+ continue; // a main template with no key: reported above
1004
+ const count = template.designs.length;
1005
+ const [first, ...later] = template.designs;
1006
+ if (first.id !== PRIMARY_DEMO_ID && !template.designs.some((design) => design.id === key)) {
1007
+ add(where(first.id, 'template'), `template "${key}" has no design 1 (a design whose id is "${key}")`, `Design 1 of a template is the design whose id is its key, so \`/<slug>~${key}\` opens the template. Declare \`{ id: '${key}', template: '${key}', label, for, description }\` with demos/${key}.json, or give "${first.id}" its own id as its \`template\`.`);
1008
+ }
1009
+ if (count > TEMPLATE_DESIGNS_MAX) {
1010
+ add(where(template.designs[TEMPLATE_DESIGNS_MAX].id, 'template'), `template "${key}" has ${count} designs; a template has at most ${TEMPLATE_DESIGNS_MAX}`, `Keep ${TEMPLATE_DESIGNS_MAX} designs of "${key}" (contract R2.2): drop the rest, or make one a template of another business.`);
1011
+ }
1012
+ // `label` and `for` are the template's, declared once on design 1. A later
1013
+ // design may repeat them unchanged, or leave them out and inherit them.
1014
+ for (const design of later) {
1015
+ const declared = declarationOf(context, design.id);
1016
+ const label = typeof declared.label === 'string' ? declared.label.trim() : '';
1017
+ if (label !== '' && label !== template.label) {
1018
+ add(where(design.id, 'label'), `design "${design.id}" relabels its template ("${label}"); a design is named by design_label`, `\`label\` is the template's ("${template.label}"), declared once on "${first.id}". Drop \`label\` from "${design.id}" (it inherits it), and say what tells this design apart in \`design_label\`.`);
1019
+ }
1020
+ if (declared.for !== undefined && JSON.stringify(declared.for) !== JSON.stringify(template.for)) {
1021
+ add(where(design.id, 'for'), `design "${design.id}" is for ${JSON.stringify(declared.for)}, its template "${key}" for ${JSON.stringify(template.for)}`, `Every design of a template is for the same business: drop \`for\` from "${design.id}" (it inherits "${first.id}"'s), or make it exactly ${JSON.stringify(template.for)}. The backend spreads a business's vendors across its template's designs.`);
1022
+ }
1023
+ }
1024
+ if (count > 1) {
1025
+ const named = new Map();
1026
+ for (const design of template.designs) {
1027
+ if (design.designLabel === null) {
1028
+ add(where(design.id, 'design_label'), `design "${design.id}" has no design_label (template "${key}" has ${count} designs)`, `Name what tells "${design.id}" from the template's other designs ("Dining room", "Neighbourhood buka"). The registry, the gallery and the Designs dropdown show it.`);
1029
+ continue;
1030
+ }
1031
+ const other = named.get(design.designLabel.toLowerCase());
1032
+ if (other) {
1033
+ add(where(design.id, 'design_label'), `designs "${other}" and "${design.id}" share the design_label "${design.designLabel}"`, 'Give each design of a template its own `design_label`: it is how a merchant tells them apart.');
1034
+ }
1035
+ else {
1036
+ named.set(design.designLabel.toLowerCase(), design.id);
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+ // A design_label is shown on every store built from the design (R2.6).
1042
+ for (const design of designs) {
1043
+ if (design.designLabel === null)
922
1044
  continue;
923
- findings.push(finding(context, 'theme/template-versions', 'reject', {
924
- where: `${context.env.root}theme.config.ts → demos[${demo.id}].for`,
925
- found: `version "${demo.id}" is for ${forOf.get(demo.id)}, "${base}" for ${forOf.get(base)}`,
926
- fix: `Give "${demo.id}" exactly the \`for\` of "${base}". Versions are the same business in different designs; the backend spreads matching vendors across them.`,
927
- docs: `${context.env.docs}#templates`,
928
- }));
1045
+ const name = context.demos.find((store) => store.id === design.id)?.data?.profile?.name;
1046
+ const why = copyViolations(design.designLabel, typeof name === 'string' ? name : null);
1047
+ if (why.length === 0)
1048
+ continue;
1049
+ add(where(design.id, 'design_label'), `design "${design.id}" design_label: ${why.join('; ')}`, 'Write it true of any store in the business: no store name, place, naira amount, promise or date ("Neighbourhood buka", never "Lekki buka" or "Mama Tee\'s buka").');
929
1050
  }
930
1051
  return findings;
931
1052
  },
932
1053
  };
933
- /** The designed pages every template ships (contract R2.3); versions `-2`… count. */
1054
+ /** The designed pages every template ships (contract R2.3); page versions `-2`… count. */
934
1055
  export const TEMPLATE_PAGES = ['about', 'sales', 'landing'];
935
1056
  export const templatePagesRule = {
936
1057
  id: 'theme/template-pages',
@@ -945,7 +1066,7 @@ export const templatePagesRule = {
945
1066
  const pages = pagesOf(store);
946
1067
  const where = `${context.env.root}${store.file} → pages`;
947
1068
  for (const slug of TEMPLATE_PAGES) {
948
- if (Object.keys(pages).some((key) => key === slug || versionBase(key) === slug))
1069
+ if (Object.keys(pages).some((key) => key === slug || pageBase(key) === slug))
949
1070
  continue;
950
1071
  findings.push(finding(context, 'theme/template-pages', 'reject', {
951
1072
  where,
@@ -957,7 +1078,7 @@ export const templatePagesRule = {
957
1078
  }));
958
1079
  }
959
1080
  for (const [key, page] of Object.entries(pages)) {
960
- const kind = TEMPLATE_PAGES.find((slug) => slug !== 'about' && (key === slug || versionBase(key) === slug));
1081
+ const kind = TEMPLATE_PAGES.find((slug) => slug !== 'about' && (key === slug || pageBase(key) === slug));
961
1082
  if (!kind)
962
1083
  continue;
963
1084
  const content = page?.content ?? [];
@@ -1014,6 +1135,20 @@ export const templateCopyRule = {
1014
1135
  const findings = [];
1015
1136
  for (const store of context.demos) {
1016
1137
  const name = store.data?.profile?.name;
1138
+ // The header announcement is copied onto real stores like section copy,
1139
+ // whether the bar is enabled or not — so it reads by the same rules.
1140
+ const announcement = store.data?.config?.header?.announcement?.text;
1141
+ if (typeof announcement === 'string' && announcement !== '') {
1142
+ const why = copyViolations(announcement, typeof name === 'string' ? name : null);
1143
+ if (why.length > 0) {
1144
+ findings.push(finding(context, 'theme/template-copy', 'reject', {
1145
+ where: `${context.env.root}${store.file} → config.header.announcement.text`,
1146
+ found: `text: ${why.join('; ')} — "${announcement.length > 80 ? `${announcement.slice(0, 77)}…` : announcement}"`,
1147
+ 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.',
1148
+ docs: `${context.env.docs}#templates`,
1149
+ }));
1150
+ }
1151
+ }
1017
1152
  for (const [pageKey, page] of Object.entries(pagesOf(store))) {
1018
1153
  (page?.content ?? []).forEach((section, index) => {
1019
1154
  if (typeof section?.type !== 'string')
@@ -1242,5 +1377,5 @@ export const placeholderContentRule = {
1242
1377
  };
1243
1378
  export const STATIC_RULES = [moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule,
1244
1379
  templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule,
1245
- templateBusinessRule, templateVersionsRule, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule,
1380
+ templateBusinessRule, templateVersionsRule, templateDesignsRule, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule,
1246
1381
  ];
package/dist/types.d.ts CHANGED
@@ -64,13 +64,23 @@ export interface DemoStore {
64
64
  /** Null when the file is missing or not valid JSON. */
65
65
  data: Record<string, unknown> | null;
66
66
  }
67
- /** A `demos[]` entry in theme.config.ts. */
67
+ /**
68
+ * A `demos[]` entry in theme.config.ts: one design (contract R2.8). A theme is
69
+ * the look, a template is a business it is dressed as, and a design is one
70
+ * concrete store of a template — one demo file.
71
+ */
68
72
  export interface DeclaredDemo {
73
+ /** The design id: demos/<id>.json. Never renamed once shipped. */
69
74
  id: string;
70
- label: string;
71
- /** Business slugs (the vendor's service_slug/service_type vocabulary). */
72
- for: string[];
73
- /** What an AI reads to choose this template for a merchant (≤ 300 chars). */
75
+ /** The key of the template this design belongs to: `food`. Explicit on every design. */
76
+ template?: string;
77
+ /** The template's label, the business as a merchant sees it. Declared on the template's design 1; a later design inherits it. */
78
+ label?: string;
79
+ /** What tells this design from its template's others: `Neighbourhood buka`. Required when a template has 2+ designs. */
80
+ design_label?: string;
81
+ /** The template's business slugs (the vendor's service_slug/service_type vocabulary). Declared on design 1; a later design inherits them. */
82
+ for?: string[];
83
+ /** What an AI reads to choose this design for a merchant (≤ 300 chars). */
74
84
  description?: string;
75
85
  }
76
86
  /**
@@ -106,6 +116,12 @@ export interface ThemeContext {
106
116
  demo: Record<string, unknown> | null;
107
117
  /** Every store on disk, the primary first. */
108
118
  demos: DemoStore[];
119
+ /**
120
+ * theme.config.ts's default export, as loaded; null when it could not be
121
+ * loaded. The template and design rules read it through the design resolver
122
+ * (`designsOf`), so they group designs exactly as the registry does.
123
+ */
124
+ themeConfig: unknown;
109
125
  /** `demos` from theme.config.ts; null when the config could not be loaded. */
110
126
  declaredDemos: DeclaredDemo[] | null;
111
127
  /** `default_demo.description` from theme.config.ts — the primary template's description. */
@@ -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
- /** A discount or a coupon code: "Save 20% with code RAINS20", "Up to 25% off". */
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
- /\bfree\s+(?:\w+\s+)?(?:delivery|shipping|returns?|pick[- ]?up|collection|alterations?|installation|install|resizing|exchanges?)\b/i,
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,142 @@
1
+ /**
2
+ * Theme → template → design (queek_backend contract R2.8).
3
+ *
4
+ * A theme is the look. A template is a business the theme is dressed as
5
+ * (medley's Food), and a design is one concrete store of it, one demo file:
6
+ * `food` and `food-2` are the Food template's two designs. theme.config.ts
7
+ * declares every design. `default_demo` is the main template's first design
8
+ * (demo.json, id `default`); each `demos[]` entry is one more (demos/<id>.json).
9
+ *
10
+ * default_demo: { template: 'beauty', label: 'Skincare & make-up', design_label: 'Photo collage', for: [...], description },
11
+ * demos: [
12
+ * { id: 'food', template: 'food', label: 'Restaurant & kitchen', design_label: 'Dining room', for: [...], description },
13
+ * { id: 'food-2', template: 'food', design_label: 'Neighbourhood buka', description },
14
+ * ],
15
+ *
16
+ * - `template` is the key designs are grouped by, explicit on every design
17
+ * (never inferred from an id's `-2`): a slug, never `default`, never renamed
18
+ * once shipped. Template keys and design ids share one namespace.
19
+ * - Design 1 of a template is the design whose id IS its key (`default` in the
20
+ * main template), so `/medley~food` opens the Food template. The others
21
+ * follow in `demos[]` order. The index is derived, never declared.
22
+ * - `label` (the business) and `for` belong to the template and are declared
23
+ * on design 1. A later design may omit them; it inherits design 1's.
24
+ * - `design_label` names a design within its template ("Neighbourhood buka").
25
+ *
26
+ * Tolerant where theme-check is strict: a design without a valid `template` is
27
+ * a template of its own, keyed by its id, and a main design without one has no
28
+ * key (so no gallery). A config written before R2.8 previews and publishes as
29
+ * it did.
30
+ *
31
+ * One resolver for every consumer: the registry, the design index the proxy
32
+ * and the preview read, both copies of theme-check, and theme-cli. So it
33
+ * imports nothing. `yarn tools:sync-lists` copies this file byte for byte to
34
+ * theme-tools (packages/theme-check/src/utils/theme-designs.ts), and a copy
35
+ * that drifts fails `yarn tools:sync-lists --check`. Change it here, then sync.
36
+ */
37
+ /** The id `demo.json` goes by: the main template's first design. Equal to theme-demos.ts's PRIMARY_DEMO_ID. */
38
+ export declare const PRIMARY_DESIGN_ID = "default";
39
+ /** Between the theme and the design in a preview segment: `medley~food-2`. Equal to DEMO_SEPARATOR. */
40
+ export declare const DESIGN_SEPARATOR = "~";
41
+ /** A design id or a template key: both land in a URL segment and a filename. Equal to DEMO_ID_FORMAT. */
42
+ export declare const DESIGN_ID_FORMAT: RegExp;
43
+ /** One design as theme.config.ts declares it (`default_demo`, or a `demos[]` entry with its `id`). */
44
+ export interface DesignDeclaration {
45
+ /** The key of the template this design belongs to: `food`. */
46
+ template?: string;
47
+ /** The template's label, the business it is dressed as: `Restaurant & kitchen`. Declared on design 1. */
48
+ label?: string;
49
+ /** What tells this design from its template's others: `Neighbourhood buka`. */
50
+ design_label?: string;
51
+ /** The template's business, in lib/storefront/business-vocabulary.json. Declared on design 1. */
52
+ for?: string[];
53
+ /** What an AI reads to choose this design for a merchant (≤ 300 chars). */
54
+ description?: string;
55
+ }
56
+ /** The part of theme.config.ts this file reads. */
57
+ export interface ThemeDesignsConfig {
58
+ /** The theme's name: the main template's label when `default_demo` declares none. */
59
+ name?: string;
60
+ default_demo?: DesignDeclaration;
61
+ demos?: Array<DesignDeclaration & {
62
+ id: string;
63
+ }>;
64
+ }
65
+ /** One design, resolved: what the registry publishes for it. */
66
+ export interface ThemeDesign {
67
+ /** `default` for demo.json, else the name of its demos/<id>.json. Never renamed once shipped. */
68
+ id: string;
69
+ /** Its template's key. Null only for a main design that declares none (a config written before R2.8). */
70
+ template: string | null;
71
+ /** The template's label (design 1's `label`). */
72
+ templateLabel: string;
73
+ /** 1 for design 1 (the design whose id is the key; `default` in the main template), then `demos[]` order. */
74
+ designIndex: number;
75
+ designLabel: string | null;
76
+ /** For readers that know no templates: see composeLabel. */
77
+ label: string;
78
+ /** The template's business: design 1's `for`, the same on every design of it. */
79
+ for: string[];
80
+ description: string | null;
81
+ }
82
+ /** A template: its designs, design 1 first. */
83
+ export interface ThemeTemplate {
84
+ key: string | null;
85
+ label: string;
86
+ for: string[];
87
+ designs: ThemeDesign[];
88
+ }
89
+ /** One theme in themes/design-index.json. */
90
+ export interface DesignIndexEntry {
91
+ /** The main template's key: `/<slug>~<main>` is demo.json once the theme has a gallery. */
92
+ main: string | null;
93
+ /** The main template first, then in the order the config first names each. */
94
+ templates: DesignIndexTemplate[];
95
+ }
96
+ export interface DesignIndexTemplate {
97
+ key: string | null;
98
+ label: string;
99
+ /** Design 1 first. `label` is the design's `design_label` (its composed label when it declares none). */
100
+ designs: Array<{
101
+ id: string;
102
+ label: string;
103
+ }>;
104
+ }
105
+ /** themes/design-index.json: every active theme, by slug. */
106
+ export type DesignIndex = Record<string, DesignIndexEntry>;
107
+ /**
108
+ * The label a reader that knows no templates shows (the registry's `label`):
109
+ * the template's label when it has one design; `template — design` on every
110
+ * design, design 1 included, when it has more, so two cards never read alike.
111
+ */
112
+ export declare function composeLabel(templateLabel: string, designLabel: string | null, designs: number): string;
113
+ /** Every design the config declares, resolved, in declaration order (demo.json first). */
114
+ export declare function designsOf(config: ThemeDesignsConfig | null | undefined): ThemeDesign[];
115
+ /** Designs grouped into templates: the main template (demo.json's) first, each template's design 1 first. */
116
+ export declare function groupTemplates(designs: readonly ThemeDesign[]): ThemeTemplate[];
117
+ /** The main template's key (`default_demo.template`), or null when it declares none. */
118
+ export declare function mainTemplateKey(config: ThemeDesignsConfig | null | undefined): string | null;
119
+ /** The theme's entry in themes/design-index.json. */
120
+ export declare function designIndexEntry(config: ThemeDesignsConfig | null | undefined): DesignIndexEntry;
121
+ /**
122
+ * Does `/<slug>` open the template gallery? Only for a theme with two or more
123
+ * templates and a main key to move the main store to (`/<slug>~<main>`). A
124
+ * single-template theme keeps `/<slug>` as its store.
125
+ */
126
+ export declare function hasGallery(theme: {
127
+ main: string | null;
128
+ templates: readonly unknown[];
129
+ }): boolean;
130
+ /**
131
+ * The preview segment of a design: `medley~food-2`. demo.json's is the bare
132
+ * slug, unless the theme has a gallery there: then it is `<slug>~<main key>`.
133
+ */
134
+ export declare function designParam(slug: string, id: string, main: string | null, gallery: boolean): string;
135
+ /**
136
+ * The design a preview segment's `~<segment>` names, or null for none: the
137
+ * inverse of designParam. The main key is demo.json (`default`) on a gallery
138
+ * theme; any other design id is itself. `default` is never a segment (one URL
139
+ * per design), and a single-template theme's key is none either: its store
140
+ * stays at the bare slug.
141
+ */
142
+ export declare function designOfSegment(theme: DesignIndexEntry, segment: string): string | null;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Theme → template → design (queek_backend contract R2.8).
3
+ *
4
+ * A theme is the look. A template is a business the theme is dressed as
5
+ * (medley's Food), and a design is one concrete store of it, one demo file:
6
+ * `food` and `food-2` are the Food template's two designs. theme.config.ts
7
+ * declares every design. `default_demo` is the main template's first design
8
+ * (demo.json, id `default`); each `demos[]` entry is one more (demos/<id>.json).
9
+ *
10
+ * default_demo: { template: 'beauty', label: 'Skincare & make-up', design_label: 'Photo collage', for: [...], description },
11
+ * demos: [
12
+ * { id: 'food', template: 'food', label: 'Restaurant & kitchen', design_label: 'Dining room', for: [...], description },
13
+ * { id: 'food-2', template: 'food', design_label: 'Neighbourhood buka', description },
14
+ * ],
15
+ *
16
+ * - `template` is the key designs are grouped by, explicit on every design
17
+ * (never inferred from an id's `-2`): a slug, never `default`, never renamed
18
+ * once shipped. Template keys and design ids share one namespace.
19
+ * - Design 1 of a template is the design whose id IS its key (`default` in the
20
+ * main template), so `/medley~food` opens the Food template. The others
21
+ * follow in `demos[]` order. The index is derived, never declared.
22
+ * - `label` (the business) and `for` belong to the template and are declared
23
+ * on design 1. A later design may omit them; it inherits design 1's.
24
+ * - `design_label` names a design within its template ("Neighbourhood buka").
25
+ *
26
+ * Tolerant where theme-check is strict: a design without a valid `template` is
27
+ * a template of its own, keyed by its id, and a main design without one has no
28
+ * key (so no gallery). A config written before R2.8 previews and publishes as
29
+ * it did.
30
+ *
31
+ * One resolver for every consumer: the registry, the design index the proxy
32
+ * and the preview read, both copies of theme-check, and theme-cli. So it
33
+ * imports nothing. `yarn tools:sync-lists` copies this file byte for byte to
34
+ * theme-tools (packages/theme-check/src/utils/theme-designs.ts), and a copy
35
+ * that drifts fails `yarn tools:sync-lists --check`. Change it here, then sync.
36
+ */
37
+ /** The id `demo.json` goes by: the main template's first design. Equal to theme-demos.ts's PRIMARY_DEMO_ID. */
38
+ export const PRIMARY_DESIGN_ID = 'default';
39
+ /** Between the theme and the design in a preview segment: `medley~food-2`. Equal to DEMO_SEPARATOR. */
40
+ export const DESIGN_SEPARATOR = '~';
41
+ /** A design id or a template key: both land in a URL segment and a filename. Equal to DEMO_ID_FORMAT. */
42
+ export const DESIGN_ID_FORMAT = /^[a-z0-9]+(-[a-z0-9]+)*$/;
43
+ const text = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null);
44
+ const businessKeys = (value) => {
45
+ if (!Array.isArray(value))
46
+ return null;
47
+ const keys = value.filter((key) => typeof key === 'string');
48
+ return keys.length > 0 ? keys : null;
49
+ };
50
+ /** A declared `template`, or null when it is missing or could not be a key. */
51
+ function templateKey(value) {
52
+ return typeof value === 'string' && DESIGN_ID_FORMAT.test(value) && value !== PRIMARY_DESIGN_ID ? value : null;
53
+ }
54
+ function declaredDesigns(config) {
55
+ const main = config?.default_demo ?? {};
56
+ const designs = [{
57
+ id: PRIMARY_DESIGN_ID,
58
+ primary: true,
59
+ key: templateKey(main.template),
60
+ label: text(main.label),
61
+ designLabel: text(main.design_label),
62
+ for: businessKeys(main.for),
63
+ description: text(main.description),
64
+ }];
65
+ const seen = new Set([PRIMARY_DESIGN_ID]);
66
+ const demos = config?.demos;
67
+ for (const demo of Array.isArray(demos) ? demos : []) {
68
+ // A missing, reserved or repeated id is theme-check's to name; it is no design.
69
+ if (typeof demo?.id !== 'string' || seen.has(demo.id))
70
+ continue;
71
+ seen.add(demo.id);
72
+ designs.push({
73
+ id: demo.id,
74
+ primary: false,
75
+ key: templateKey(demo.template) ?? demo.id,
76
+ label: text(demo.label),
77
+ designLabel: text(demo.design_label),
78
+ for: businessKeys(demo.for),
79
+ description: text(demo.description),
80
+ });
81
+ }
82
+ return designs;
83
+ }
84
+ /**
85
+ * The label a reader that knows no templates shows (the registry's `label`):
86
+ * the template's label when it has one design; `template — design` on every
87
+ * design, design 1 included, when it has more, so two cards never read alike.
88
+ */
89
+ export function composeLabel(templateLabel, designLabel, designs) {
90
+ return designs > 1 && designLabel ? `${templateLabel} — ${designLabel}` : templateLabel;
91
+ }
92
+ /** Every design the config declares, resolved, in declaration order (demo.json first). */
93
+ export function designsOf(config) {
94
+ const declared = declaredDesigns(config);
95
+ const groups = new Map();
96
+ for (const design of declared)
97
+ groups.set(design.key, [...(groups.get(design.key) ?? []), design]);
98
+ const resolved = new Map();
99
+ for (const [key, members] of groups) {
100
+ const first = members.find((design) => design.primary) ?? members.find((design) => design.id === key) ?? members[0];
101
+ const ordered = [first, ...members.filter((design) => design !== first)];
102
+ const templateLabel = first.label
103
+ ?? (first.primary ? text(config?.name) : null)
104
+ ?? ordered.map((design) => design.label).find((label) => label !== null)
105
+ ?? key
106
+ ?? first.id;
107
+ const templateFor = first.for ?? ordered.map((design) => design.for).find((keys) => keys !== null) ?? [];
108
+ ordered.forEach((design, position) => {
109
+ resolved.set(design.id, {
110
+ id: design.id,
111
+ template: key,
112
+ templateLabel,
113
+ designIndex: position + 1,
114
+ designLabel: design.designLabel,
115
+ // A later design of a template that names no design_label keeps its own
116
+ // label rather than repeating the template's (theme-check rejects it).
117
+ label: ordered.length > 1 && !design.designLabel ? design.label ?? templateLabel : composeLabel(templateLabel, design.designLabel, ordered.length),
118
+ for: [...templateFor],
119
+ description: design.description,
120
+ });
121
+ });
122
+ }
123
+ return declared.map((design) => resolved.get(design.id));
124
+ }
125
+ /** Designs grouped into templates: the main template (demo.json's) first, each template's design 1 first. */
126
+ export function groupTemplates(designs) {
127
+ const groups = new Map();
128
+ const main = designs.find((design) => design.id === PRIMARY_DESIGN_ID);
129
+ if (main)
130
+ groups.set(main.template, []);
131
+ for (const design of designs)
132
+ groups.set(design.template, [...(groups.get(design.template) ?? []), design]);
133
+ return [...groups].map(([key, members]) => {
134
+ const ordered = [...members].sort((a, b) => a.designIndex - b.designIndex);
135
+ return { key, label: ordered[0].templateLabel, for: [...ordered[0].for], designs: ordered };
136
+ });
137
+ }
138
+ /** The main template's key (`default_demo.template`), or null when it declares none. */
139
+ export function mainTemplateKey(config) {
140
+ return templateKey(config?.default_demo?.template);
141
+ }
142
+ /** The theme's entry in themes/design-index.json. */
143
+ export function designIndexEntry(config) {
144
+ return {
145
+ main: mainTemplateKey(config),
146
+ templates: groupTemplates(designsOf(config)).map((template) => ({
147
+ key: template.key,
148
+ label: template.label,
149
+ designs: template.designs.map((design) => ({ id: design.id, label: design.designLabel ?? design.label })),
150
+ })),
151
+ };
152
+ }
153
+ /**
154
+ * Does `/<slug>` open the template gallery? Only for a theme with two or more
155
+ * templates and a main key to move the main store to (`/<slug>~<main>`). A
156
+ * single-template theme keeps `/<slug>` as its store.
157
+ */
158
+ export function hasGallery(theme) {
159
+ return theme.main !== null && theme.templates.length >= 2;
160
+ }
161
+ /**
162
+ * The preview segment of a design: `medley~food-2`. demo.json's is the bare
163
+ * slug, unless the theme has a gallery there: then it is `<slug>~<main key>`.
164
+ */
165
+ export function designParam(slug, id, main, gallery) {
166
+ if (id !== PRIMARY_DESIGN_ID)
167
+ return `${slug}${DESIGN_SEPARATOR}${id}`;
168
+ return gallery && main !== null ? `${slug}${DESIGN_SEPARATOR}${main}` : slug;
169
+ }
170
+ /**
171
+ * The design a preview segment's `~<segment>` names, or null for none: the
172
+ * inverse of designParam. The main key is demo.json (`default`) on a gallery
173
+ * theme; any other design id is itself. `default` is never a segment (one URL
174
+ * per design), and a single-template theme's key is none either: its store
175
+ * stays at the bare slug.
176
+ */
177
+ export function designOfSegment(theme, segment) {
178
+ if (hasGallery(theme) && segment === theme.main)
179
+ return PRIMARY_DESIGN_ID;
180
+ if (segment === PRIMARY_DESIGN_ID)
181
+ return null;
182
+ return theme.templates.some((template) => template.designs.some((design) => design.id === segment)) ? segment : null;
183
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-check",
3
- "version": "0.3.7",
3
+ "version": "0.4.1",
4
4
  "description": "The rules a Queek storefront theme is checked against, as a library.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,6 +8,10 @@
8
8
  ".": {
9
9
  "types": "./dist/index.d.ts",
10
10
  "default": "./dist/index.js"
11
+ },
12
+ "./designs": {
13
+ "types": "./dist/utils/theme-designs.d.ts",
14
+ "default": "./dist/utils/theme-designs.js"
11
15
  }
12
16
  },
13
17
  "files": [