diff --git a/.gitignore b/.gitignore index 2be74316..9fe87f95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ sample-apps/*/public/build/* !sample-apps/**/shopify.extension.toml !sample-apps/**/locales/*.json dev.sqlite +shopify.app.toml diff --git a/README.md b/README.md index b02ecb8f..872c02fc 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,29 @@ yarn expand-liquid vanilla-js yarn expand-liquid typescript ``` +### Update API Versions and Function Schemas + +To update API versions and function schemas automatically: + +```shell +# Step 1: Link to a Shopify app to create shopify.app.toml with client_id +shopify app config link + +# Step 2: Generate/update the extension directories list in shopify.app.toml +yarn generate-app + +# Step 3: Run the comprehensive update command +yarn update-api-version +``` + +This process: +1. First, links to a Shopify app to create shopify.app.toml with the client ID +2. Then adds all extension directories to the same file (preserving the client_id) +3. Finally, runs a sequence of commands that: + - Updates API versions across all extensions + - Expands liquid templates + - Updates function schemas + ### Run Tests ```shell @@ -34,3 +57,19 @@ cargo test cargo fmt cargo clippy -- -D warnings ``` + +### Update Dependencies + +To check and update JavaScript dependencies in all package.json.liquid files: + +```shell +yarn check-js-dependencies +``` + +To check and update Rust dependencies in all Cargo.toml and Cargo.toml.liquid files: + +```shell +yarn check-rust-dependencies +``` + +These utilities will fetch the latest versions from npm and crates.io respectively and update your templates. diff --git a/package.json b/package.json index 3677e6ae..ed980ed8 100644 --- a/package.json +++ b/package.json @@ -3,22 +3,31 @@ "version": "1.0.0", "type": "module", "devDependencies": { - "@iarna/toml": "^2.2.5", - "fast-glob": "^3.2.11", - "liquidjs": "^9.37.0", "@graphql-codegen/cli": "^3.2.2", "@graphql-codegen/typescript": "^3.0.2", "@graphql-codegen/typescript-operations": "^3.0.2", - "graphql": "^16.6.0" + "@iarna/toml": "^2.2.5", + "@shopify/toml-patch": "^1.0.0", + "dayjs": "^1.11.11", + "fast-glob": "^3.2.11", + "graphql": "^16.6.0", + "liquidjs": "^9.37.0" }, "scripts": { "expand-liquid": "node ./util/expand-liquid.js", "typegen": "yarn workspaces run graphql-code-generator --config package.json", "test-js": "yarn expand-liquid vanilla-js && yarn && yarn typegen && yarn workspaces run test run", - "test-ts": "yarn expand-liquid typescript && yarn && yarn typegen && yarn workspaces run test run" + "test-ts": "yarn expand-liquid typescript && yarn && yarn typegen && yarn workspaces run test run", + "check-api-version": "node ./util/check-api-version.js", + "check-js-dependencies": "node ./util/check-js-dependencies.js", + "check-rust-dependencies": "node ./util/check-rust-dependencies.js", + "check-rust": "yarn check-rust-dependencies && yarn expand-liquid && cargo test", + "generate-app": "node ./util/generate-app.js", + "update-schemas": "node ./util/update-schemas.js", + "update-api-version": "yarn check-api-version && yarn expand-liquid && yarn update-schemas" }, "private": true, "workspaces": [ "*/javascript/**" ] -} +} \ No newline at end of file diff --git a/util/check-api-version.js b/util/check-api-version.js new file mode 100644 index 00000000..b8b77443 --- /dev/null +++ b/util/check-api-version.js @@ -0,0 +1,114 @@ +import fs from 'fs/promises'; +import fastGlob from 'fast-glob'; +import dayjs from 'dayjs'; +import { updateTomlValues } from '@shopify/toml-patch'; + +const ROOT_DIR = '.'; +const FILE_PATTERN = '**/shopify.extension.toml.liquid'; +const LIQUID_PLACEHOLDER = 'LIQUID_PLACEHOLDER'; + +// Method to get the latest API version based on today's date +function getLatestApiVersion() { + const date = dayjs(); + const year = date.year(); + const month = date.month(); + const quarter = Math.floor(month / 3); + + const monthNum = quarter * 3 + 1; + const paddedMonth = String(monthNum).padStart(2, '0'); + + return `${year}-${paddedMonth}`; +} + +// Method to find all shopify.extension.toml.liquid files +async function findAllExtensionFiles() { + return fastGlob(FILE_PATTERN, { cwd: ROOT_DIR, absolute: true }); +} + +// Function to preprocess liquid syntax +function preprocessLiquidSyntax(content) { + const liquidExpressions = []; + const placeholderContent = content.replace(/\{\{.*?\}\}|\{%\s.*?\s%\}/g, (match) => { + liquidExpressions.push(match); + return `{${LIQUID_PLACEHOLDER}:${liquidExpressions.length - 1}}`; + }); + return { placeholderContent, liquidExpressions }; +} + +// Function to restore liquid syntax +function restoreLiquidSyntax(content, liquidExpressions) { + return content.replace(new RegExp(`\\{${LIQUID_PLACEHOLDER}:(\\d+)\\}`, 'g'), (match, index) => { + return liquidExpressions[Number(index)]; + }); +} + +// Method to update the API version in the file using toml-patch +async function updateApiVersion(filePath, latestVersion) { + try { + const content = await fs.readFile(filePath, 'utf8'); + + // Handle liquid templates if needed + const isLiquidFile = filePath.endsWith('.liquid'); + let liquidExpressions = []; + let processedContent = content; + + if (isLiquidFile) { + const processed = preprocessLiquidSyntax(content); + processedContent = processed.placeholderContent; + liquidExpressions = processed.liquidExpressions; + } + + // Use toml-patch to update the API version + const updates = [ + [['api_version'], latestVersion] + ]; + + let updatedContent = updateTomlValues(processedContent, updates); + + // Restore liquid syntax if needed + if (isLiquidFile) { + updatedContent = restoreLiquidSyntax(updatedContent, liquidExpressions); + } + + await fs.writeFile(filePath, updatedContent, 'utf8'); + console.log(`Updated API version in ${filePath} to ${latestVersion}`); + + } catch (error) { + console.error(`Error updating API version in ${filePath}:`, error.message); + } +} + +// Main method to check and update API versions +async function checkAndUpdateApiVersions() { + const latestVersion = getLatestApiVersion(); + console.log(`Latest API version: ${latestVersion}`); + const extensionFiles = await findAllExtensionFiles(); + console.log(`Found ${extensionFiles.length} extension files to check`); + + for (const filePath of extensionFiles) { + try { + const content = await fs.readFile(filePath, 'utf8'); + const match = content.match(/api_version\s*=\s*"(\d{4}-\d{2})"/); + + if (match) { + const currentVersion = match[1]; + + if (currentVersion !== latestVersion) { + console.log(`Updating ${filePath} from ${currentVersion} to ${latestVersion}`); + await updateApiVersion(filePath, latestVersion); + } else { + console.log(`API version in ${filePath} is already up to date (${currentVersion}).`); + } + } else { + console.warn(`No API version found in ${filePath}`); + } + } catch (error) { + console.error(`Error processing ${filePath}:`, error.message); + } + } +} + +checkAndUpdateApiVersions().catch(error => { + console.error('Error checking and updating API versions:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/util/check-js-dependencies.js b/util/check-js-dependencies.js new file mode 100644 index 00000000..1dc23897 --- /dev/null +++ b/util/check-js-dependencies.js @@ -0,0 +1,58 @@ +import fs from 'fs/promises'; +import path from 'path'; +import fastGlob from 'fast-glob'; +import { execSync } from 'child_process'; + +const ROOT_DIR = '.'; +const FILE_PATTERN = '**/package.json.liquid'; + +async function findAllPackageFiles() { + return fastGlob(FILE_PATTERN, { cwd: ROOT_DIR, absolute: true }); +} + +async function getLatestVersion(packageName) { + try { + // Fetch the latest version of a package from the npm registry + const output = execSync(`npm show ${packageName} version`, { encoding: 'utf8' }); + return output.trim(); + } catch (error) { + console.warn(`Could not fetch version for package ${packageName}:`, error.message); + return null; + } +} + +async function checkAndUpdateDependencies(filePath) { + const content = await fs.readFile(filePath, 'utf8'); + const jsonContent = JSON.parse(content); + + const { dependencies = {}, devDependencies = {} } = jsonContent; + + const updateDependencyVersion = async (dependencies) => { + for (const [name, currentVersion] of Object.entries(dependencies)) { + const latestVersion = await getLatestVersion(name); + if (latestVersion && currentVersion !== `^${latestVersion}`) { + console.log(`Updating ${name} from ${currentVersion} to ^${latestVersion}`); + dependencies[name] = `^${latestVersion}`; + } + } + }; + + await updateDependencyVersion(dependencies); + await updateDependencyVersion(devDependencies); + + const updatedContent = JSON.stringify(jsonContent, null, 2); + await fs.writeFile(filePath, updatedContent, 'utf8'); + console.log(`Updated dependencies in ${filePath}`); +} + +async function main() { + const packageFiles = await findAllPackageFiles(); + for (const filePath of packageFiles) { + await checkAndUpdateDependencies(filePath); + } +} + +main().catch(error => { + console.error('Error checking and updating dependencies:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/util/check-rust-dependencies.js b/util/check-rust-dependencies.js new file mode 100644 index 00000000..a6b92a20 --- /dev/null +++ b/util/check-rust-dependencies.js @@ -0,0 +1,142 @@ +import fs from 'fs/promises'; +import fastGlob from 'fast-glob'; +import { updateTomlValues } from '@shopify/toml-patch'; + +const ROOT_DIR = '.'; +const FILE_PATTERNS = ['**/Cargo.toml', '**/Cargo.toml.liquid']; +const LIQUID_PLACEHOLDER = 'LIQUID_PLACEHOLDER'; + +async function findAllCargoFiles() { + return fastGlob(FILE_PATTERNS, { cwd: ROOT_DIR, absolute: true }); +} + +async function getLatestVersion(packageName) { + try { + // Fetch the latest version of a package from crates.io + const response = await fetch(`https://crates.io/api/v1/crates/${packageName}`); + const jsonResponse = await response.json(); + return jsonResponse.crate.max_version; + } catch (error) { + console.warn(`Could not fetch version for package ${packageName}:`, error.message); + return null; + } +} + +async function updateDependencyVersion(name, currentVersion) { + const latestVersion = await getLatestVersion(name); + + if (latestVersion) { + if (typeof currentVersion === 'string') { + if (!currentVersion.includes(latestVersion)) { + console.log(`Updating ${name} from ${currentVersion} to ${latestVersion}`); + return latestVersion; + } + } else if (typeof currentVersion === 'object' && 'version' in currentVersion) { + if (!currentVersion.version.includes(latestVersion)) { + console.log(`Updating ${name} from ${currentVersion.version} to ${latestVersion}`); + return { ...currentVersion, version: latestVersion }; + } + } + } + return currentVersion; +} + +function preprocessLiquidSyntax(content) { + const liquidExpressions = []; + const placeholderContent = content.replace(/\{\{.*?\}\}|\{%\s.*?\s%\}/g, (match) => { + liquidExpressions.push(match); + return `{${LIQUID_PLACEHOLDER}:${liquidExpressions.length - 1}}`; + }); + return { placeholderContent, liquidExpressions }; +} + +function restoreLiquidSyntax(content, liquidExpressions) { + return content.replace(new RegExp(`\\{${LIQUID_PLACEHOLDER}:(\\d+)\\}`, 'g'), (match, index) => { + return liquidExpressions[Number(index)]; + }); +} + +async function checkAndUpdateDependencies(filePath) { + let content = await fs.readFile(filePath, 'utf8'); + + const isLiquidFile = filePath.endsWith('.liquid'); + let liquidExpressions = []; + + if (isLiquidFile) { + const processed = preprocessLiquidSyntax(content); + content = processed.placeholderContent; + liquidExpressions = processed.liquidExpressions; + } + + try { + // Get all dependencies and their latest versions + const updates = []; + + // Simple regex to extract dependencies section + const depsMatch = content.match(/\[dependencies\]([\s\S]*?)(\[|\Z)/); + if (depsMatch) { + const depsSection = depsMatch[1]; + // Extract dependency names with a simple regex + const depMatches = depsSection.matchAll(/^([a-zA-Z0-9_-]+)\s*=\s*("[^"]*"|{[^}]*})/gm); + + for (const match of depMatches) { + const name = match[1].trim(); + const currentDef = match[2]; + + // Only process string dependencies for now + if (currentDef.startsWith('"')) { + const currentVersion = currentDef.replace(/"/g, ''); + const latestVersion = await getLatestVersion(name); + + if (latestVersion && !currentVersion.includes(latestVersion)) { + console.log(`Updating ${name} from ${currentVersion} to ${latestVersion}`); + updates.push([['dependencies', name], latestVersion]); + } + } else if (currentDef.startsWith('{')) { + // Extract version from table syntax like { version = "1.0.0", features = ["derive"] } + const versionMatch = currentDef.match(/version\s*=\s*"([^"]*)"/); + if (versionMatch) { + const currentVersion = versionMatch[1]; + const latestVersion = await getLatestVersion(name); + + if (latestVersion && !currentVersion.includes(latestVersion)) { + console.log(`Updating ${name} from ${currentVersion} to ${latestVersion}`); + // For table dependencies, we need to update just the version field + // This approach preserves other fields like features + const newDef = currentDef.replace(/version\s*=\s*"[^"]*"/, `version = "${latestVersion}"`); + updates.push([['dependencies', name], newDef.replace(/[{}]/g, '').trim()]); + } + } + } + } + } + + // Only update the file if we have changes + if (updates.length > 0) { + let updatedContent = updateTomlValues(content, updates); + + if (isLiquidFile) { + updatedContent = restoreLiquidSyntax(updatedContent, liquidExpressions); + } + + await fs.writeFile(filePath, updatedContent, 'utf8'); + console.log(`Updated dependencies in ${filePath}`); + } else { + console.log(`No updates needed for ${filePath}`); + } + } catch (error) { + console.error(`Failed to update TOML in file: ${filePath}`, error.message); + } +} + +async function main() { + const cargoFiles = await findAllCargoFiles(); + for (const filePath of cargoFiles) { + await checkAndUpdateDependencies(filePath); + } +} + +main().catch(error => { + console.error('Error checking and updating dependencies:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/util/expand-liquid.js b/util/expand-liquid.js index bcd4031e..6abddd6c 100644 --- a/util/expand-liquid.js +++ b/util/expand-liquid.js @@ -8,7 +8,7 @@ import { Liquid } from 'liquidjs'; import path from 'path'; import fs from 'node:fs/promises'; import { existsSync } from 'fs'; -import toml from '@iarna/toml'; +import toml from '@iarna/toml'; // For parsing only, no manipulation import { exec } from 'child_process'; async function expandLiquidTemplates(template, liquidData) { @@ -62,6 +62,7 @@ async function directoryNames(parentPath) { async function expandExtensionLiquidTemplates(domainName, flavor) { console.log(`Expanding liquid templates for ${domainName}`); const domainPath = path.join(process.cwd(), domainName); + let handleCounter = 1; // Counter for potentially truncated handles within this domain const langNames = await directoryNames(domainPath); for (const langName of langNames) { @@ -79,18 +80,36 @@ async function expandExtensionLiquidTemplates(domainName, flavor) { await (await glob(path.join(templatePath, 'src', '!(*.liquid|*.graphql)'))).forEach(async (path) => await fs.rm(path)); } + let baseHandle = `${langName}-${domainName}-${extensionTypeName}-${templateName}`; + let handle; + + if (baseHandle.length > 30) { + // Truncate to leave space for a counter suffix (e.g., '-99') + const maxBaseLength = 27; + // Remove trailing hyphen if present after slicing, then add counter + handle = `${baseHandle.slice(0, maxBaseLength).replace(/-$/, '')}-${handleCounter}`; + // Ensure final handle does not exceed 30 characters (safeguard) + if (handle.length > 30) { + handle = handle.slice(0, 30); + } + handleCounter++; // Increment counter only when a handle is truncated + } else { + // Use the base handle if it's within the length limit + handle = baseHandle; + } + const liquidData = { - name: `${domainName}-${extensionTypeName}-${templateName}`, - handle: `${domainName}-${extensionTypeName}-${templateName}`, + name: `${langName}-${domainName}-${extensionTypeName}-${templateName}`, // Keep original descriptive name + handle, // Use the potentially modified handle flavor, }; await expandLiquidTemplates(templatePath, liquidData); if (langName === "javascript") { - const srcFilePaths = await glob(path.join(templatePath, 'src', '!(*.liquid|*.graphql)')) - const srcFileExtensionsToChange = [] - + const srcFilePaths = await glob(path.join(templatePath, 'src', '!(*.liquid|*.graphql)')); + const srcFileExtensionsToChange = []; + const fileExtension = flavor === "typescript" ? "ts" : "js"; for (const srcFilePath of srcFilePaths) { @@ -99,7 +118,7 @@ async function expandExtensionLiquidTemplates(domainName, flavor) { })); } - await Promise.all(srcFileExtensionsToChange) + await Promise.all(srcFileExtensionsToChange); } } } @@ -141,4 +160,4 @@ console.log('The above files should be added to .gitignore if they have not alre if (process.env.CI) { ensureNoGitChanges(); -} +} \ No newline at end of file diff --git a/util/generate-app.js b/util/generate-app.js new file mode 100644 index 00000000..291acd6a --- /dev/null +++ b/util/generate-app.js @@ -0,0 +1,76 @@ +import fs from 'fs/promises'; +import path from 'path'; +import fastGlob from 'fast-glob'; +import { existsSync } from 'fs'; +import { updateTomlValues } from '@shopify/toml-patch'; + +const ROOT_DIR = '.'; +const FILE_PATTERN = '**/shopify.extension.toml.liquid'; +const EXCLUDED_DIRS = ['samples', 'sample-apps', 'node_modules']; +const OUTPUT_FILE = 'shopify.app.toml'; + +// Method to find all shopify.extension.toml.liquid files excluding specified directories +async function findAllExtensionFiles() { + return fastGlob(FILE_PATTERN, { + cwd: ROOT_DIR, + absolute: true, + ignore: EXCLUDED_DIRS.map(dir => `${dir}/**`) + }); +} + +// Method is no longer needed as we use toml-patch's array handling + +// Method to read existing shopify.app.toml if it exists +async function readExistingToml() { + try { + if (existsSync(OUTPUT_FILE)) { + return await fs.readFile(OUTPUT_FILE, 'utf8'); + } + return null; + } catch (error) { + console.error(`Error reading ${OUTPUT_FILE}:`, error); + return null; + } +} + +// Main method to update or create the shopify.app.toml file +async function updateAppToml() { + try { + const extensionFiles = await findAllExtensionFiles(); + + // Transform paths to be relative to root and exclude the filenames + const extensionDirectories = extensionFiles.map(filePath => path.relative(ROOT_DIR, path.dirname(filePath))); + + // Remove duplicates + const uniqueDirectories = [...new Set(extensionDirectories)]; + + // Read existing content + const existingContent = await readExistingToml(); + + // Require an existing shopify.app.toml file + if (!existingContent) { + throw new Error(`${OUTPUT_FILE} not found. Please run 'shopify app config link' first to create the file.`); + } + + // Use existing file as the base + const baseToml = existingContent; + + // Update the TOML content with extension directories + const updatedContent = updateTomlValues(baseToml, [ + [['extension_directories'], uniqueDirectories], + [['web_directories'], []] + ]); + + // Write the updated content to the file + await fs.writeFile(OUTPUT_FILE, updatedContent, 'utf8'); + console.log(`Updated ${OUTPUT_FILE} with ${uniqueDirectories.length} extension directories`); + } catch (error) { + console.error(`Error updating ${OUTPUT_FILE}:`, error); + throw error; + } +} + +updateAppToml().catch(error => { + console.error('Error updating shopify.app.toml file:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/util/update-schemas.js b/util/update-schemas.js new file mode 100644 index 00000000..7d502862 --- /dev/null +++ b/util/update-schemas.js @@ -0,0 +1,93 @@ +import fs from 'fs/promises'; +import { exec } from 'child_process'; +import path from 'path'; +import util from 'util'; +import { existsSync } from 'fs'; +import toml from '@iarna/toml'; + +const execPromise = util.promisify(exec); +const APP_TOML_FILE = 'shopify.app.toml'; +const COMMAND_TEMPLATE = 'shopify app function schema'; + +// Method to read shopify.app.toml and extract needed configuration +async function getConfig() { + try { + if (!existsSync(APP_TOML_FILE)) { + throw new Error(`${APP_TOML_FILE} does not exist. Run 'shopify app config link' first.`); + } + + const content = await fs.readFile(APP_TOML_FILE, 'utf8'); + + // Parse the TOML content + const parsedToml = toml.parse(content); + + const config = { + clientId: '', + directories: [] + }; + + // Extract client_id if it exists + if (parsedToml.client_id) { + config.clientId = parsedToml.client_id; + } + + // Extract extension directories if they exist + if (parsedToml.extension_directories && Array.isArray(parsedToml.extension_directories)) { + // Filter the directories to ensure they exist + config.directories = parsedToml.extension_directories.filter(dir => { + const exists = existsSync(dir); + if (!exists) { + console.warn(`Directory specified in config does not exist: ${dir}`); + } + return exists; + }); + } + + return config; + } catch (error) { + console.error(`Error reading ${APP_TOML_FILE}:`, error); + throw error; + } +} + +// Method to run the command for each directory +async function updateSchemas() { + try { + const config = await getConfig(); + + if (!config.clientId) { + throw new Error('Client ID not found in shopify.app.toml'); + } + + if (config.directories.length === 0) { + console.warn('No valid extension directories found in shopify.app.toml'); + return; + } + + console.log(`Found ${config.directories.length} extension directories`); + console.log(`Using client ID: ${config.clientId}`); + + for (const dir of config.directories) { + try { + const command = `${COMMAND_TEMPLATE} --path ${dir}`; + console.log(`\nUpdating schema for: ${dir}`); + console.log(`Running: ${command}`); + + const { stdout, stderr } = await execPromise(command); + if (stdout) console.log(`Output: ${stdout.trim()}`); + if (stderr && !stderr.includes('warning')) console.error(`Error: ${stderr.trim()}`); + } catch (error) { + console.error(`Failed to update schema for ${dir}:`, error.message); + } + } + + console.log("\nSchema update completed"); + } catch (error) { + console.error('Failed to update schemas:', error); + } +} + +updateSchemas().catch(error => { + console.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file