Skip to content

Commit ba8a795

Browse files
committed
Add typed JSON output to doc fetch
1 parent d303a2a commit ba8a795

12 files changed

Lines changed: 397 additions & 58 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli': minor
3+
---
4+
5+
Add typed JSON output and schema discovery to `doc fetch`.

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4562,7 +4562,7 @@
45624562
"syntaxKind": "PropertySignature",
45634563
"name": "--output <value>",
45644564
"value": "string",
4565-
"description": "Write the document to this file path instead of printing it to stdout.",
4565+
"description": "Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute path and Markdown format of the written file.",
45664566
"isOptional": true,
45674567
"environmentValue": "SHOPIFY_FLAG_OUTPUT"
45684568
},
@@ -4582,9 +4582,18 @@
45824582
"description": "Increase the verbosity of the output. May include sensitive data.",
45834583
"isOptional": true,
45844584
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
4585+
},
4586+
{
4587+
"filePath": "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts",
4588+
"syntaxKind": "PropertySignature",
4589+
"name": "-j, --json",
4590+
"value": "''",
4591+
"description": "Output the result as JSON. Automatically disables color output.",
4592+
"isOptional": true,
4593+
"environmentValue": "SHOPIFY_FLAG_JSON"
45854594
}
45864595
],
4587-
"value": "export interface docfetch {\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Filter code examples in the returned Markdown to this language. Supply the language of the app you are building so examples match your stack. Optional — if omitted, or if shopify.dev does not recognize the language for a given page, the document includes examples in every language.\n * @environment SHOPIFY_FLAG_LANGUAGE\n */\n '--language <value>'?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Write the document to this file path instead of printing it to stdout.\n * @environment SHOPIFY_FLAG_OUTPUT\n */\n '--output <value>'?: string\n\n /**\n * The shopify.dev URL to fetch.\n * @environment SHOPIFY_FLAG_URL\n */\n '--url <value>': string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
4596+
"value": "export interface docfetch {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Filter code examples in the returned Markdown to this language. Supply the language of the app you are building so examples match your stack. Optional — if omitted, or if shopify.dev does not recognize the language for a given page, the document includes examples in every language.\n * @environment SHOPIFY_FLAG_LANGUAGE\n */\n '--language <value>'?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute path and Markdown format of the written file.\n * @environment SHOPIFY_FLAG_OUTPUT\n */\n '--output <value>'?: string\n\n /**\n * The shopify.dev URL to fetch.\n * @environment SHOPIFY_FLAG_URL\n */\n '--url <value>': string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
45884597
}
45894598
},
45904599
"docsearch": {

‎packages/cli/README.md‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4039,11 +4039,15 @@ Download a complete document from shopify.dev. Every page on shopify.dev has a M
40394039
40404040
```
40414041
USAGE
4042-
$ shopify doc fetch --url <value> [--json-schema] [--language
4042+
$ shopify doc fetch --url <value> [-j] [--json-schema] [--language
40434043
javascript|typescript|python|ruby|php|rust|curl|liquid|graphql|html] [--no-color] [--no-input] [--output <value>]
40444044
[--verbose]
40454045

40464046
FLAGS
4047+
-j, --json
4048+
Output the result as JSON. Automatically disables color output.
4049+
[env: SHOPIFY_FLAG_JSON]
4050+
40474051
--json-schema
40484052
Print the command's JSON schemas.
40494053
[env: SHOPIFY_FLAG_JSON_SCHEMA]
@@ -4064,7 +4068,8 @@ FLAGS
40644068
[env: SHOPIFY_FLAG_NO_INPUT]
40654069

40664070
--output=<value>
4067-
Write the document to this file path instead of printing it to stdout.
4071+
Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute
4072+
path and Markdown format of the written file.
40684073
[env: SHOPIFY_FLAG_OUTPUT]
40694074

40704075
--url=<value>
@@ -4081,6 +4086,75 @@ DESCRIPTION
40814086
a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your
40824087
stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.
40834088

4089+
Use `--json-schema` to print the result, error, and event schemas.
4090+
4091+
Output from `--json` conforms to the `DocFetchResult` schema.
4092+
4093+
```json
4094+
{
4095+
"anyOf": [
4096+
{
4097+
"type": "object",
4098+
"properties": {
4099+
"document": {
4100+
"$ref": "#/definitions/Document"
4101+
}
4102+
},
4103+
"required": [
4104+
"document"
4105+
],
4106+
"additionalProperties": false
4107+
},
4108+
{
4109+
"$ref": "#/definitions/DocumentFile"
4110+
}
4111+
],
4112+
"title": "DocFetchResult",
4113+
"definitions": {
4114+
"Document": {
4115+
"type": "object",
4116+
"properties": {
4117+
"url": {
4118+
"type": "string",
4119+
"format": "uri",
4120+
"description": "The requested shopify.dev document URL."
4121+
},
4122+
"content": {
4123+
"type": "string",
4124+
"description": "The document in Markdown, with the requested language filter applied."
4125+
}
4126+
},
4127+
"required": [
4128+
"url",
4129+
"content"
4130+
],
4131+
"additionalProperties": false
4132+
},
4133+
"DocumentFile": {
4134+
"type": "object",
4135+
"properties": {
4136+
"path": {
4137+
"type": "string",
4138+
"pattern": "^(?:\\/|[A-Za-z]:[\\\\/]|\\\\\\\\)",
4139+
"description": "The absolute native path of the written file."
4140+
},
4141+
"format": {
4142+
"type": "string",
4143+
"const": "markdown",
4144+
"description": "The file contains the original Markdown document, not a JSON wrapper."
4145+
}
4146+
},
4147+
"required": [
4148+
"path",
4149+
"format"
4150+
],
4151+
"additionalProperties": false
4152+
}
4153+
},
4154+
"$schema": "http://json-schema.org/draft-07/schema#"
4155+
}
4156+
```
4157+
40844158
EXAMPLES
40854159
# fetch the Markdown version of a Shopify.dev page
40864160

@@ -4093,6 +4167,10 @@ EXAMPLES
40934167
# save the document to a file instead of printing it
40944168

40954169
$ shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md
4170+
4171+
# return a typed document as JSON
4172+
4173+
$ shopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --json
40964174
```
40974175
40984176
## `shopify doc search`

‎packages/cli/oclif.manifest.json‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6113,14 +6113,25 @@
61136113
],
61146114
"args": {
61156115
},
6116-
"description": "Download a complete document from shopify.dev. Every page on shopify.dev has a Markdown version, and that is what this tool returns. Use this to pull an entire document verbatim — for example, a set of instructions an agent follows like a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.",
6116+
"description": "Download a complete document from shopify.dev. Every page on shopify.dev has a Markdown version, and that is what this tool returns. Use this to pull an entire document verbatim — for example, a set of instructions an agent follows like a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `DocFetchResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"document\": {\n \"$ref\": \"#/definitions/Document\"\n }\n },\n \"required\": [\n \"document\"\n ],\n \"additionalProperties\": false\n },\n {\n \"$ref\": \"#/definitions/DocumentFile\"\n }\n ],\n \"title\": \"DocFetchResult\",\n \"definitions\": {\n \"Document\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\",\n \"description\": \"The requested shopify.dev document URL.\"\n },\n \"content\": {\n \"type\": \"string\",\n \"description\": \"The document in Markdown, with the requested language filter applied.\"\n }\n },\n \"required\": [\n \"url\",\n \"content\"\n ],\n \"additionalProperties\": false\n },\n \"DocumentFile\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"pattern\": \"^(?:\\\\/|[A-Za-z]:[\\\\\\\\/]|\\\\\\\\\\\\\\\\)\",\n \"description\": \"The absolute native path of the written file.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"const\": \"markdown\",\n \"description\": \"The file contains the original Markdown document, not a JSON wrapper.\"\n }\n },\n \"required\": [\n \"path\",\n \"format\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
6117+
"descriptionWithMarkdown": "Download a complete document from shopify.dev. Every page on shopify.dev has a Markdown version, and that is what this tool returns. Use this to pull an entire document verbatim — for example, a set of instructions an agent follows like a centrally-served skill. Pass `--language` for the language of the app you are building so code examples match your stack. For finding the relevant pieces of content across shopify.dev instead, use `doc search`.",
61176118
"enableJsonFlag": false,
61186119
"examples": [
61196120
"# fetch the Markdown version of a Shopify.dev page\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli",
61206121
"# filter code examples to the language of the app you are building\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --language ruby",
6121-
"# save the document to a file instead of printing it\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md"
6122+
"# save the document to a file instead of printing it\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --output docs/shopify-cli.md",
6123+
"# return a typed document as JSON\nshopify doc fetch --url https://shopify.dev/docs/api/shopify-cli --json"
61226124
],
61236125
"flags": {
6126+
"json": {
6127+
"allowNo": false,
6128+
"char": "j",
6129+
"description": "Output the result as JSON. Automatically disables color output.",
6130+
"env": "SHOPIFY_FLAG_JSON",
6131+
"hidden": false,
6132+
"name": "json",
6133+
"type": "boolean"
6134+
},
61246135
"json-schema": {
61256136
"allowNo": false,
61266137
"description": "Print the command's JSON schemas.",
@@ -6164,7 +6175,7 @@
61646175
"type": "boolean"
61656176
},
61666177
"output": {
6167-
"description": "Write the document to this file path instead of printing it to stdout.",
6178+
"description": "Write the document to this file path instead of printing it to stdout. With --json, stdout contains the absolute path and Markdown format of the written file.",
61686179
"env": "SHOPIFY_FLAG_OUTPUT",
61696180
"hasDynamicHelp": false,
61706181
"multiple": false,
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import DocFetch from './fetch.js'
2+
import {docFetchJsonOutputSchema} from '../../services/commands/doc/types.js'
3+
import {fetch, Response} from '@shopify/cli-kit/node/http'
4+
import {inTemporaryDirectory, readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs'
5+
import {joinPath, relativePath, cwd} from '@shopify/cli-kit/node/path'
6+
import {launchCLI} from '@shopify/cli-kit/node/cli-launcher'
7+
import {ShopifyConfig} from '@shopify/cli-kit/node/custom-oclif-loader'
8+
import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
9+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
10+
11+
vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => ({
12+
...(await importOriginal<typeof import('@shopify/cli-kit/node/http')>()),
13+
fetch: vi.fn(),
14+
}))
15+
16+
const url = 'https://shopify.dev/docs/api/shopify-cli'
17+
const content = '# Shopify CLI\n\nA document.\n'
18+
19+
beforeEach(() => {
20+
vi.stubEnv('CI', '1')
21+
vi.stubEnv('SHOPIFY_CLI_NO_ANALYTICS', '1')
22+
vi.mocked(fetch).mockResolvedValue(new Response(content))
23+
vi.spyOn(process, 'exit').mockReturnValue(undefined as never)
24+
})
25+
26+
afterEach(() => {
27+
vi.unstubAllEnvs()
28+
mockAndCaptureOutput().clear()
29+
})
30+
31+
describe('doc fetch command', () => {
32+
test('preserves the Markdown output and stdout channel', async () => {
33+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
34+
await DocFetch.run(['--url', url, '--no-input'], import.meta.url)
35+
expect(stdout()).toBe(`${content}\n`)
36+
expect(stderr()).toBe('')
37+
})
38+
})
39+
40+
test.each([{flags: []}, {flags: ['--no-input']}])(
41+
'writes one document object with JSON and flags $flags',
42+
async ({flags}) => {
43+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
44+
await DocFetch.run(['--url', url, '--language', 'ruby', '--json', ...flags], import.meta.url)
45+
expect(JSON.parse(stdout())).toEqual({document: {url, content}})
46+
expect(stderr()).toBe('')
47+
expect(fetch).toHaveBeenCalledWith(url, {
48+
headers: {Accept: 'text/markdown', 'X-Shopify-Surface': 'cli', 'Accept-Language': 'ruby'},
49+
})
50+
})
51+
},
52+
)
53+
54+
test('supports the shared JSON environment flag', async () => {
55+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
56+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
57+
await DocFetch.run(['--url', url], import.meta.url)
58+
expect(JSON.parse(stdout())).toEqual({document: {url, content}})
59+
expect(stderr()).toBe('')
60+
})
61+
})
62+
63+
test('reports missing required input before fetching', async () => {
64+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
65+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
66+
await DocFetch.run(['--json', '--no-input'], import.meta.url)
67+
expect(JSON.parse(stdout())).toHaveProperty('error')
68+
expect(process.exit).toHaveBeenCalledWith(2)
69+
expect(stderr()).toBe('')
70+
expect(fetch).not.toHaveBeenCalled()
71+
})
72+
})
73+
74+
test.each(['text', 'json'] as const)('saves exact Markdown bytes with a relative path in %s mode', async (format) => {
75+
await inTemporaryDirectory(async (directory) => {
76+
const path = joinPath(directory, 'docs/shopify-cli.md')
77+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
78+
await DocFetch.run(
79+
['--url', url, '--output', relativePath(cwd(), path), ...(format === 'json' ? ['--json'] : [])],
80+
import.meta.url,
81+
)
82+
83+
await expect(readFile(path)).resolves.toBe(content)
84+
if (format === 'json') {
85+
expect(JSON.parse(stdout())).toEqual({path, format: 'markdown'})
86+
expect(JSON.parse(stderr())).toMatchObject({
87+
type: 'diagnostic',
88+
level: 'info',
89+
message: `Saved ${url} to ${path}`,
90+
})
91+
} else {
92+
expect(stdout()).toBe('')
93+
expect(stderr()).toBe(`Saved ${url} to ${path}\n`)
94+
}
95+
})
96+
})
97+
})
98+
99+
test('does not emit a receipt when the file write fails', async () => {
100+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
101+
await inTemporaryDirectory(async (directory) => {
102+
const parent = joinPath(directory, 'file')
103+
await writeFile(parent, 'existing content')
104+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
105+
await expect(
106+
DocFetch.run(['--url', url, '--output', joinPath(parent, 'doc.md'), '--json'], import.meta.url),
107+
).resolves.toBeUndefined()
108+
expect(process.exit).toHaveBeenCalledWith(1)
109+
expect(JSON.parse(stdout())).toHaveProperty('error')
110+
expect(stdout()).not.toContain('"format": "markdown"')
111+
expect(stderr()).toBe('')
112+
await expect(readFile(parent)).resolves.toBe('existing content')
113+
})
114+
})
115+
})
116+
117+
test('does not create a file when fetching fails', async () => {
118+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
119+
vi.mocked(fetch).mockResolvedValue(new Response('', {status: 404, statusText: 'Not Found'}))
120+
await inTemporaryDirectory(async (directory) => {
121+
const path = joinPath(directory, 'doc.md')
122+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
123+
await expect(DocFetch.run(['--url', url, '--output', path, '--json'], import.meta.url)).resolves.toBeUndefined()
124+
expect(process.exit).toHaveBeenCalledWith(1)
125+
expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message: `Failed to fetch ${url}: 404 Not Found`}})
126+
expect(stderr()).toBe('')
127+
await expect(fileExists(path)).resolves.toBe(false)
128+
})
129+
})
130+
})
131+
132+
test.each(['not a url', 'https://example.com/docs'])(
133+
'reports invalid input as one fatal document: %s',
134+
async (input) => {
135+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
136+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
137+
await expect(DocFetch.run(['--url', input, '--json'], import.meta.url)).resolves.toBeUndefined()
138+
expect(process.exit).toHaveBeenCalledWith(1)
139+
expect(JSON.parse(stdout()).error.type).toBe('abort')
140+
expect(stderr()).toBe('')
141+
expect(fetch).not.toHaveBeenCalled()
142+
})
143+
},
144+
)
145+
146+
test('exposes the schema in help and through the launcher without fetching', async () => {
147+
expect(DocFetch.jsonOutputSchema).toBe(docFetchJsonOutputSchema)
148+
expect(DocFetch.flags.json).toBeDefined()
149+
expect(DocFetch.description).toContain('Output from `--json` conforms to the `DocFetchResult` schema.')
150+
vi.spyOn(ShopifyConfig.prototype, 'runHook').mockResolvedValue({successes: [], failures: []})
151+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
152+
await launchCLI({
153+
moduleURL: import.meta.url,
154+
argv: ['doc', 'fetch', '--json-schema'],
155+
lazyCommandLoader: async () => DocFetch,
156+
})
157+
const schema = JSON.parse(stdout())
158+
expect(schema.definitions.Result.anyOf).toHaveLength(2)
159+
expect(schema.definitions.Result.anyOf[0].properties.document.properties.content.type).toBe('string')
160+
expect(schema.definitions.Result.anyOf[1].properties.format.const).toBe('markdown')
161+
expect(stderr()).toBe('')
162+
expect(fetch).not.toHaveBeenCalled()
163+
})
164+
})
165+
})

0 commit comments

Comments
 (0)