diff --git a/bin/docs/build-dev-docs.sh b/bin/docs/build-dev-docs.sh index 4f60089457e..d38407603f0 100644 --- a/bin/docs/build-dev-docs.sh +++ b/bin/docs/build-dev-docs.sh @@ -1,17 +1,46 @@ echo "STARTING" -COMPILE_DOCS="npx tsc --project bin/docs/tsconfig.docs.json --moduleResolution node --target esNext && npx generate-docs --overridePath ./bin/docs/typeOverride.json --input ./docs-shopify.dev/commands --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/commands/**/*.doc.js docs-shopify.dev/commands/*.doc.js" + +# Check if schema docs exist (generated by `pnpm generate-schema-docs`) +HAS_SCHEMA_DOCS=false +if [ -d "docs-shopify.dev/configuration" ] && [ "$(ls -A docs-shopify.dev/configuration/*.doc.ts 2>/dev/null)" ]; then + HAS_SCHEMA_DOCS=true +fi + +# Step 1: Compile TypeScript for all doc files (commands + configuration if present) +COMPILE_DOCS_TS="npx tsc --project bin/docs/tsconfig.docs.json --moduleResolution node --target esNext" +# Step 2: Run generate-docs with all inputs at once so they end up in a single output file +GENERATE_DOCS_INPUT="./docs-shopify.dev/commands" +CLEANUP="rm -rf docs-shopify.dev/commands/**/*.doc.js docs-shopify.dev/commands/*.doc.js" + COMPILE_STATIC_PAGES="npx tsc docs-shopify.dev/static/*.doc.ts --moduleResolution node --target esNext && npx generate-docs --isLandingPage --input ./docs-shopify.dev/static --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/static/*.doc.js" -COMPILE_CATEGORY_PAGES="npx tsc docs-shopify.dev/categories/*.doc.ts --moduleResolution node --target esNext && generate-docs --isCategoryPage --input ./docs-shopify.dev/categories --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/categories/*.doc.js" +COMPILE_CATEGORY_PAGES="npx tsc docs-shopify.dev/categories/*.doc.ts --moduleResolution node --target esNext && npx generate-docs --isCategoryPage --input ./docs-shopify.dev/categories --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/categories/*.doc.js" + +OUTPUT_DIR="./docs-shopify.dev/generated" if [ "$1" = "isTest" ]; then -COMPILE_DOCS="npx tsc --project bin/docs/tsconfig.docs.json --moduleResolution node --target esNext && npx generate-docs --overridePath ./bin/docs/typeOverride.json --input ./docs-shopify.dev/commands --output ./docs-shopify.dev/static/temp && rm -rf docs-shopify.dev/commands/**/*.doc.js docs-shopify.dev/commands/*.doc.js" +OUTPUT_DIR="./docs-shopify.dev/static/temp" COMPILE_STATIC_PAGES="npx tsc docs-shopify.dev/static/*.doc.ts --moduleResolution node --target esNext && npx generate-docs --isLandingPage --input ./docs-shopify.dev/static/docs-shopify.dev --output ./docs-shopify.dev/static/temp && rm -rf docs-shopify.dev/static/*.doc.js" fi echo $1 echo "RUNNING" -eval $COMPILE_DOCS + +# Compile all doc TypeScript (tsconfig.docs.json includes both commands and configuration) +eval $COMPILE_DOCS_TS + +# Add configuration docs to input if present +if [ "$HAS_SCHEMA_DOCS" = true ]; then + GENERATE_DOCS_INPUT="./docs-shopify.dev/commands ./docs-shopify.dev/configuration" + CLEANUP="$CLEANUP && rm -rf docs-shopify.dev/configuration/**/*.doc.js docs-shopify.dev/configuration/*.doc.js" +fi + +# Generate all reference entity docs in a single pass +# Note: $GENERATE_DOCS_INPUT is intentionally unquoted — it may contain two space-separated +# paths that must split into separate arguments for --input. +npx generate-docs --overridePath ./bin/docs/typeOverride.json --input $GENERATE_DOCS_INPUT --output $OUTPUT_DIR +eval $CLEANUP + eval $COMPILE_STATIC_PAGES eval $COMPILE_CATEGORY_PAGES echo "DONE" diff --git a/bin/docs/generate-schema-docs.js b/bin/docs/generate-schema-docs.js new file mode 100644 index 00000000000..c851e871498 --- /dev/null +++ b/bin/docs/generate-schema-docs.js @@ -0,0 +1,13 @@ + +import {join} from 'node:path' + +import {generateSchemaDocs} from '../../packages/app/dist/cli/services/docs/generate-schema-docs.js' + +const clientId = process.argv[2] +if (!clientId) { + console.error('Usage: node bin/docs/generate-schema-docs.js ') + process.exit(1) +} + +const basePath = join(process.cwd(), 'docs-shopify.dev/configuration') +await generateSchemaDocs(basePath, clientId) diff --git a/bin/docs/tsconfig.docs.json b/bin/docs/tsconfig.docs.json index d310bc2c21a..13510e218b6 100644 --- a/bin/docs/tsconfig.docs.json +++ b/bin/docs/tsconfig.docs.json @@ -1,9 +1,10 @@ { "compilerOptions": { - "rootDir": "/", + "rootDir": "/" }, "include": [ - "../../docs-shopify.dev/commands/**/*.doc.ts" + "../../docs-shopify.dev/commands/**/*.doc.ts", + "../../docs-shopify.dev/configuration/**/*.doc.ts" ], "exclude": [] } diff --git a/docs-shopify.dev/categories/app-configuration.doc.ts b/docs-shopify.dev/categories/app-configuration.doc.ts new file mode 100644 index 00000000000..b899ca09897 --- /dev/null +++ b/docs-shopify.dev/categories/app-configuration.doc.ts @@ -0,0 +1,10 @@ +import {CategoryTemplateSchema} from '@shopify/generate-docs' + +const data: CategoryTemplateSchema = { + // Name of the category + category: 'app-configuration', + title: 'App configuration (app.toml)', + sections: [], +} + +export default data diff --git a/docs-shopify.dev/categories/extension-configuration.doc.ts b/docs-shopify.dev/categories/extension-configuration.doc.ts new file mode 100644 index 00000000000..a8fa0a6d5c4 --- /dev/null +++ b/docs-shopify.dev/categories/extension-configuration.doc.ts @@ -0,0 +1,10 @@ +import {CategoryTemplateSchema} from '@shopify/generate-docs' + +const data: CategoryTemplateSchema = { + // Name of the category + category: 'extension-configuration', + title: 'Extension configuration (extension.toml)', + sections: [], +} + +export default data diff --git a/docs-shopify.dev/generated/generated_category_pages.json b/docs-shopify.dev/generated/generated_category_pages.json index 7d90a1310bc..07998e562fb 100644 --- a/docs-shopify.dev/generated/generated_category_pages.json +++ b/docs-shopify.dev/generated/generated_category_pages.json @@ -1,9 +1,19 @@ [ + { + "category": "app-configuration", + "title": "App configuration (app.toml)", + "sections": [] + }, { "category": "app", "title": "Shopify CLI App commands", "sections": [] }, + { + "category": "extension-configuration", + "title": "Extension configuration (extension.toml)", + "sections": [] + }, { "category": "general-commands", "title": "Shopify CLI General commands", diff --git a/package.json b/package.json index f23b3d394eb..b1a0b445f33 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "create-app": "nx build create-app && node packages/create-app/bin/dev.js --package-manager npm", "deploy-experimental": "node bin/deploy-experimental.js", "graph": "nx graph", + "generate-schema-docs": "node bin/docs/generate-schema-docs.js", "graphql-codegen:get-graphql-schemas": "bin/get-graphql-schemas.js", "graphql-codegen": "nx run-many --target=graphql-codegen --all", "knip": "knip", @@ -193,7 +194,9 @@ "packages/app": { "entry": [ "**/{commands,hooks}/**/*.ts!", - "**/index.ts!" + "**/index.ts!", + "src/cli/services/docs/generate-schema-docs.ts", + "src/cli/services/docs/schema-to-docs.ts" ], "project": "**/*.{ts,tsx}!", "ignore": [ diff --git a/packages/app/package.json b/packages/app/package.json index a11155916c7..88ef44153a7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -84,7 +84,8 @@ "@types/react-dom": "^19.0.0", "@types/which": "3.0.4", "@types/ws": "^8.5.13", - "@vitest/coverage-istanbul": "^3.1.4" + "@vitest/coverage-istanbul": "^3.1.4", + "zod-to-json-schema": "~3.24.1" }, "engines": { "node": ">=20.10.0" diff --git a/packages/app/src/cli/services/docs/generate-schema-docs.ts b/packages/app/src/cli/services/docs/generate-schema-docs.ts new file mode 100644 index 00000000000..f6d2cddb54f --- /dev/null +++ b/packages/app/src/cli/services/docs/generate-schema-docs.ts @@ -0,0 +1,150 @@ +import { + extractFieldsFromSpec, + zodSchemaToFields, + extensionSlug, + generateAppConfigDocFile, + generateAppConfigSectionInterface, + generateAppConfigExampleToml, + generateExtensionDocFile, + generateExtensionInterfaceFile, + generateExtensionExampleToml, +} from './schema-to-docs.js' +import {appFromIdentifiers} from '../context.js' +import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' +import {AppSchema} from '../../models/app/app.js' + +/* eslint-disable @nx/enforce-module-boundaries -- internal tooling, not lazy-loaded at runtime */ +import {mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {outputInfo, outputSuccess} from '@shopify/cli-kit/node/output' +import type {AppConfigSection, MergedSpec} from './schema-to-docs.js' +/* eslint-enable @nx/enforce-module-boundaries */ + +/** + * App config specs to skip in docs — these share a schema with another spec and + * would produce duplicate sections. Their fields are already covered by the other spec. + */ +const SKIP_APP_CONFIG_SPECS = new Set([ + // Uses the same WebhooksSchema as 'webhooks'; its fields are covered by the Webhooks section + 'privacy_compliance_webhooks', + // Branding fields (name, handle) are added to the Global section instead + 'branding', +]) + +/** + * Generate TOML configuration schema documentation files. + * + * Authenticates via the developer platform APIs, fetches extension specifications, + * and writes doc/interface/example files for app config and extensions. + * + * @param basePath - Absolute path to the output directory (e.g. `/docs-shopify.dev/configuration`) + * @param clientId - The app client ID to authenticate with + */ +export async function generateSchemaDocs(basePath: string, clientId: string): Promise { + outputInfo('Authenticating and fetching app...') + const app = await appFromIdentifiers({apiKey: clientId}) + const {developerPlatformClient} = app + + outputInfo('Fetching extension specifications...') + const specs = await fetchSpecifications({ + developerPlatformClient, + app: {apiKey: app.apiKey, organizationId: app.organizationId, id: app.id}, + }) + + // Partition: single = app.toml config modules, uuid/dynamic = extension types + const appConfigSpecs: MergedSpec[] = [] + const extensionSpecs: MergedSpec[] = [] + for (const spec of specs) { + const merged = spec as MergedSpec + if (merged.uidStrategy === 'single') { + if (!SKIP_APP_CONFIG_SPECS.has(merged.identifier)) { + appConfigSpecs.push(merged) + } + } else { + extensionSpecs.push(merged) + } + } + + outputInfo( + `Found ${specs.length} specifications (${appConfigSpecs.length} app config, ${extensionSpecs.length} extensions). Generating docs...`, + ) + + // Ensure output directories exist + await mkdir(basePath) + await mkdir(joinPath(basePath, 'interfaces')) + await mkdir(joinPath(basePath, 'examples')) + + // --- App configuration: one consolidated page --- + + // Start with root-level fields from AppSchema (client_id, build, extension_directories, etc.) + // Also include name and handle which are root-level app.toml fields contributed by the branding spec. + const globalFields = [ + ...zodSchemaToFields(AppSchema), + {name: 'name', type: 'string', required: true, description: 'The name of your app.'}, + {name: 'handle', type: 'string', required: false, description: 'The URL handle of your app.'}, + ] + const appSections: AppConfigSection[] = [ + { + identifier: 'global', + externalName: 'Global', + fields: globalFields, + }, + ] + outputInfo(` App config section: global (${globalFields.length} fields)`) + + const appConfigFieldPromises = appConfigSpecs.map(async (spec) => { + const fields = await extractFieldsFromSpec(spec) + return { + identifier: spec.identifier, + externalName: spec.externalName, + fields, + } + }) + const resolvedAppConfigSections = await Promise.all(appConfigFieldPromises) + for (const section of resolvedAppConfigSections) { + appSections.push(section) + outputInfo(` App config section: ${section.identifier} (${section.fields.length} fields)`) + } + + const appDocContent = generateAppConfigDocFile(appSections) + await writeFile(joinPath(basePath, 'app-configuration.doc.ts'), appDocContent) + + // Write one interface file per app config section + const interfaceWrites = appSections + .filter((section) => section.fields.length > 0) + .map(async (section) => { + const sectionSlug = section.identifier.replace(/_/g, '-') + const interfaceContent = generateAppConfigSectionInterface(section) + await writeFile(joinPath(basePath, 'interfaces', `${sectionSlug}.interface.ts`), interfaceContent) + }) + await Promise.all(interfaceWrites) + + // Write combined app.toml example + const appExampleContent = generateAppConfigExampleToml(appSections) + await writeFile(joinPath(basePath, 'examples', 'app-configuration.example.toml'), appExampleContent) + + // --- Extensions: one page per extension type --- + const extensionWrites = extensionSpecs.map(async (spec) => { + const fields = await extractFieldsFromSpec(spec) + const slug = extensionSlug(spec) + + const docContent = generateExtensionDocFile(spec, fields) + await writeFile(joinPath(basePath, `${slug}.doc.ts`), docContent) + + if (fields.length > 0) { + const interfaceContent = generateExtensionInterfaceFile(spec, fields) + await writeFile(joinPath(basePath, 'interfaces', `${slug}.interface.ts`), interfaceContent) + } + + const exampleContent = generateExtensionExampleToml(spec, fields) + await writeFile(joinPath(basePath, 'examples', `${slug}.example.toml`), exampleContent) + + outputInfo(` Extension: ${slug} (${fields.length} fields)`) + }) + + await Promise.all(extensionWrites) + + outputSuccess( + `Generated documentation: 1 app config page (${appSections.length} sections), ${extensionSpecs.length} extension pages`, + ) +} diff --git a/packages/app/src/cli/services/docs/schema-to-docs.ts b/packages/app/src/cli/services/docs/schema-to-docs.ts new file mode 100644 index 00000000000..d566c71fde2 --- /dev/null +++ b/packages/app/src/cli/services/docs/schema-to-docs.ts @@ -0,0 +1,545 @@ +// eslint-disable-next-line @nx/enforce-module-boundaries -- internal tooling command, not lazy-loaded at runtime +import {normaliseJsonSchema} from '@shopify/cli-kit/node/json-schema' +import {zodToJsonSchema} from 'zod-to-json-schema' +import type {FlattenedRemoteSpecification} from '../../api/graphql/extension_specifications.js' +import type {RemoteAwareExtensionSpecification} from '../../models/extensions/specification.js' + +/** + * Represents a single field extracted from a JSON Schema. + */ +interface SchemaField { + name: string + type: string + description?: string + required: boolean + enumValues?: string[] + nested?: SchemaField[] + isArray?: boolean + arrayItemType?: string +} + +/** + * A section in the consolidated app.toml doc — one per config module spec. + */ +export interface AppConfigSection { + identifier: string + externalName: string + fields: SchemaField[] +} + +export type MergedSpec = RemoteAwareExtensionSpecification & FlattenedRemoteSpecification + +// --------------------------------------------------------------------------- +// JSON Schema walker +// --------------------------------------------------------------------------- + +interface JsonSchemaProperty { + type?: string | string[] + description?: string + enum?: unknown[] + properties?: Record + items?: JsonSchemaProperty + required?: string[] + anyOf?: JsonSchemaProperty[] + oneOf?: JsonSchemaProperty[] + allOf?: JsonSchemaProperty[] + const?: unknown + default?: unknown + $ref?: string +} + +/** + * Walk a dereferenced JSON Schema object and extract fields. + */ +function jsonSchemaToFields(schema: JsonSchemaProperty, parentRequired?: string[]): SchemaField[] { + const properties = schema.properties + if (!properties) return [] + + const requiredSet = new Set(schema.required ?? parentRequired ?? []) + + return Object.entries(properties).map(([name, prop]) => { + const resolvedProp = resolveComposite(prop) + const field: SchemaField = { + name, + type: resolveType(resolvedProp), + description: resolvedProp.description, + required: requiredSet.has(name), + } + + if (resolvedProp.enum) { + field.enumValues = resolvedProp.enum.map(String) + } + + if (resolvedProp.type === 'array' && resolvedProp.items) { + field.isArray = true + const itemResolved = resolveComposite(resolvedProp.items) + field.arrayItemType = resolveType(itemResolved) + if (itemResolved.properties) { + field.nested = jsonSchemaToFields(itemResolved) + } + } + + if (resolvedProp.type === 'object' && resolvedProp.properties) { + field.nested = jsonSchemaToFields(resolvedProp) + } + + return field + }) +} + +function resolveComposite(prop: JsonSchemaProperty): JsonSchemaProperty { + // Merge allOf schemas + if (prop.allOf && prop.allOf.length > 0) { + let merged: JsonSchemaProperty = {} + for (const sub of prop.allOf) { + merged = { + ...merged, + ...sub, + properties: {...(merged.properties ?? {}), ...(sub.properties ?? {})}, + required: [...(merged.required ?? []), ...(sub.required ?? [])], + } + } + return {...prop, ...merged, allOf: undefined} + } + // For anyOf/oneOf, pick the first non-null branch + const union = prop.anyOf ?? prop.oneOf + if (union && union.length > 0) { + const nonNull = union.filter((branch) => branch.type !== 'null') + if (nonNull.length > 0) return {...prop, ...(nonNull[0] ?? {}), anyOf: undefined, oneOf: undefined} + } + return prop +} + +function resolveType(prop: JsonSchemaProperty): string { + if (prop.const !== undefined) return 'const' + if (Array.isArray(prop.type)) { + const nonNull = prop.type.filter((t) => t !== 'null') + return nonNull[0] ?? 'unknown' + } + return prop.type ?? 'unknown' +} + +// --------------------------------------------------------------------------- +// Zod → JSON Schema conversion (via zod-to-json-schema) +// --------------------------------------------------------------------------- + +/** + * Convert a Zod schema to SchemaFields by first converting it to JSON Schema + * using zod-to-json-schema, then walking the result with jsonSchemaToFields. + * + * This avoids brittle introspection of Zod's internal `_def` structure. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function zodSchemaToFields(schema: any): SchemaField[] { + const converted = zodToJsonSchema(schema, {$refStrategy: 'none', effectStrategy: 'input'}) + return jsonSchemaToFields(converted as unknown as JsonSchemaProperty) +} + +// --------------------------------------------------------------------------- +// Schema field extraction from a spec (priority: JSON Schema contract > Zod) +// --------------------------------------------------------------------------- + +/** + * Fields from the base extension schema that every JSON Schema contract includes. + * These are internal extension framework fields that users never write in app.toml. + * We strip these from app config specs (uidStrategy === 'single') since only the + * module-specific fields are relevant to the app.toml reference. + * + * Note: 'name' and 'handle' are also excluded here because they come from BaseSchema. + * The branding spec contributes them, but they are root-level app.toml fields — + * they should appear in the Global section instead (added by the command). + */ +const BASE_EXTENSION_SCHEMA_FIELDS = new Set([ + 'name', + 'type', + 'handle', + 'uid', + 'description', + 'api_version', + 'extension_points', + 'capabilities', + 'supported_features', + 'settings', +]) + +export async function extractFieldsFromSpec(spec: MergedSpec): Promise { + const isAppConfig = spec.uidStrategy === 'single' + + // 1. Try JSON Schema contract from the platform API + if (spec.validationSchema?.jsonSchema) { + try { + const normalised = await normaliseJsonSchema(spec.validationSchema.jsonSchema) + let fields = jsonSchemaToFields(normalised as unknown as JsonSchemaProperty) + if (isAppConfig) { + fields = fields.filter((field) => !BASE_EXTENSION_SCHEMA_FIELDS.has(field.name)) + } + if (fields.length > 0) return fields + } catch (error) { + // Fall through to Zod — contract may be malformed or have unresolvable $refs + if (error instanceof SyntaxError || (error as {code?: string}).code === 'ENOENT') { + throw error + } + } + } + + // 2. Fall back to Zod schema → JSON Schema conversion + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const zodSchema = (spec as any).schema + if (zodSchema) { + let fields = zodSchemaToFields(zodSchema) + if (isAppConfig) { + fields = fields.filter((field) => !BASE_EXTENSION_SCHEMA_FIELDS.has(field.name)) + } + return fields + } + + return [] +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function extensionSlug(spec: MergedSpec): string { + return spec.identifier.replace(/_/g, '-') +} + +// Used to avoid generating `interface function { ... }` or `interface export { ... }` which would be +// syntax errors. Extension slugs like "function" are real spec identifiers. Interface keys are already +// quoted ('key': type) so they don't need this check — only the interface name itself does. +const JS_RESERVED_WORDS = new Set([ + 'break', + 'case', + 'catch', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'finally', + 'for', + 'function', + 'if', + 'in', + 'instanceof', + 'new', + 'return', + 'switch', + 'this', + 'throw', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + 'class', + 'const', + 'enum', + 'export', + 'extends', + 'import', + 'super', + 'implements', + 'interface', + 'let', + 'package', + 'private', + 'protected', + 'public', + 'static', + 'yield', +]) + +function interfaceName(slug: string): string { + const name = slug.replace(/-/g, '') + // Avoid JS reserved words as interface names + if (JS_RESERVED_WORDS.has(name)) return `${name}Config` + return name +} + +function tsTypeForField(field: SchemaField): string { + if (field.enumValues) { + return field.enumValues.map((val) => `'${escapeSingleQuotes(val)}'`).join(' | ') + } + if (field.isArray && field.nested) { + return `${interfaceName(field.name)}Item[]` + } + if (field.isArray) { + return `${field.arrayItemType ?? 'string'}[]` + } + if (field.nested) { + return interfaceName(field.name) + } + const typeMap: Record = { + string: 'string', + number: 'number', + integer: 'number', + boolean: 'boolean', + object: 'object', + const: 'string', + any: 'unknown', + unknown: 'unknown', + } + return typeMap[field.type] ?? 'unknown' +} + +function escapeSingleQuotes(str: string): string { + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'") +} + +// --------------------------------------------------------------------------- +// Consolidated app.toml doc — one page with a section per config module +// --------------------------------------------------------------------------- + +/** + * Generate the single .doc.ts for the entire app.toml reference. + * Each config module becomes a `definitions` entry (section on the page). + */ +export function generateAppConfigDocFile(sections: AppConfigSection[]): string { + const definitionsEntries = sections + .filter((section) => section.fields.length > 0) + .map((section) => { + const sectionSlug = section.identifier.replace(/_/g, '-') + const iface = interfaceName(sectionSlug) + return ` { + title: '${escapeSingleQuotes(section.externalName)}', + description: '${escapeSingleQuotes(section.externalName)} properties.', + type: '${iface}', + },` + }) + .join('\n') + + return `// This is an autogenerated file. Don't edit this file manually. +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' + +const data: ReferenceEntityTemplateSchema = { + name: 'App configuration', + description: 'Reference for the shopify.app.toml configuration file.', + overviewPreviewDescription: 'shopify.app.toml configuration reference.', + type: 'resource', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'shopify.app.toml', + code: './examples/app-configuration.example.toml', + language: 'toml', + }, + ], + title: 'Example configuration', + }, + }, + definitions: [ +${definitionsEntries} + ], + category: 'app-configuration', + related: [], +} + +export default data` +} + +/** + * Generate the interface file for one config module section of app.toml. + */ +export function generateAppConfigSectionInterface(section: AppConfigSection): string { + const sectionSlug = section.identifier.replace(/_/g, '-') + const iface = interfaceName(sectionSlug) + return generateInterfaceContent(iface, section.fields) +} + +/** + * Generate a combined example TOML for the entire app.toml. + */ +export function generateAppConfigExampleToml(sections: AppConfigSection[]): string { + const lines: string[] = [] + + for (const section of sections) { + if (section.fields.length === 0) continue + lines.push(`# ${section.externalName}`) + emitTomlFields(section.fields, '', lines) + lines.push('') + } + + return `${lines.join('\n').trim()}\n` +} + +// --------------------------------------------------------------------------- +// Extension TOML doc — one page per extension type +// --------------------------------------------------------------------------- + +export function generateExtensionDocFile(spec: MergedSpec, fields: SchemaField[]): string { + const slug = extensionSlug(spec) + const name = spec.externalName + const iface = interfaceName(slug) + + const hasFields = fields.length > 0 + const definitionsBlock = hasFields + ? ` + { + title: 'Properties', + description: 'The following properties are available:', + type: '${iface}', + },` + : '' + + return `// This is an autogenerated file. Don't edit this file manually. +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' + +const data: ReferenceEntityTemplateSchema = { + name: '${escapeSingleQuotes(name)}', + description: 'Configuration reference for ${escapeSingleQuotes(name)}.', + overviewPreviewDescription: '${escapeSingleQuotes(name)} TOML configuration.', + type: 'resource', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'shopify.extension.toml', + code: './examples/${slug}.example.toml', + language: 'toml', + }, + ], + title: 'Example configuration', + }, + }, + definitions: [${definitionsBlock} + ], + category: 'extension-configuration', + related: [], +} + +export default data` +} + +export function generateExtensionInterfaceFile(spec: MergedSpec, fields: SchemaField[]): string { + const slug = extensionSlug(spec) + const iface = interfaceName(slug) + return generateInterfaceContent(iface, fields) +} + +export function generateExtensionExampleToml(spec: MergedSpec, fields: SchemaField[]): string { + const lines: string[] = [] + lines.push(`name = "${spec.externalName}"`) + lines.push(`type = "${spec.identifier}"`) + lines.push('') + emitTomlFields(fields, '', lines, new Set(['name', 'type', 'handle'])) + return `${lines.join('\n').trim()}\n` +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** + * Flatten fields into a single interface with dot-notation keys for nested properties. + * generate-docs renders one flat interface as a properties table — it doesn't recurse + * into sub-interfaces, so we need to flatten everything. + * + * Example output: + * 'client_id': string + * 'build.automatically_update_urls_on_dev'?: boolean + * 'access_scopes.scopes'?: string + */ +function generateInterfaceContent(iface: string, fields: SchemaField[]): string { + const flatLines = flattenFields(fields, '') + + const mainInterface = `export interface ${iface} {\n${flatLines.join('\n\n')}\n}` + return `// This is an autogenerated file. Don't edit this file manually.\n${mainInterface}\n` +} + +function flattenFields(fields: SchemaField[], prefix: string): string[] { + const lines: string[] = [] + for (const field of fields) { + const key = prefix ? `${prefix}.${field.name}` : field.name + if (field.nested && field.nested.length > 0 && !field.isArray) { + // Nested object: recurse, don't emit the parent as its own row + lines.push(...flattenFields(field.nested, key)) + } else if (field.isArray && field.nested && field.nested.length > 0) { + // Array of objects: emit parent as array, then flatten items with [] notation + const desc = field.description ? ` /** ${field.description} */\n` : '' + const optional = field.required ? '' : '?' + lines.push(`${desc} '${key}'${optional}: object[]`) + lines.push(...flattenFields(field.nested, `${key}[]`)) + } else { + const desc = field.description ? ` /** ${field.description} */\n` : '' + const optional = field.required ? '' : '?' + const tsType = tsTypeForField(field) + lines.push(`${desc} '${key}'${optional}: ${tsType}`) + } + } + return lines +} + +/** + * Recursively emit TOML fields. Handles nested objects as [table] headers, + * arrays of objects as [[array_of_tables]], and skips opaque objects without children. + * + * TOML requires all bare key=value pairs for a scope to appear before any [sub.table] + * headers, and all table headers must be fully qualified. + */ +function emitTomlFields(fields: SchemaField[], prefix: string, lines: string[], skipNames?: Set): void { + // Partition: scalar/leaf fields first, then nested objects, then arrays of objects + const scalarFields: SchemaField[] = [] + const nestedObjects: SchemaField[] = [] + const arrayTables: SchemaField[] = [] + + for (const field of fields) { + if (skipNames?.has(field.name)) continue + if (field.type === 'object' && !field.nested) { + // Opaque object with no known children — skip + continue + } else if (field.nested && field.nested.length > 0 && field.type === 'object' && !field.isArray) { + nestedObjects.push(field) + } else if (field.isArray && field.nested && field.nested.length > 0) { + arrayTables.push(field) + } else { + scalarFields.push(field) + } + } + + // 1. Emit scalar key=value pairs first (must come before any [table] headers) + for (const field of scalarFields) { + lines.push(`${field.name} = ${exampleValue(field)}`) + } + + // 2. Emit nested object tables with fully qualified paths + for (const field of nestedObjects) { + const key = prefix ? `${prefix}.${field.name}` : field.name + lines.push(`[${key}]`) + emitTomlFields(field.nested!, key, lines) + lines.push('') + } + + // 3. Emit arrays of tables with fully qualified paths + for (const field of arrayTables) { + const key = prefix ? `${prefix}.${field.name}` : field.name + lines.push(`[[${key}]]`) + emitTomlFields(field.nested!, key, lines) + lines.push('') + } +} + +function exampleValue(field: SchemaField): string { + if (field.enumValues && field.enumValues.length > 0) { + return `"${field.enumValues[0]}"` + } + if (field.isArray) { + return `["example"]` + } + switch (field.type) { + case 'string': + return `"example"` + case 'number': + case 'integer': + return '0' + case 'boolean': + return 'true' + default: + return `"example"` + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c611fe81206..17e3e86db46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@vitest/coverage-istanbul': specifier: ^3.1.4 version: 3.2.4(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.2)) + zod-to-json-schema: + specifier: ~3.24.1 + version: 3.24.6(zod@3.24.4) packages/cli: dependencies: @@ -9209,6 +9212,11 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod-to-json-schema@3.24.6: + resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + peerDependencies: + zod: ^3.24.1 + zod-validation-error@3.5.4: resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} engines: {node: '>=18.0.0'} @@ -13842,15 +13850,6 @@ snapshots: msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3) vite: 6.4.1(@types/node@18.19.70)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2) - '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3) - vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2) - '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -19245,7 +19244,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@6.4.1(@types/node@18.19.70)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -19504,6 +19503,10 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zod-to-json-schema@3.24.6(zod@3.24.4): + dependencies: + zod: 3.24.4 + zod-validation-error@3.5.4(zod@3.24.4): dependencies: zod: 3.24.4