@zohofinance/commerce 1.1.0 → 2.1.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.
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs';
3
+ import { fileURLToPath } from 'url';
3
4
  import open from 'open';
4
5
  import openEditor from 'open-editor';
5
6
  import chalk from 'chalk';
@@ -15,6 +16,10 @@ import StackTrace from '../utils/stack-trace.js';
15
16
  import { validateTheme, formatValidationIssuesForConsole } from '../utils/theme-validation.js';
16
17
  import { buildValidationErrorReport, buildRuntimeErrorReport, renderDevErrorPage } from '../utils/error-report.js';
17
18
  import { STOREFRONT_ROUTES, resolveApiPath, resolvePageTemplate } from '../routes/storefront-routes.js';
19
+ import { resolveSchemaSettings } from '../utils/resolve-schema-settings.js';
20
+ import { resolveSchemaTranslations as resolveSchemaT, resolveSchemaName } from '../utils/resolve-schema-translations.js';
21
+ import { prepareSectionData } from '../utils/prepare-section-data.js';
22
+ import { buildSectionComponentAttr } from '../utils/editor-attributes.js';
18
23
  function normalizeStoreUrl(input) {
19
24
  if (!input)
20
25
  return undefined;
@@ -183,7 +188,11 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
183
188
  try {
184
189
  const pageTemplate = resolvePageTemplate(routePageTemplate);
185
190
  const data = await fetchPageData(apiPath);
191
+ // Always render in editor mode so sections get data-zcommerce-component attributes
192
+ engine.engine.__editorMode = true;
193
+ engine.engine.__editorJsonType = 'template';
186
194
  const result = await renderPage(pageTemplate, data, req);
195
+ engine.engine.__editorMode = false;
187
196
  if ('json' in result && result.json) {
188
197
  res
189
198
  .status(result.status ?? 200)
@@ -191,9 +200,18 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
191
200
  .send(JSON.stringify(result.json, null, 2));
192
201
  return;
193
202
  }
194
- res.status(result.status ?? 200).send(result.html);
203
+ let html = result.html ?? '';
204
+ const bridgeTag = '<script src="/sb-bridge.js"></script>';
205
+ if (html.includes('</body>')) {
206
+ html = html.replace('</body>', `${bridgeTag}</body>`);
207
+ }
208
+ else {
209
+ html += bridgeTag;
210
+ }
211
+ res.status(result.status ?? 200).send(html);
195
212
  }
196
213
  catch (error) {
214
+ engine.engine.__editorMode = false;
197
215
  next(error);
198
216
  }
199
217
  }
@@ -201,6 +219,20 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
201
219
  const app = express();
202
220
  const upload = multer();
203
221
  app.use(express.json());
222
+ app.use((_req, res, next) => {
223
+ res.setHeader('Access-Control-Allow-Origin', '*');
224
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
225
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
226
+ if (_req.method === 'OPTIONS') {
227
+ res.sendStatus(204);
228
+ return;
229
+ }
230
+ next();
231
+ });
232
+ app.get('/sb-bridge.js', (_req, res) => {
233
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
234
+ res.type('application/javascript').sendFile(path.resolve(__dirname, '../../public/iframe-bridge.js'));
235
+ });
204
236
  app.engine('liquid', engine.express());
205
237
  app.set('views', ['./pages']);
206
238
  app.set('view engine', 'liquid');
@@ -714,6 +746,639 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
714
746
  next(error);
715
747
  }
716
748
  });
