diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..db46ba6 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +/dist/** +/shopify-dev-utils/** diff --git a/package.json b/package.json index cbc0211..f37112c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@shopify/eslint-plugin": "^39.0.3", "@shopify/themekit": "^1.1.6", "autoprefixer": "^10.0.4", + "axios": "^0.21.0", "babel-eslint": "^10.1.0", "babel-loader": "^8.1.0", "babel-plugin-transform-class-properties": "^6.24.1", @@ -36,12 +37,15 @@ "file-loader": "^6.0.0", "glob": "^7.1.6", "html-webpack-plugin": "^4.3.0", + "liquidjs": "^9.16.1", "mini-css-extract-plugin": "^1.3.1", "node-fetch": "^2.6.1", "node-sass": "^5.0.0", "postcss": "^8.1.10", "postcss-loader": "^4.0.4", "prettier": "^2.1.2", + "raw-loader": "^4.0.2", + "sass": "^1.29.0", "sass-loader": "^10.1.0", "style-loader": "^2.0.0", "tailwindcss": "^2.0.1", @@ -52,6 +56,7 @@ "webpack-dev-server": "^3.11.0", "webpack-merge": "^5.7.3", "webpack-shell-plugin-next": "^2.0.8", + "yaml": "^1.10.0", "yargs": "^16.1.0" } } diff --git a/shopify-dev-utils/convertToGlobalDataStructure.js b/shopify-dev-utils/convertToGlobalDataStructure.js new file mode 100644 index 0000000..2c69370 --- /dev/null +++ b/shopify-dev-utils/convertToGlobalDataStructure.js @@ -0,0 +1,38 @@ +module.exports.convertToGlobalDataStructure = function convertToGlobalDataStructure(gqlData) { + // return gqlData; + return { + shop: { + name: gqlData.shop.name, + }, + collections: gqlData.collections.edges.map(({ node }) => ({ + title: node.title, + id: node.id, + handle: node.handle, + image: node.image, + description: node.description, + url: `/collections/${node.handle}`, + products: node.products.edges.map((product) => ({ + id: product.node.id, + title: product.node.title, + description: product.node.description, + handle: product.node.handle, + available: product.node.availableForSale, + price: product.node.priceRange.maxVariantPrice, // preserve the entire obj for money-* filters + price_max: product.node.priceRange.maxVariantPrice, + price_min: product.node.priceRange.minVariantPrice, + price_varies: + +product.node.priceRange.maxVariantPrice.amount !== + +product.node.priceRange.minVariantPrice, + url: `/products/${product.node.handle}`, + featured_image: + product.node.images.edges.length > 0 + ? { + id: product.node.images.edges[0].node.id, + alt: product.node.images.edges[0].node.altText, + src: product.node.images.edges[0].node.originalSrc, + } + : null, + })), + })), + }; +}; diff --git a/shopify-dev-utils/filters/asset_url.js b/shopify-dev-utils/filters/asset_url.js new file mode 100644 index 0000000..dcaf2ae --- /dev/null +++ b/shopify-dev-utils/filters/asset_url.js @@ -0,0 +1,5 @@ +module.exports.assetUrl = function assetUrl(v) { + const { publicPath } = this.context.opts.loaderOptions; + + return `${publicPath}${v}`; +}; diff --git a/shopify-dev-utils/filters/default_pagination.js b/shopify-dev-utils/filters/default_pagination.js new file mode 100644 index 0000000..781612b --- /dev/null +++ b/shopify-dev-utils/filters/default_pagination.js @@ -0,0 +1,26 @@ +module.exports.defaultPagination = function defaultPagination(paginate, ...rest) { + const next = rest.filter((arg) => arg[0] === 'next').pop(); + const previous = rest.filter((arg) => arg[0] === 'previous').pop(); + + const prevLabel = + previous.length > 0 ? previous.pop() : paginate.previous ? paginate.previous.title : ''; + const nextLabel = next.length > 0 ? next.pop() : paginate.next ? paginate.next.title : ''; + + const prevPart = paginate.previous + ? `${prevLabel}` + : ''; + const nextPart = paginate.next + ? `${nextLabel}` + : ''; + + const pagesPart = paginate.parts + .map((part) => { + if (part.is_link) { + return `${part.title}`; + } + return `${part.title}`; + }) + .join(''); + + return `${prevPart}${pagesPart}${nextPart}`; +}; diff --git a/shopify-dev-utils/filters/money_with_currency.js b/shopify-dev-utils/filters/money_with_currency.js new file mode 100644 index 0000000..784825c --- /dev/null +++ b/shopify-dev-utils/filters/money_with_currency.js @@ -0,0 +1,13 @@ +module.exports.moneyWithCurrency = function moneyWithCurrency(price) { + if (!price || !price.currencyCode || !price.amount) { + return ''; + } + + // the price that this object gets has 2 fields it is not the same value in "real" env, + // at real it should be only a number multiplied by 100 + return new Intl.NumberFormat('en', { + style: 'currency', + currency: price.currencyCode, + maximumFractionDigits: 2, + }).format(price.amount); +}; diff --git a/shopify-dev-utils/filters/money_without_trailing_zeros.js b/shopify-dev-utils/filters/money_without_trailing_zeros.js new file mode 100644 index 0000000..0e0f2e7 --- /dev/null +++ b/shopify-dev-utils/filters/money_without_trailing_zeros.js @@ -0,0 +1,14 @@ +const { moneyWithCurrency } = require('./money_with_currency'); + +module.exports.moneyWithoutTrailingZeros = function moneyWithoutTrailingZeros(price) { + // the price that this object gets has 2 fields it is not the same value in "real" env, + // at real it should be only a number multiplied by 100 + const moneyWithCurrencyAndTrailingZeros = moneyWithCurrency(price); + return moneyWithCurrencyAndTrailingZeros.replace( + /([,.][^0]*(0+))\D*$/, + (match, group, zeros) => { + const cutSize = zeros.length > 1 ? zeros.length + 1 : zeros.length; + return match.replace(group, group.substring(0, group.length - cutSize)); + } + ); +}; diff --git a/shopify-dev-utils/filters/script_tag.js b/shopify-dev-utils/filters/script_tag.js new file mode 100644 index 0000000..f555324 --- /dev/null +++ b/shopify-dev-utils/filters/script_tag.js @@ -0,0 +1,3 @@ +module.exports.scriptTag = function scriptTag(v) { + return ``; +}; diff --git a/shopify-dev-utils/filters/stylesheet_tag.js b/shopify-dev-utils/filters/stylesheet_tag.js new file mode 100644 index 0000000..a69fb92 --- /dev/null +++ b/shopify-dev-utils/filters/stylesheet_tag.js @@ -0,0 +1,3 @@ +module.exports.stylesheetTag = function stylesheetTag() { + return ''; // in Dev mode we load css from js for HMR +}; diff --git a/shopify-dev-utils/filters/within.js b/shopify-dev-utils/filters/within.js new file mode 100644 index 0000000..aa117ce --- /dev/null +++ b/shopify-dev-utils/filters/within.js @@ -0,0 +1,3 @@ +module.exports.within = function within(productUrl, collection) { + return `${collection.url}${productUrl}`; +}; diff --git a/shopify-dev-utils/liquidDev.entry.js b/shopify-dev-utils/liquidDev.entry.js new file mode 100644 index 0000000..bbbf9ab --- /dev/null +++ b/shopify-dev-utils/liquidDev.entry.js @@ -0,0 +1,63 @@ +const context = require.context( + '../src', + true, + /(collection|footer|featured-product|featured-collection|header|message)\.liquid$/ +); + +const cache = {}; + +context.keys().forEach(function (key) { + cache[key] = context(key); +}); + +function replaceHtml(key, startCommentNode) { + const commentNodeType = startCommentNode.nodeType; + while ( + startCommentNode.nextSibling.nodeType !== commentNodeType || + !startCommentNode.nextSibling.nodeValue.includes(`hmr-end: ${key}`) + ) { + startCommentNode.nextSibling.remove(); + } + + const tpl = document.createElement('template'); + tpl.innerHTML = cache[key]; + startCommentNode.parentNode.insertBefore(tpl.content, startCommentNode.nextSibling); +} + +if (module.hot) { + module.hot.accept(context.id, function () { + const newContext = require.context( + '../src', + true, + /(collection|footer|featured-product|featured-collection|header|message)\.liquid$/ + ); + const changes = []; + newContext.keys().forEach(function (key) { + const newFile = newContext(key); + if (cache[key] !== newFile) { + changes.push(key); + cache[key] = newFile; + } + }); + + changes.forEach((changedFile) => { + traverseHMRComments(changedFile, replaceHtml); + }); + }); +} + +function traverseHMRComments(file, callback) { + const nodeIterator = document.createNodeIterator( + document.body, + NodeFilter.SHOW_COMMENT, + function (node) { + return node.nodeValue.includes(`hmr-start: ${file}`) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + } + ); + + while (nodeIterator.nextNode()) { + callback(file, nodeIterator.referenceNode); + } +} diff --git a/shopify-dev-utils/liquidDev.loader.js b/shopify-dev-utils/liquidDev.loader.js new file mode 100644 index 0000000..427441d --- /dev/null +++ b/shopify-dev-utils/liquidDev.loader.js @@ -0,0 +1,78 @@ +const loaderUtils = require('loader-utils'); +const path = require('path'); +const { Liquid } = require('liquidjs'); +const glob = require('glob'); +const { moneyWithoutTrailingZeros } = require('./filters/money_without_trailing_zeros'); +const { moneyWithCurrency } = require('./filters/money_with_currency'); +const { within } = require('./filters/within'); +const { defaultPagination } = require('./filters/default_pagination'); +const { scriptTag } = require('./filters/script_tag'); +const { stylesheetTag } = require('./filters/stylesheet_tag'); +const { assetUrl } = require('./filters/asset_url'); +const { getStoreGlobalData } = require('./storeData'); +const { liquidSectionTags } = require('./section-tags/index'); +const { Paginate } = require('./tags/paginate'); + +let engine; +let loadPromise; + +function initEngine() { + if (!loadPromise) { + loadPromise = new Promise(async (resolve) => { + const liquidFiles = [ + ...glob + .sync('./src/components/**/*.liquid') + .map((filePath) => + path.resolve(path.join(__dirname, '../'), path.dirname(filePath)) + ) + .reduce((set, dir) => { + set.add(dir); + return set; + }, new Set()), + ]; + + engine = new Liquid({ + root: liquidFiles, // root for layouts/includes lookup + extname: '.liquid', // used for layouts/includes, defaults "", + globals: await getStoreGlobalData(), + }); + + engine.registerFilter('asset_url', assetUrl); + engine.registerFilter('stylesheet_tag', stylesheetTag); + engine.registerFilter('script_tag', scriptTag); + engine.registerFilter('default_pagination', defaultPagination); + engine.registerFilter('within', within); + engine.registerFilter('money_with_currency', moneyWithCurrency); + engine.registerFilter('money_without_trailing_zeros', moneyWithoutTrailingZeros); + + engine.registerTag('paginate', Paginate); + engine.plugin(liquidSectionTags()); + + resolve(); + }); + } + + return loadPromise; +} + +module.exports = async function (content) { + if (this.cacheable) this.cacheable(); + const callback = this.async(); + + if (!engine) { + await initEngine(); + } + + engine.options.loaderOptions = loaderUtils.getOptions(this); + const { isSection } = engine.options.loaderOptions; + + // section handled specially + if (typeof isSection === 'function' && isSection(this.context)) { + const sectionName = path.basename(this.resourcePath, '.liquid'); + content = `{% section "${sectionName}" %}`; + } + + return engine + .parseAndRender(content, engine.options.loaderOptions.globals || {}) + .then((result) => callback(null, result)); +}; diff --git a/shopify-dev-utils/section-tags/index.d.ts b/shopify-dev-utils/section-tags/index.d.ts new file mode 100644 index 0000000..49e17b0 --- /dev/null +++ b/shopify-dev-utils/section-tags/index.d.ts @@ -0,0 +1,2 @@ +import { Liquid } from 'liquidjs'; +export declare function liquidSectionTags(): (this: Liquid) => void; diff --git a/shopify-dev-utils/section-tags/index.js b/shopify-dev-utils/section-tags/index.js new file mode 100644 index 0000000..d0f109e --- /dev/null +++ b/shopify-dev-utils/section-tags/index.js @@ -0,0 +1,16 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.liquidSectionTags = void 0; +const javascript_1 = require("./javascript"); +const schema_1 = require("./schema"); +const section_1 = require("./section"); +const stylesheet_1 = require("./stylesheet"); +function liquidSectionTags() { + return function () { + this.registerTag('section', section_1.Section); + this.registerTag('schema', schema_1.Schema); + this.registerTag('stylesheet', stylesheet_1.StyleSheet); + this.registerTag('javascript', javascript_1.JavaScript); + }; +} +exports.liquidSectionTags = liquidSectionTags; diff --git a/shopify-dev-utils/section-tags/javascript.d.ts b/shopify-dev-utils/section-tags/javascript.d.ts new file mode 100644 index 0000000..40a02a3 --- /dev/null +++ b/shopify-dev-utils/section-tags/javascript.d.ts @@ -0,0 +1,2 @@ +import { TagImplOptions } from 'liquidjs/dist/template/tag/tag-impl-options'; +export declare const JavaScript: TagImplOptions; diff --git a/shopify-dev-utils/section-tags/javascript.js b/shopify-dev-utils/section-tags/javascript.js new file mode 100644 index 0000000..2c89529 --- /dev/null +++ b/shopify-dev-utils/section-tags/javascript.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JavaScript = void 0; +exports.JavaScript = { + parse: function (tagToken, remainTokens) { + this.tokens = []; + const stream = this.liquid.parser.parseStream(remainTokens); + stream + .on('token', (token) => { + if (token.name === 'endjavascript') + stream.stop(); + else + this.tokens.push(token); + }) + .on('end', () => { + throw new Error(`tag ${tagToken.getText()} not closed`); + }); + stream.start(); + }, + render: function () { + const text = this.tokens.map((token) => token.getText()).join(''); + return ``; + } +}; diff --git a/shopify-dev-utils/section-tags/schema.d.ts b/shopify-dev-utils/section-tags/schema.d.ts new file mode 100644 index 0000000..da4f996 --- /dev/null +++ b/shopify-dev-utils/section-tags/schema.d.ts @@ -0,0 +1,2 @@ +import { TagImplOptions } from 'liquidjs/dist/template/tag/tag-impl-options'; +export declare const Schema: TagImplOptions; diff --git a/shopify-dev-utils/section-tags/schema.js b/shopify-dev-utils/section-tags/schema.js new file mode 100644 index 0000000..71744e2 --- /dev/null +++ b/shopify-dev-utils/section-tags/schema.js @@ -0,0 +1,45 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Schema = void 0; +function generateSettingsObj(settings) { + if (!Array.isArray(settings)) { + return settings; + } + return settings + .filter((entry) => !!entry.id) + .reduce((sectionSettings, entry) => { + sectionSettings[entry.id] = entry.default; + return sectionSettings; + }, {}); +} +exports.Schema = { + parse: function (tagToken, remainTokens) { + this.tokens = []; + const stream = this.liquid.parser.parseStream(remainTokens); + stream + .on('token', (token) => { + if (token.name === 'endschema') { + stream.stop(); + } + else + this.tokens.push(token); + }) + .on('end', () => { + throw new Error(`tag ${tagToken.getText()} not closed`); + }); + stream.start(); + }, + render: function (ctx) { + const json = this.tokens.map((token) => token.getText()).join(''); + const schema = JSON.parse(json); + const scope = ctx.scopes[ctx.scopes.length - 1]; + scope.section = { + settings: generateSettingsObj(schema.settings), + blocks: (schema.blocks || []).map((block) => ({ + ...block, + settings: generateSettingsObj(block.settings) + })) + }; + return ''; + } +}; diff --git a/shopify-dev-utils/section-tags/section.d.ts b/shopify-dev-utils/section-tags/section.d.ts new file mode 100644 index 0000000..7770b29 --- /dev/null +++ b/shopify-dev-utils/section-tags/section.d.ts @@ -0,0 +1,2 @@ +import { TagImplOptions } from 'liquidjs'; +export declare const Section: TagImplOptions; diff --git a/shopify-dev-utils/section-tags/section.js b/shopify-dev-utils/section-tags/section.js new file mode 100644 index 0000000..0baca53 --- /dev/null +++ b/shopify-dev-utils/section-tags/section.js @@ -0,0 +1,30 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Section = void 0; +const quoted = /^'[^']*'|"[^"]*"$/; +exports.Section = { + parse: function (token) { + this.namestr = token.args; + }, + render: function* (ctx, emitter) { + let name; + if (quoted.exec(this.namestr)) { + const template = this.namestr.slice(1, -1); + name = yield this.liquid._parseAndRender(template, ctx.getAll(), ctx.opts); + } + if (!name) + throw new Error('cannot include with empty filename'); + const templates = yield this.liquid._parseFile(name, ctx.opts, ctx.sync); + // Bubble up schema tag for allowing it's data available to the section + templates.sort((tagA) => { + return tagA.token.kind === 4 && + tagA.token.name === 'schema' + ? -1 + : 0; + }); + const scope = {}; + ctx.push(scope); + yield this.liquid.renderer.renderTemplates(templates, ctx, emitter); + ctx.pop(); + } +}; diff --git a/shopify-dev-utils/section-tags/stylesheet.d.ts b/shopify-dev-utils/section-tags/stylesheet.d.ts new file mode 100644 index 0000000..c09b425 --- /dev/null +++ b/shopify-dev-utils/section-tags/stylesheet.d.ts @@ -0,0 +1,2 @@ +import { TagImplOptions } from 'liquidjs'; +export declare const StyleSheet: TagImplOptions; diff --git a/shopify-dev-utils/section-tags/stylesheet.js b/shopify-dev-utils/section-tags/stylesheet.js new file mode 100644 index 0000000..b44809c --- /dev/null +++ b/shopify-dev-utils/section-tags/stylesheet.js @@ -0,0 +1,44 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.StyleSheet = void 0; +const sass_1 = require("sass"); +const quoted = /^'[^']*'|"[^"]*"$/; +const processors = { + '': (x) => x, + sass: sassProcessor, + scss: sassProcessor +}; +exports.StyleSheet = { + parse: function (token, remainTokens) { + this.processor = token.args; + this.tokens = []; + const stream = this.liquid.parser.parseStream(remainTokens); + stream + .on('token', (token) => { + if (token.name === 'endstylesheet') + stream.stop(); + else + this.tokens.push(token); + }) + .on('end', () => { + throw new Error(`tag ${token.getText()} not closed`); + }); + stream.start(); + }, + render: async function (ctx) { + let processor = ''; + if (quoted.exec(this.processor)) { + const template = this.processor.slice(1, -1); + processor = await this.liquid.parseAndRender(template, ctx.getAll(), ctx.opts); + } + const text = this.tokens.map((token) => token.getText()).join(''); + const p = processors[processor]; + if (!p) + throw new Error(`processor for ${processor} not found`); + const css = await p(text); + return ``; + } +}; +function sassProcessor(data) { + return new Promise((resolve, reject) => sass_1.render({ data }, (err, result) => err ? reject(err) : resolve('' + result.css))); +} diff --git a/shopify-dev-utils/storeData.js b/shopify-dev-utils/storeData.js new file mode 100644 index 0000000..1c9d8a4 --- /dev/null +++ b/shopify-dev-utils/storeData.js @@ -0,0 +1,68 @@ +const yaml = require('yaml'); +const fs = require('fs'); +const path = require('path'); +const { convertToGlobalDataStructure } = require('./convertToGlobalDataStructure'); +const { StorefrontApi } = require('./storefrontApi'); + +const configFile = path.join(__dirname, '../config.yml'); +let config = { token: '', baseURL: '' }; +if (fs.existsSync(configFile)) { + const configYml = yaml.parse(fs.readFileSync(configFile, 'utf-8')); + config.token = configYml.development.storefront_api_key; + config.baseURL = configYml.development.store; + + if (!config.token) { + console.warn(`'storefront_api_key' was not found in 'config.yml'`); + } +} + +function getGlobalSettings() { + const rawSettings = require('../src/config/settings_schema.json'); + + return rawSettings + .filter((section) => !!section.settings) + .reduce((result, section) => { + section.settings + .filter((setting) => !!setting.id && typeof setting.default !== 'undefined') + .forEach((setting) => { + result[setting.id] = setting.default; + }); + return result; + }, {}); +} + +async function getStoreGlobalData() { + const storefrontApi = new StorefrontApi(config); + + const data = await storefrontApi + .getStoreData() + .then(({ data }) => convertToGlobalDataStructure(data)); + + return { + shop: { + name: data.shop.name, + }, + settings: getGlobalSettings(), + linklists: { + 'main-menu': { + title: '', + levels: 1, + links: [ + { + title: 'Home', + url: '/', + links: [], + }, + { + title: 'Catalog', + url: '/collections/all', + links: [], + }, + ], + }, + }, + collection: data.collections[0], + }; +} + +module.exports.getStoreGlobalData = getStoreGlobalData; diff --git a/shopify-dev-utils/storefrontApi.js b/shopify-dev-utils/storefrontApi.js new file mode 100644 index 0000000..43febaf --- /dev/null +++ b/shopify-dev-utils/storefrontApi.js @@ -0,0 +1,79 @@ +const Axios = require('axios'); + +class StorefrontApi { + constructor({ baseURL, token }) { + console.log(`https://${baseURL}/api/2020-10/graphql`); + this.axios = Axios.create({ + baseURL: `https://${baseURL}/api/2020-10/graphql`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/graphql', + 'X-Shopify-Storefront-Access-Token': token, + }, + }); + } + + async getStoreData() { + return this.axios + .post( + '', + ` +{ + shop { + name + } + collections(first: 50) { + edges { + node { + id + title + handle + description + image(scale:1) { + id + altText + originalSrc + transformedSrc + } + products(first: 50) { + edges { + node { + id + title + description + handle + availableForSale + priceRange { + maxVariantPrice { + amount + currencyCode + } + minVariantPrice { + amount + currencyCode + } + } + images(first: 1) { + edges { + node { + id + altText + originalSrc + } + } + } + onlineStoreUrl + } + } + } + } + } + } +} +` + ) + .then(({ data }) => data); + } +} + +module.exports.StorefrontApi = StorefrontApi; diff --git a/shopify-dev-utils/tags/paginate.d.ts b/shopify-dev-utils/tags/paginate.d.ts new file mode 100644 index 0000000..34abbfe --- /dev/null +++ b/shopify-dev-utils/tags/paginate.d.ts @@ -0,0 +1,2 @@ +import { TagImplOptions } from 'liquidjs/dist/template/tag/tag-impl-options'; +export declare const Paginate: TagImplOptions; diff --git a/shopify-dev-utils/tags/paginate.js b/shopify-dev-utils/tags/paginate.js new file mode 100644 index 0000000..a1730fc --- /dev/null +++ b/shopify-dev-utils/tags/paginate.js @@ -0,0 +1,104 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Paginate = void 0; +const liquidjs_1 = require("liquidjs"); +function generatePaginateObj({ offset, perPage, total }) { + const pages = Math.ceil(total / perPage); + const currentPage = Math.floor((offset + perPage) / perPage); + const paginate = { + current_offset: offset, + current_page: currentPage, + items: total, + page_size: perPage, + parts: Array(pages) + .fill(0) + .map((_, index) => { + const page = index + 1; + if (page === currentPage) { + return { title: page, is_link: false }; + } + return { title: page, url: `?page=${page}`, is_link: true }; + }), + pages, + previous: undefined, + next: undefined + }; + if (currentPage === pages && pages > 1) { + paginate.previous = { + title: '\u0026laquo; Previous', + url: `?page=${currentPage - 1}`, + is_link: true + }; + } + else if (currentPage < pages && pages > 1) { + paginate.next = { + title: 'Next \u0026raquo;', + url: `?page=${currentPage + 1}`, + is_link: true + }; + } + return paginate; +} +function populateVariableObj({ list, originalValue, depth }) { + if (depth.length === 0) { + return list; + } + const clone = JSON.parse(JSON.stringify(originalValue)); + depth.reduce((result, prop, index) => { + const propName = prop.getText(); + if (index === depth.length - 1) { + result[propName] = list; + } + return result[propName] || {}; + }, clone); + return clone; +} +exports.Paginate = { + parse: function (tagToken, remainTokens) { + this.templates = []; + const stream = this.liquid.parser.parseStream(remainTokens); + stream + .on('start', () => { + const toknenizer = new liquidjs_1.Tokenizer(tagToken.args); + const list = toknenizer.readValue(); + const by = toknenizer.readWord(); + const perPage = toknenizer.readValue(); + liquidjs_1.assert(list.size() && + by.content === 'by' && + +perPage.getText() > 0 && + +perPage.getText() <= 50, () => `illegal tag: ${tagToken.getText()}`); + this.args = { list, perPage: +perPage.getText() }; + }) + .on('tag:endpaginate', () => stream.stop()) + .on('template', (tpl) => { + this.templates.push(tpl); + }) + .on('end', () => { + throw new Error(`tag ${tagToken.getText()} not closed`); + }); + stream.start(); + }, + render: function* (ctx, emitter) { + const list = yield liquidjs_1.evalToken(this.args.list, ctx) || []; + const perPage = this.args.perPage; + const currentPage = +ctx.get(['current_page']); + const offset = currentPage ? (currentPage - 1) * perPage : 0; + const variableName = this.args.list.getVariableAsText(); + const originalValue = ctx.get([variableName]); + const scopeList = list.slice(offset, offset + perPage); + const data = populateVariableObj({ + list: scopeList, + originalValue, + depth: this.args.list.props + }); + const paginate = generatePaginateObj({ + offset, + perPage, + total: list.length + }); + const scope = { [variableName]: data, paginate }; + ctx.push(scope); + yield this.liquid.renderer.renderTemplates(this.templates, ctx, emitter); + ctx.pop(); + } +}; diff --git a/shopify-dev-utils/transformLiquid.js b/shopify-dev-utils/transformLiquid.js new file mode 100644 index 0000000..23d80ed --- /dev/null +++ b/shopify-dev-utils/transformLiquid.js @@ -0,0 +1,31 @@ +const path = require('path'); + +module.exports.transformLiquid = function transformLiquid(publicPath) { + return (content, absolutePath) => { + const relativePath = path.join(__dirname, '../src'); + const diff = path.relative(relativePath, absolutePath); + + content = content + .toString() + .replace( + /{{\s*'([^']+)'\s*\|\s*asset_url\s*\|\s*(stylesheet_tag|script_tag)\s*}}/g, + function (matched, fileName, type) { + if (type === 'stylesheet_tag') { + if (fileName !== 'tailwind.min.css') { + return ''; + } + return matched; + } + + return ``; + } + ); + + if(diff.includes('/layout/theme.liquid')) { + // inject HMR entry bundle + content = content.replace('',``) + } + + return `${content}`; + }; +} diff --git a/src/components/layout/theme.liquid b/src/components/layout/theme.liquid index 67cc6d3..1e8cc9e 100644 --- a/src/components/layout/theme.liquid +++ b/src/components/layout/theme.liquid @@ -31,9 +31,11 @@ {%- comment -%}Varibles{%- endcomment -%} {{ content_for_header }} {% include 'global-css' %} - + {{ 'tailwind.min.css' | asset_url | stylesheet_tag }} {{ 'bundle.global-css.css' | asset_url | stylesheet_tag }} + {{ 'bundle.runtime.js' | asset_url | script_tag }} + {{ 'bundle.liquidDev.js' | asset_url | script_tag }} {{ 'bundle.theme.js' | asset_url | script_tag }} diff --git a/src/components/templates/collection.liquid b/src/components/templates/collection.liquid index 3940917..5643b18 100644 --- a/src/components/templates/collection.liquid +++ b/src/components/templates/collection.liquid @@ -1,16 +1,16 @@
{% paginate collection.products by 8 %} -

{{ collection.title }}

-
+

{{ collection.title }}

+
\ No newline at end of file + diff --git a/src/components/templates/list-collections.liquid b/src/components/templates/list-collections.liquid index 23c9d3f..d5cdcfe 100644 --- a/src/components/templates/list-collections.liquid +++ b/src/components/templates/list-collections.liquid @@ -33,4 +33,4 @@ {%- endif -%} {%- endif -%} {%- endpaginate -%} - \ No newline at end of file + diff --git a/src/config/settings_schema.json b/src/config/settings_schema.json index d23f356..976b8f7 100644 --- a/src/config/settings_schema.json +++ b/src/config/settings_schema.json @@ -58,4 +58,4 @@ } ] } -] \ No newline at end of file +] diff --git a/webpack.server.js b/webpack.server.js index 5c6d350..712d5be 100644 --- a/webpack.server.js +++ b/webpack.server.js @@ -1,74 +1,105 @@ -const { merge } = require('webpack-merge'); +const { merge } = require('we' + 'bpack-merge'); +const glob = require('glob'); const common = require('./webpack.common.js'); const path = require('path'); const CopyPlugin = require('copy-webpack-plugin'); const WebpackShellPluginNext = require('webpack-shell-plugin-next'); +const { transformLiquid } = require('./shopify-dev-utils/transformLiquid'); const port = 9000; const publicPath = `https://localhost:${port}/`; - module.exports = merge(common, { - mode: 'development', - devtool: 'inline-source-map', - output: { - filename: './assets/bundle.[name].js', - hotUpdateChunkFilename: './hot/[id].[fullhash].hot-update.js', - hotUpdateMainFilename: './hot/[fullhash].hot-update.json', +module.exports = merge(common, { + mode: 'development', + devtool: 'inline-source-map', + entry: glob.sync('./src/components/**/*.js').reduce( + (acc, path) => { + const entry = path.replace(/^.*[\\\/]/, '').replace('.js', ''); + acc[entry] = path; + return acc; + }, + { liquidDev: './shopify-dev-utils/liquidDev.entry.js' } + ), + output: { + filename: 'assets/bundle.[name].js', + hotUpdateChunkFilename: 'hot/[id].[fullhash].hot-update.js', + hotUpdateMainFilename: 'hot/[fullhash].hot-update.json', path: path.resolve(__dirname, 'dist'), publicPath, }, + cache: false, + optimization: { + runtimeChunk: { name: 'runtime' }, + }, module: { - rules: [ - { - test: /\.(sc|sa|c)ss$/, - use: [ - 'style-loader', - { - loader: 'css-loader', - options: { - url: false, + rules: [ + { + test: /\.liquid$/, + use: [ + { loader: 'raw-loader', options: { esModule: false } }, + { + loader: path.resolve(__dirname, 'shopify-dev-utils/liquidDev.loader.js'), + options: { + publicPath, + isSection(liquidPath) { + const diff = path.relative( + path.join(__dirname, './src/components/'), + liquidPath + ); + const componentType = diff.split(path.sep).shift(); + return componentType === 'sections'; + }, + }, }, - }, - 'postcss-loader', - { - loader: 'sass-loader', - options: { - sourceMap: true, + ], + }, + { + test: /\.(sc|sa|c)ss$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + url: false, + }, }, - }, - ], - }, - ], -}, + 'postcss-loader', + { + loader: 'sass-loader', + options: { + sourceMap: true, + }, + }, + ], + }, + ], + }, plugins: [ - new WebpackShellPluginNext({ - onBuildStart: { - scripts: [ - 'echo -- Webpack build started 🛠', - 'shopify-themekit watch --env=server', - ], - blocking: false, - parallel: true, - }, - onBuildError: { - scripts: ['echo -- ☠️ Aw snap, Webpack build failed...'], - }, - onBuildEnd: { - scripts: [ - 'echo -- Webpack build complete ✓', - 'echo -- Building TailwindCSS...', - 'npx tailwindcss build src/components/tailwind.css -o dist/assets/tailwind.css.liquid', - 'echo -- Minifying TailwindCSS', - 'cleancss -o dist/assets/tailwind.min.css.liquid dist/assets/tailwind.css.liquid', - 'echo -- Deploying to theme ✈️', - 'shopify-themekit deploy --env=server', - 'echo -- Deployment competed ✓', - 'shopify-themekit open', - ], - blocking: true, - parallel: false, - }, - }), + new WebpackShellPluginNext({ + onBuildStart: { + scripts: ['echo -- Webpack build started 🛠', 'shopify-themekit watch --env=server'], + blocking: false, + parallel: true, + }, + onBuildError: { + scripts: ['echo -- ☠️ Aw snap, Webpack build failed...'], + }, + onBuildEnd: { + scripts: [ + 'echo -- Webpack build complete ✓', + 'echo -- Building TailwindCSS...', + 'npx tailwindcss build src/components/tailwind.css -o dist/assets/tailwind.css.liquid', + 'echo -- Minifying TailwindCSS', + 'cleancss -o dist/assets/tailwind.min.css.liquid dist/assets/tailwind.css.liquid', + 'echo -- Deploying to theme ✈️', + 'shopify-themekit deploy --env=server', + 'echo -- Deployment competed ✓', + 'shopify-themekit open', + ], + blocking: true, + parallel: false, + }, + }), new CopyPlugin({ patterns: [ { @@ -81,25 +112,7 @@ const publicPath = `https://localhost:${port}/`; const targetFolder = diff.split(path.sep)[0]; return path.join(targetFolder, path.basename(absolutePath)); }, - transform: function (content) { - content = content - .toString() - .replace( - /{{\s*'([^']+)'\s*\|\s*asset_url\s*\|\s*(stylesheet_tag|script_tag)\s*}}/g, - function (matched, fileName, type) { - if (type === 'stylesheet_tag') { - if (fileName !== 'tailwind.min.css') { - return ''; - } - return matched; - } - - return ``; - } - ); - - return content; - } + transform: transformLiquid(publicPath), }, { from: 'src/assets/**/*', @@ -130,8 +143,7 @@ const publicPath = `https://localhost:${port}/`; https: true, disableHostCheck: true, hot: true, - liveReload: false, overlay: true, writeToDisk: true, }, - }); +});