749
+ // Runtime fields added by the CLI on section/block root objects — must NOT be written to disk.
750
+ // NOTE: display_name inside settings values (e.g. {display_name:"Full Width",value:"full"}) is
751
+ // legitimate theme data — we only strip it at the section/block root level, not inside settings.
752
+ const COMPONENT_RUNTIME_FIELDS = new Set(['id', 'display_name', 'settingid', 'zcommerce_attributes', 'block_key', 'available_sections', 'available_blocks']);
753
+ // Clean a single section or block object: strip runtime fields from root, recurse into blocks,
754
+ // but leave settings values entirely untouched (they may contain legitimate display_name/handle).
755
+ function cleanComponentForDisk(obj) {
756
+ const out = {};
757
+ for (const [k, v] of Object.entries(obj)) {
758
+ if (COMPONENT_RUNTIME_FIELDS.has(k))
759
+ continue;
760
+ if (k === 'blocks' && v && typeof v === 'object' && !Array.isArray(v)) {
761
+ // blocks is an object map — clean each block recursively
762
+ const cleanedBlocks = {};
763
+ for (const [bk, bv] of Object.entries(v)) {
764
+ cleanedBlocks[bk] = bv && typeof bv === 'object' && !Array.isArray(bv)
765
+ ? cleanComponentForDisk(bv)
766
+ : bv;
767
+ }
768
+ out[k] = cleanedBlocks;
769
+ }
770
+ else {
771
+ // settings, type, block_order, etc. — pass through unchanged
772
+ out[k] = v;
773
+ }
774
+ }
775
+ return out;
776
+ }
777
+ function cleanFullJsonForDisk(json) {
778
+ const sections = json.sections;
779
+ if (!sections || typeof sections !== 'object' || Array.isArray(sections)) {
780
+ return json;
781
+ }
782
+ const cleanedSections = {};
783
+ for (const [sk, sv] of Object.entries(sections)) {
784
+ cleanedSections[sk] = sv && typeof sv === 'object' && !Array.isArray(sv)
785
+ ? cleanComponentForDisk(sv)
786
+ : sv;
787
+ }
788
+ return { ...json, sections: cleanedSections };
789
+ }
790
+ function getJsonFilePath(jsonType, pageName) {
791
+ if (jsonType === 'header')
792
+ return path.join(cwd, 'sections', 'header-group.json');
793
+ if (jsonType === 'footer')
794
+ return path.join(cwd, 'sections', 'footer-group.json');
795
+ const page = pageName === 'home' ? 'index' : pageName || 'index';
796
+ return path.join(cwd, 'pages', `${page}.json`);
797
+ }
798
+ function persistFullJsonToDisk(jsonType, pageName, json) {
799
+ const filePath = getJsonFilePath(jsonType, pageName);
800
+ try {
801
+ fs.writeFileSync(filePath, JSON.stringify(cleanFullJsonForDisk(json), null, 2), 'utf-8');
802
+ }
803
+ catch (e) {
804
+ console.warn('[dev] Failed to persist full JSON:', e);
805
+ }
806
+ }
807
+ // Cache parsed section schemas (type → { name, blocks }) to avoid re-reading files.
808
+ const sectionSchemaCache = new Map();
809
+ function getSectionSchema(sectionType) {
810
+ if (sectionSchemaCache.has(sectionType))
811
+ return sectionSchemaCache.get(sectionType);
812
+ const liquidPath = path.join(cwd, 'sections', `${sectionType}.liquid`);
813
+ try {
814
+ const content = fs.existsSync(liquidPath) ? fs.readFileSync(liquidPath, 'utf-8') : '';
815
+ const match = content.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
816
+ const schema = match ? JSON.parse(match[1].trim()) : {};
817
+ const result = { name: schema.name, blocks: schema.blocks };
818
+ sectionSchemaCache.set(sectionType, result);
819
+ return result;
820
+ }
821
+ catch {
822
+ sectionSchemaCache.set(sectionType, {});
823
+ return {};
824
+ }
825
+ }
826
+ function addSectionIds(json) {
827
+ const sections = json.sections;
828
+ if (sections) {
829
+ for (const [key, section] of Object.entries(sections)) {
830
+ section.id = `section_${key}`;
831
+ const sectionType = section.type || key;
832
+ const schema = getSectionSchema(sectionType);
833
+ if (!section.display_name) {
834
+ section.display_name = resolveSchemaName(schema.name, sectionType, cwd);
835
+ }
836
+ const blocks = section.blocks;
837
+ if (blocks && !Array.isArray(blocks) && typeof blocks === 'object') {
838
+ const blockSchemaByType = new Map((schema.blocks || []).map((b) => [b.type, b]));
839
+ for (const [blockKey, block] of Object.entries(blocks)) {
840
+ if (block && typeof block === 'object') {
841
+ if (!block.id) {
842
+ block.id = `block_${blockKey}_section_${key}`;
843
+ }
844
+ if (!block.display_name) {
845
+ const blockType = block.type || blockKey;
846
+ const blockSchema = blockSchemaByType.get(blockType);
847
+ block.display_name = resolveSchemaName(blockSchema?.name, blockType, cwd);
848
+ }
849
+ }
850
+ }
851
+ }
852
+ }
853
+ }
854
+ return json;
855
+ }
856
+ app.get('/_editor/themeinfo', (_req, res) => {
857
+ res.json({ name: path.basename(cwd) });
858
+ });
859
+ app.get('/_editor/availablepages', (_req, res, next) => {
860
+ try {
861
+ const pagesDir = path.join(cwd, 'pages');
862
+ if (!fs.existsSync(pagesDir)) {
863
+ res.json([]);
864
+ return;
865
+ }
866
+ const PAGE_NAMES = {
867
+ index: 'Home', product: 'Product', collection: 'Category',
868
+ cart: 'Cart', checkout: 'Checkout', search: 'Search',
869
+ wishlist: 'Wishlist', account: 'Account', blog: 'Blog',
870
+ article: 'Article', page: 'Page', order: 'Order',
871
+ };
872
+ const pages = fs.readdirSync(pagesDir)
873
+ .filter((f) => f.endsWith('.json'))
874
+ .map((f) => {
875
+ const key = f.replace('.json', '');
876
+ return { key, name: PAGE_NAMES[key] || key.charAt(0).toUpperCase() + key.slice(1), url: `/${key === 'index' ? '' : key}` };
877
+ });
878
+ res.json(pages);
879
+ }
880
+ catch (error) {
881
+ next(error);
882
+ }
883
+ });
884
+ app.get('/_editor/sectionsschema', (_req, res, next) => {
885
+ try {
886
+ const sectionsDir = path.join(cwd, 'sections');
887
+ if (!fs.existsSync(sectionsDir)) {
888
+ res.json({ sections_schema: [] });
889
+ return;
890
+ }
891
+ const files = fs.readdirSync(sectionsDir).filter((f) => f.endsWith('.liquid'));
892
+ const sections_schema = files.map((file) => {
893
+ const sectionType = file.replace('.liquid', '');
894
+ const content = fs.readFileSync(path.join(sectionsDir, file), 'utf-8');
895
+ const schemaMatch = content.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
896
+ let schema = { settings: [], blocks: [] };
897
+ if (schemaMatch) {
898
+ try {
899
+ schema = JSON.parse(schemaMatch[1].trim());
900
+ }
901
+ catch { /* skip invalid schema */ }
902
+ }
903
+ const resolved = resolveSchemaT({ settings: schema.settings || [], name: schema.name || sectionType, blocks: schema.blocks || [] }, cwd);
904
+ const blocks = (resolved.blocks || []).map((b) => ({
905
+ ...b,
906
+ id: `setting_section_${sectionType}.${b.type}`,
907
+ settings: b.settings || [],
908
+ }));
909
+ return {
910
+ id: `setting_section_${sectionType}`,
911
+ name: resolved.name || sectionType,
912
+ type: sectionType,
913
+ settings: resolved.settings || [],
914
+ blocks,
915
+ };
916
+ });
917
+ res.json({ sections_schema });
918
+ }
919
+ catch (error) {
920
+ next(error);
921
+ }
922
+ });
923
+ app.get('/_editor/templatejson', (req, res, next) => {
924
+ try {
925
+ const pageName = req.query.page || 'index';
926
+ const jsonPath = path.join(cwd, 'pages', `${pageName}.json`);
927
+ if (!fs.existsSync(jsonPath)) {
928
+ res.status(404).json({ error: `pages/${pageName}.json not found` });
929
+ return;
930
+ }
931
+ const json = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
932
+ res.json(addSectionIds(json));
933
+ }
934
+ catch (error) {
935
+ next(error);
936
+ }
937
+ });
938
+ app.get('/_editor/headerjson', (req, res, next) => {
939
+ try {
940
+ const jsonPath = path.join(cwd, 'sections', 'header-group.json');
941
+ if (!fs.existsSync(jsonPath)) {
942
+ res.status(404).json({ error: 'sections/header-group.json not found' });
943
+ return;
944
+ }
945
+ const json = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
946
+ res.json(addSectionIds(json));
947
+ }
948
+ catch (error) {
949
+ next(error);
950
+ }
951
+ });
952
+ app.get('/_editor/footerjson', (req, res, next) => {
953
+ try {
954
+ const jsonPath = path.join(cwd, 'sections', 'footer-group.json');
955
+ if (!fs.existsSync(jsonPath)) {
956
+ res.status(404).json({ error: 'sections/footer-group.json not found' });
957
+ return;
958
+ }
959
+ const json = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
960
+ res.json(addSectionIds(json));
961
+ }
962
+ catch (error) {
963
+ next(error);
964
+ }
965
+ });
966
+ app.get('/_editor/page', async (req, res, next) => {
967
+ try {
968
+ const pageKey = req.query.page || 'home';
969
+ // Match by pageTemplate, exact expressPath, or expressPath prefix (e.g. 'product' → '/products/...')
970
+ const route = STOREFRONT_ROUTES.find((r) => r.pageTemplate === pageKey)
971
+ || STOREFRONT_ROUTES.find((r) => r.expressPath === `/${pageKey}`)
972
+ || STOREFRONT_ROUTES.find((r) => r.expressPath.split('/')[1]?.startsWith(pageKey))
973
+ || STOREFRONT_ROUTES[0];
974
+ const apiPath = resolveApiPath(route, {});
975
+ const data = await fetchPageData(apiPath);
976
+ engine.engine.__editorMode = true;
977
+ engine.engine.__editorJsonType = 'template';
978
+ const { html, error } = await engine.renderJSONTemplates({
979
+ pathName: resolvePageTemplate(route.pageTemplate),
980
+ data
981
+ });
982
+ engine.engine.__editorMode = false;
983
+ if (error) {
984
+ res.status(500).json({ error: String(error) });
985
+ return;
986
+ }
987
+ res.send(html);
988
+ }
989
+ catch (error) {
990
+ engine.engine.__editorMode = false;
991
+ next(error);
992
+ }
993
+ });
994
+ // Re-renders the full page with in-memory settings override (no disk write).
995
+ // Used by the editor when theme settings change so the preview updates immediately.
996
+ app.post('/_editor/renderpage', async (req, res, next) => {
997
+ const engineInternal = engine.engine;
998
+ try {
999
+ const body = req.body;
1000
+ const settingsData = body.settingsData;
1001
+ const pageName = body.pageName || 'index';
1002
+ const route = STOREFRONT_ROUTES.find((r) => r.pageTemplate === pageName)
1003
+ || STOREFRONT_ROUTES.find((r) => r.expressPath === `/${pageName}`)
1004
+ || STOREFRONT_ROUTES.find((r) => r.expressPath.split('/')[1]?.startsWith(pageName))
1005
+ || STOREFRONT_ROUTES[0];
1006
+ const apiPath = resolveApiPath(route, {});
1007
+ const data = await fetchPageData(apiPath);
1008
+ // Temporarily override runtimeGlobals.settings so the engine renders with
1009
+ // the new values. Restored in the finally block regardless of outcome.
1010
+ const savedSettings = runtimeGlobals.settings;
1011
+ if (settingsData)
1012
+ runtimeGlobals.settings = settingsData;
1013
+ engineInternal.__editorMode = true;
1014
+ engineInternal.__editorJsonType = 'template';
1015
+ try {
1016
+ const { html, error } = await engine.renderJSONTemplates({
1017
+ pathName: resolvePageTemplate(route.pageTemplate),
1018
+ data,
1019
+ });
1020
+ if (error) {
1021
+ res.status(500).json({ error: String(error) });
1022
+ return;
1023
+ }
1024
+ res.json({ html });
1025
+ }
1026
+ finally {
1027
+ runtimeGlobals.settings = savedSettings;
1028
+ engineInternal.__editorMode = false;
1029
+ }
1030
+ }
1031
+ catch (error) {
1032
+ engineInternal.__editorMode = false;
1033
+ next(error);
1034
+ }
1035
+ });
1036
+ app.post('/_editor/rendersection', async (req, res, next) => {
1037
+ try {
1038
+ const body = req.body;
1039
+ const sectionName = body.sectionName;
1040
+ const jsonType = body.jsonType || 'template';
1041
+ if (!sectionName) {
1042
+ res.status(400).json({ error: 'sectionName required' });
1043
+ return;
1044
+ }
1045
+ const sectionJsonRaw = body.sectionJson;
1046
+ const sectionJson = typeof sectionJsonRaw === 'string' ? JSON.parse(sectionJsonRaw) : sectionJsonRaw;
1047
+ const sectionType = sectionJson.type || sectionName;
1048
+ const filePath = path.join(cwd, 'sections', `${sectionType}.liquid`);
1049
+ if (!fs.existsSync(filePath)) {
1050
+ res.status(404).json({ error: `Section ${sectionType} not found` });
1051
+ return;
1052
+ }
1053
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
1054
+ const schemaMatch = fileContent.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1055
+ let schemaData;
1056
+ if (schemaMatch) {
1057
+ try {
1058
+ schemaData = JSON.parse(schemaMatch[1].trim());
1059
+ }
1060
+ catch { /* ignore */ }
1061
+ }
1062
+ // Deep copy BEFORE any mutation (resolveSchemaSettings, prepareSectionData both mutate sectionJson).
1063
+ // This copy goes back to the editor and is later saved to disk — must have original unmodified URLs.
1064
+ const editorSectionJson = JSON.parse(JSON.stringify(sectionJson));
1065
+ const data = { ...runtimeGlobals };
1066
+ if (schemaData?.settings && sectionJson.settings) {
1067
+ await resolveSchemaSettings(schemaData.settings, sectionJson.settings, data, engine.fetcher);
1068
+ }
1069
+ editorSectionJson.id = `section_${sectionName}`;
1070
+ editorSectionJson.display_name = resolveSchemaName(schemaData?.name, sectionType, cwd);
1071
+ const editorBlocks = editorSectionJson.blocks;
1072
+ if (editorBlocks && !Array.isArray(editorBlocks) && typeof editorBlocks === 'object') {
1073
+ const blockSchemaByType = new Map((schemaData?.blocks || []).map((b) => [b.type, b]));
1074
+ for (const [blockKey, block] of Object.entries(editorBlocks)) {
1075
+ if (block && typeof block === 'object') {
1076
+ if (!block.id) {
1077
+ block.id = `block_${blockKey}_section_${sectionName}`;
1078
+ }
1079
+ if (!block.display_name) {
1080
+ const blockType = block.type || blockKey;
1081
+ const blockSchema = blockSchemaByType.get(blockType);
1082
+ block.display_name = resolveSchemaName(blockSchema?.name, blockType, cwd);
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+ const preparedSection = prepareSectionData(sectionJson, schemaData, sectionName, { jsonType }, cwd);
1088
+ const resolvedDisplayName = resolveSchemaName(schemaData?.name, sectionType, cwd);
1089
+ preparedSection.settingid = `setting_section_${sectionType}`;
1090
+ preparedSection.display_name = resolvedDisplayName;
1091
+ const sectionHtml = await engine.renderFile(engine.getFilePath(`sections/${sectionType}.liquid`), {
1092
+ ...data,
1093
+ section: {
1094
+ ...preparedSection,
1095
+ id: `section_${sectionName}`,
1096
+ settingid: `setting_section_${sectionType}`,
1097
+ display_name: resolvedDisplayName
1098
+ }
1099
+ });
1100
+ const attrJson = buildSectionComponentAttr(sectionType, sectionName, jsonType, resolvedDisplayName);
1101
+ const wrappedHtml = `<div data-zcommerce-component='${attrJson.replace(/'/g, '&#39;')}' style="display:contents">${sectionHtml}</div>`;
1102
+ res.json({ html: wrappedHtml, sectionJson: editorSectionJson });
1103
+ }
1104
+ catch (error) {
1105
+ next(error);
1106
+ }
1107
+ });
1108
+ app.post('/_editor/sectionschema', async (req, res, next) => {
1109
+ try {
1110
+ const body = req.body;
1111
+ const sectionJsonRaw = body.sectionJson;
1112
+ const sectionJson = typeof sectionJsonRaw === 'string' ? JSON.parse(sectionJsonRaw) : sectionJsonRaw;
1113
+ const sectionType = sectionJson?.type;
1114
+ if (!sectionType) {
1115
+ res.status(400).json({ error: 'sectionJson.type required' });
1116
+ return;
1117
+ }
1118
+ const filePath = path.join(cwd, 'sections', `${sectionType}.liquid`);
1119
+ if (!fs.existsSync(filePath)) {
1120
+ res.status(404).json({ error: `Section ${sectionType} not found` });
1121
+ return;
1122
+ }
1123
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
1124
+ const schemaMatch = fileContent.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1125
+ if (!schemaMatch) {
1126
+ res.json({ schema: { settings: [], name: sectionType } });
1127
+ return;
1128
+ }
1129
+ const schema = JSON.parse(schemaMatch[1].trim());
1130
+ res.json({ schema: resolveSchemaT({ settings: schema.settings || [], name: schema.name || sectionType, blocks: schema.blocks || [] }, cwd) });
1131
+ }
1132
+ catch (error) {
1133
+ next(error);
1134
+ }
1135
+ });
1136
+ app.post('/_editor/blockschema', async (req, res, next) => {
1137
+ try {
1138
+ const body = req.body;
1139
+ const sectionJsonRaw = body.sectionJson;
1140
+ const blockJsonRaw = body.blockJson;
1141
+ const sectionJson = typeof sectionJsonRaw === 'string' ? JSON.parse(sectionJsonRaw) : sectionJsonRaw;
1142
+ const blockJson = typeof blockJsonRaw === 'string' ? JSON.parse(blockJsonRaw) : blockJsonRaw;
1143
+ const sectionType = sectionJson?.type;
1144
+ const blockType = blockJson?.type;
1145
+ if (!sectionType || !blockType) {
1146
+ res.status(400).json({ error: 'sectionJson.type and blockJson.type required' });
1147
+ return;
1148
+ }
1149
+ const filePath = path.join(cwd, 'sections', `${sectionType}.liquid`);
1150
+ if (!fs.existsSync(filePath)) {
1151
+ res.status(404).json({ error: `Section ${sectionType} not found` });
1152
+ return;
1153
+ }
1154
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
1155
+ const schemaMatch = fileContent.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1156
+ if (!schemaMatch) {
1157
+ res.json({ schema: { settings: [], name: blockType } });
1158
+ return;
1159
+ }
1160
+ const schema = JSON.parse(schemaMatch[1].trim());
1161
+ const blockSchema = (schema.blocks || []).find((b) => b.type === blockType);
1162
+ res.json({ schema: resolveSchemaT({ settings: blockSchema?.settings || [], name: blockSchema?.name || blockType }, cwd) });
1163
+ }
1164
+ catch (error) {
1165
+ next(error);
1166
+ }
1167
+ });
1168
+ // Returns list of available sections (from sections/ dir) with optional rendered HTML.
1169
+ app.get('/_editor/availablesections', async (req, res, next) => {
1170
+ try {
1171
+ const needHtml = req.query.needHtml === 'true';
1172
+ const jsonType = req.query.jsonType || 'template';
1173
+ const pageName = req.query.pageName || 'index';
1174
+ const sectionsDir = path.join(cwd, 'sections');
1175
+ if (!fs.existsSync(sectionsDir)) {
1176
+ res.json([]);
1177
+ return;
1178
+ }
1179
+ let templateName;
1180
+ if (jsonType === 'header') {
1181
+ templateName = 'header-group';
1182
+ }
1183
+ else if (jsonType === 'footer') {
1184
+ templateName = 'footer-group';
1185
+ }
1186
+ else {
1187
+ templateName = pageName === 'home' ? 'index' : pageName;
1188
+ }
1189
+ let currentTemplateJson = { sections: {}, order: [] };
1190
+ try {
1191
+ let jsonPath;
1192
+ if (jsonType === 'header') {
1193
+ jsonPath = path.join(cwd, 'sections', 'header-group.json');
1194
+ }
1195
+ else if (jsonType === 'footer') {
1196
+ jsonPath = path.join(cwd, 'sections', 'footer-group.json');
1197
+ }
1198
+ else {
1199
+ jsonPath = path.join(cwd, 'pages', `${templateName}.json`);
1200
+ }
1201
+ if (fs.existsSync(jsonPath)) {
1202
+ currentTemplateJson = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
1203
+ }
1204
+ }
1205
+ catch { /* use defaults */ }
1206
+ const currentSections = (currentTemplateJson.sections || {});
1207
+ const currentOrder = (currentTemplateJson.order || []);
1208
+ const availableSections = [];
1209
+ const files = fs.readdirSync(sectionsDir).filter((f) => f.endsWith('.liquid'));
1210
+ for (const file of files) {
1211
+ const sectionType = file.replace('.liquid', '');
1212
+ const content = fs.readFileSync(path.join(sectionsDir, file), 'utf-8');
1213
+ const schemaMatch = content.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1214
+ if (!schemaMatch)
1215
+ continue;
1216
+ let schema;
1217
+ try {
1218
+ schema = JSON.parse(schemaMatch[1].trim());
1219
+ }
1220
+ catch {
1221
+ continue;
1222
+ }
1223
+ const templates = schema?.available_in?.templates;
1224
+ if (!Array.isArray(templates) || !templates.includes(templateName)) {
1225
+ continue;
1226
+ }
1227
+ const presets = schema.presets;
1228
+ if (!Array.isArray(presets) || presets.length === 0) {
1229
+ continue;
1230
+ }
1231
+ if (typeof schema.limit === 'number') {
1232
+ let count = 0;
1233
+ for (const key of currentOrder) {
1234
+ if (currentSections[key]?.type === sectionType)
1235
+ count++;
1236
+ }
1237
+ if (count >= schema.limit) {
1238
+ continue;
1239
+ }
1240
+ }
1241
+ for (let p = 0; p < presets.length; p++) {
1242
+ const preset = presets[p];
1243
+ if (!preset)
1244
+ continue;
1245
+ const uuid = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
1246
+ const sectionName = `${sectionType}-additional_section-${uuid}`;
1247
+ const resolved = resolveSchemaT({ name: preset.name || sectionType, settings: [], blocks: [] }, cwd);
1248
+ const displayName = resolved.name || preset.name || sectionType;
1249
+ availableSections.push({
1250
+ section_name: sectionName,
1251
+ display_name: displayName,
1252
+ name: displayName,
1253
+ preset_index: p,
1254
+ category: schema.category,
1255
+ _sectionType: sectionType,
1256
+ });
1257
+ }
1258
+ }
1259
+ if (!needHtml) {
1260
+ res.json(availableSections.map(({ _sectionType: _, ...s }) => s));
1261
+ return;
1262
+ }
1263
+ const requestedNames = req.query.sectionNames
1264
+ ? JSON.parse(req.query.sectionNames)
1265
+ : availableSections.map((s) => s.section_name);
1266
+ const baseTypeFor = (name) => {
1267
+ const idx = name.indexOf('-additional_section-');
1268
+ return idx >= 0 ? name.substring(0, idx) : name;
1269
+ };
1270
+ const typesToRender = [...new Set(requestedNames.map(baseTypeFor))];
1271
+ const renderedMap = await renderSectionsForResponse(typesToRender, {});
1272
+ const assetsDir = path.join(cwd, 'assets');
1273
+ const cssLinks = fs.existsSync(assetsDir)
1274
+ ? fs.readdirSync(assetsDir)
1275
+ .filter((f) => f.endsWith('.css'))
1276
+ .map((f) => `<link rel="stylesheet" href="http://localhost:${port}/assets/${f}">`)
1277
+ .join('')
1278
+ : '';
1279
+ const cliOrigin = `http://localhost:${port}`;
1280
+ const displayNameForType = new Map();
1281
+ for (const entry of availableSections) {
1282
+ if (!displayNameForType.has(entry._sectionType)) {
1283
+ displayNameForType.set(entry._sectionType, entry.display_name);
1284
+ }
1285
+ }
1286
+ const presetSettingsForType = new Map();
1287
+ for (const entry of availableSections) {
1288
+ if (!presetSettingsForType.has(entry._sectionType)) {
1289
+ presetSettingsForType.set(entry._sectionType, {});
1290
+ }
1291
+ }
1292
+ for (const [sType] of presetSettingsForType) {
1293
+ try {
1294
+ const content = fs.readFileSync(path.join(sectionsDir, `${sType}.liquid`), 'utf-8');
1295
+ const sm = content.match(/{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/);
1296
+ if (sm) {
1297
+ const sc = JSON.parse(sm[1].trim());
1298
+ if (Array.isArray(sc.presets) && sc.presets[0]?.settings) {
1299
+ presetSettingsForType.set(sType, sc.presets[0].settings);
1300
+ }
1301
+ }
1302
+ }
1303
+ catch { /* skip */ }
1304
+ }
1305
+ const results = requestedNames.map((reqName) => {
1306
+ const baseType = baseTypeFor(reqName);
1307
+ const sectionHtml = (renderedMap[baseType] || '')
1308
+ .replace(/(['"(])\/assets\//g, `$1${cliOrigin}/assets/`)
1309
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
1310
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8">${cssLinks}</head><body>${sectionHtml}</body></html>`;
1311
+ const displayName = displayNameForType.get(baseType) || baseType;
1312
+ const sectionJson = {
1313
+ type: baseType,
1314
+ settings: presetSettingsForType.get(baseType) || {},
1315
+ blocks: {},
1316
+ block_order: [],
1317
+ };
1318
+ return { section_name: reqName, display_name: displayName, name: displayName, sectionJson, html };
1319
+ });
1320
+ res.json(results);
1321
+ }
1322
+ catch (error) {
1323
+ next(error);
1324
+ }
1325
+ });
1326
+ app.get('/_editor/themesettings', (req, res, next) => {
1327
+ try {
1328
+ const settingsDir = path.join(cwd, 'settings');
1329
+ let settings_schema = [];
1330
+ const schemaPath = path.join(settingsDir, 'schema.json');
1331
+ if (fs.existsSync(schemaPath)) {
1332
+ try {
1333
+ const raw = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
1334
+ settings_schema = resolveSchemaT(raw, cwd);
1335
+ }
1336
+ catch { /* use empty */ }
1337
+ }
1338
+ let settings_data = { selected: {}, presets: {} };
1339
+ const themePath = path.join(settingsDir, 'theme.json');
1340
+ if (fs.existsSync(themePath)) {
1341
+ try {
1342
+ settings_data = JSON.parse(fs.readFileSync(themePath, 'utf-8'));
1343
+ if (!settings_data.selected)
1344
+ settings_data.selected = {};
1345
+ const { selected, presets } = settings_data;
1346
+ if (selected && typeof selected.preset_name === 'string' && presets) {
1347
+ const preset = presets[selected.preset_name];
1348
+ if (preset && typeof preset === 'object') {
1349
+ settings_data = {
1350
+ ...settings_data,
1351
+ selected: { ...preset, preset_name: selected.preset_name },
1352
+ };
1353
+ }
1354
+ }
1355
+ }
1356
+ catch { /* use defaults */ }
1357
+ }
1358
+ res.json({ settings_schema, settings_data });
1359
+ }
1360
+ catch (error) {
1361
+ next(error);
1362
+ }
1363
+ });
1364
+ // Persists the full editor JSON to disk (for structural changes: add/delete/reorder sections).
1365
+ app.post('/_editor/savejson', (req, res, next) => {
1366
+ try {
1367
+ const body = req.body;
1368
+ const jsonType = body.jsonType || 'template';
1369
+ const pageName = body.pageName || 'index';
1370
+ const json = body.json;
1371
+ if (!json || typeof json !== 'object') {
1372
+ res.status(400).json({ error: 'json required' });
1373
+ return;
1374
+ }
1375
+ persistFullJsonToDisk(jsonType, pageName, json);
1376
+ res.json({ ok: true });
1377
+ }
1378
+ catch (error) {
1379
+ next(error);
1380
+ }
1381
+ });
717
1382
  app.get('/_cli/editor', async (req, res) => {
718
1383
  try {
719
1384
  const { file } = req.query;
@@ -752,6 +1417,7 @@ async function serveTheme(portNumber, openInWeb, livereloadOff, useMock = true,
752
1417
  }
753
1418
  res.set('Cache-Control', 'no-store').send('OK');
754
1419
  });
1420
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
755
1421
  app.use((err, _req, res, _next) => {
756
1422
  if (res.headersSent) {
757
1423
  return;