diff --git a/change/@minecraft-api-docs-generator-efb1c0e7-576a-46cd-9e12-3f19323ef8b5.json b/change/@minecraft-api-docs-generator-efb1c0e7-576a-46cd-9e12-3f19323ef8b5.json new file mode 100644 index 00000000..f409af93 --- /dev/null +++ b/change/@minecraft-api-docs-generator-efb1c0e7-576a-46cd-9e12-3f19323ef8b5.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Updated MS Docs Commands documentation to support examples", + "packageName": "@minecraft/api-docs-generator", + "email": "jake@xbox.com", + "dependentChangeType": "patch" +} diff --git a/change/@minecraft-markup-generators-plugin-cba35642-23cd-4760-be4c-75b9eb620273.json b/change/@minecraft-markup-generators-plugin-cba35642-23cd-4760-be4c-75b9eb620273.json new file mode 100644 index 00000000..16a43037 --- /dev/null +++ b/change/@minecraft-markup-generators-plugin-cba35642-23cd-4760-be4c-75b9eb620273.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Updated MS Docs Commands documentation to support examples", + "packageName": "@minecraft/markup-generators-plugin", + "email": "jake@xbox.com", + "dependentChangeType": "patch" +} diff --git a/tools/api-docs-generator-test-snapshots/test/general/__snapshots__/general.spec.ts.snap b/tools/api-docs-generator-test-snapshots/test/general/__snapshots__/general.spec.ts.snap index bf61ac2f..862fcc62 100644 --- a/tools/api-docs-generator-test-snapshots/test/general/__snapshots__/general.spec.ts.snap +++ b/tools/api-docs-generator-test-snapshots/test/general/__snapshots__/general.spec.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`General > Properly generates documentation for the input > # Of Files Generated 1`] = `42`; +exports[`General > Properly generates documentation for the input > # Of Files Generated 1`] = `45`; exports[`General > Properly generates documentation for the input > msdocs/blocks/block_properties/diamonds_stored.md 1`] = ` "--- @@ -101,6 +101,14 @@ exports[`General > Properly generates documentation for the input > msdocs/comma href: commands.md - name: testcommand href: commands/testcommand.md +- name: Command argument types + items: + - name: int + href: types/int.md + - name: position + href: types/position.md + - name: target + href: types/target.md " `; @@ -121,9 +129,9 @@ description: List and descriptions of Minecraft commands and enums # List and summary of command argument types and enums: |Type |Description |Is Enum? | |----------|-----------|-----------| -| int | A basic number | | -| position | | | -| target | | | +| [int](./types/int.md) | A basic number | | +| [position](./types/position.md) | | | +| [target](./types/target.md) | | | " `; @@ -139,47 +147,68 @@ description: Description and usage of the testcommand command # \`/testcommand\` Command A fake command for testing documentation generation - - - - - - - - - - - - - -
Aliases/notrealcommand, /fakecommand
Permission LevelAny
Requires Cheats?Yes
+| | | +| --- | --- | +| **Aliases** | \`/notrealcommand\`, \`/fakecommand\` | +| **Permission Level** | Any | +| **Requires Cheats** | Yes | + +## Syntax Overview + +\`/testcommand add [amount: int]\` + +\`/testcommand remove [amount: int]\` + +\`/testcommand set \` + +\`/testcommand set \` ## Usage ### \`/testcommand add [amount: int]\` Adds an amount to a target +| Argument | Type | Required | Description | +| --- | --- | --- | --- | +| \`target\` | [target](../types/target.md) | Required | A target entity | +| \`amount\` | [int](../types/int.md) | Optional | | + ### \`/testcommand remove [amount: int]\` Removes an amount from a target +| Argument | Type | Required | Description | +| --- | --- | --- | --- | +| \`target\` | [target](../types/target.md) | Required | A target entity | +| \`amount\` | [int](../types/int.md) | Optional | | + ### Set based on Position \`/testcommand set \` Sets an area to a biome centered on a position +| Argument | Type | Required | Description | +| --- | --- | --- | --- | +| \`center: x y z\` | [position](../types/position.md) | Required | The position that the biome will be centered on | +| \`biome\` | TestBiomeEnum | Required | | + ### Set based on Target \`/testcommand set \` Sets an area to a biome centered on a target -## Arguments -- \`amount\`: int -- \`biome\`: TestBiomeEnum -- \`center: x y z\`: position -The position that the biome will be centered on -- \`center\`: target -The target entity that the biome will be centered on -- \`target\`: target -A target entity +| Argument | Type | Required | Description | +| --- | --- | --- | --- | +| \`center\` | [target](../types/target.md) | Required | The target entity that the biome will be centered on | +| \`biome\` | TestBiomeEnum | Required | | + +## Arguments Reference + +| Argument | Type | Description | +| --- | --- | --- | +| \`amount\` | [int](../types/int.md) | | +| \`biome\` | TestBiomeEnum | | +| \`center: x y z\` | [position](../types/position.md) | The position that the biome will be centered on | +| \`center\` | [target](../types/target.md) | The target entity that the biome will be centered on | +| \`target\` | [target](../types/target.md) | A target entity | ## Enums ### \`TestBiomeEnum\` @@ -197,6 +226,47 @@ The plains biome. " `; +exports[`General > Properly generates documentation for the input > msdocs/commands/types/int.md 1`] = ` +"--- +# DO NOT TOUCH — This file was automatically generated by @minecraft/api-docs-generator, to report problems file an issue at https://github.com/Mojang/minecraft-scripting-libraries +author: jakeshirley +ms.author: jashir +ms.service: minecraft-bedrock-edition +title: int Command Argument Type +description: Description of the int command argument type +--- +# \`int\` Command Argument Type +A basic number + +" +`; + +exports[`General > Properly generates documentation for the input > msdocs/commands/types/position.md 1`] = ` +"--- +# DO NOT TOUCH — This file was automatically generated by @minecraft/api-docs-generator, to report problems file an issue at https://github.com/Mojang/minecraft-scripting-libraries +author: jakeshirley +ms.author: jashir +ms.service: minecraft-bedrock-edition +title: position Command Argument Type +description: Description of the position command argument type +--- +# \`position\` Command Argument Type +" +`; + +exports[`General > Properly generates documentation for the input > msdocs/commands/types/target.md 1`] = ` +"--- +# DO NOT TOUCH — This file was automatically generated by @minecraft/api-docs-generator, to report problems file an issue at https://github.com/Mojang/minecraft-scripting-libraries +author: jakeshirley +ms.author: jashir +ms.service: minecraft-bedrock-edition +title: target Command Argument Type +description: Description of the target command argument type +--- +# \`target\` Command Argument Type +" +`; + exports[`General > Properly generates documentation for the input > msdocs/scriptapi/TOC.yml 1`] = ` " - name: test-module2 items: diff --git a/tools/api-docs-generator/src/Context.ts b/tools/api-docs-generator/src/Context.ts index 9adf9f1e..cf03c672 100644 --- a/tools/api-docs-generator/src/Context.ts +++ b/tools/api-docs-generator/src/Context.ts @@ -291,7 +291,7 @@ export class GeneratorContext { if (config.documentationDirectory) { const docsPath = path.resolve(config.documentationDirectory); - const loader = new FileLoader(docsPath, ['.json', '.js', '.ts']); + const loader = new FileLoader(docsPath, ['.json', '.js', '.ts', '.mcfunction']); if (loader.loaded()) { this.documentationFileLoader = loader; } else { diff --git a/tools/api-docs-generator/src/filters/CommandFilters.ts b/tools/api-docs-generator/src/filters/CommandFilters.ts index 10b80720..8037b418 100644 --- a/tools/api-docs-generator/src/filters/CommandFilters.ts +++ b/tools/api-docs-generator/src/filters/CommandFilters.ts @@ -110,7 +110,7 @@ function formatCommandArgumentTypes(moduleJson: MinecraftCommandModule) { typeJson.name = enumJson.name; typeJson.is_enum = true; - if (enumJson.command_references.length > 1) { + if (enumJson.values.length > 1 && enumJson.command_references.length > 1) { typeJson.has_link = true; } if (enumJson.values.length === 1) { @@ -121,6 +121,7 @@ function formatCommandArgumentTypes(moduleJson: MinecraftCommandModule) { } } if (!typeJson.is_enum) { + typeJson.has_link = true; const mappedTypeName = commandTypeNameMappings[typeJson.name]; if (mappedTypeName) { typeJson.name = mappedTypeName; diff --git a/tools/api-docs-generator/src/filters/CommonFilters.ts b/tools/api-docs-generator/src/filters/CommonFilters.ts index c482e59a..7f57f122 100644 --- a/tools/api-docs-generator/src/filters/CommonFilters.ts +++ b/tools/api-docs-generator/src/filters/CommonFilters.ts @@ -32,6 +32,8 @@ import { BlockPropertyDocsValidator, CommandDocsValidator, CommandEnumDocsValidator, + CommandExampleDocsData, + CommandExampleDocsValidator, CommonDocsDescriptionValidator, ScriptCommonDocsValidator, ScriptFunctionDocsValidator, @@ -671,14 +673,9 @@ function addExamples( for (const examplePath of exampleFilePaths) { const exampleName = path.basename(examplePath); - const exampleFileData = fileLoader.readFile(examplePath); - const exampleFileStrings: string[] = exampleFileData - .toString() - .split('\n') - .map(line => line.replace(/\n|\r/, '')); - if (exampleFileStrings.at(-1) === '') { - exampleFileStrings.splice(-1); // Remove newline at end of example file - } + const exampleFileData = fileLoader.readFileAsString(examplePath); + const exampleFileStrings = + exampleFileData.length === 0 ? [] : utils.normalizeExampleText(exampleFileData).split('\n'); const escapedExampleFileStrings = exampleFileStrings.map(line => line.replace(/\/\*/g, `/\\*`).replace(/\*\//g, `*\\/`) @@ -1024,6 +1021,69 @@ function addCommandTypeDescriptions( } } +function addCommandExamples(fileLoader: FileLoader, commandJson: MinecraftCommand, commandFolderPath: string): void { + commandJson.command_examples = []; + for (const overload of commandJson.overloads) { + overload.overload_examples = []; + } + + const examplesDirectory = path.join(commandFolderPath, '_examples'); + const exampleFiles = utils + .getFiles(fileLoader.joinToRoot(examplesDirectory)) + .filter(filename => fileLoader.canLoadFile(path.join(examplesDirectory, filename))) + .sort(); + const sourceFiles = exampleFiles.filter(filename => path.extname(filename) === '.mcfunction'); + for (const filename of exampleFiles.filter(filename => path.extname(filename) === '.json')) { + const sourceName = `${path.basename(filename, '.json')}.mcfunction`; + if (!sourceFiles.includes(sourceName)) { + log.error( + `Command example metadata '${path.join(examplesDirectory, filename)}' has no matching '${sourceName}' file.` + ); + } + } + + for (const filename of sourceFiles) { + const examplePath = path.join(examplesDirectory, filename); + const infoPath = path.join(examplesDirectory, `${path.basename(filename, '.mcfunction')}.json`); + let info: CommandExampleDocsData = {}; + if (fileLoader.canLoadFile(infoPath)) { + const parsedInfo = parseJsonSafe(fileLoader, infoPath, CommandExampleDocsValidator); + if (!parsedInfo) { + continue; + } + info = parsedInfo; + } + if (info.title !== undefined && !info.title.trim()) { + log.error(`Command example metadata '${infoPath}' has an empty title.`); + continue; + } + const overload = + info.overload === undefined + ? undefined + : commandJson.overloads.find(overload => Number(overload.name) === info.overload); + if (info.overload !== undefined && !overload) { + log.error(`Command example metadata '${infoPath}' references unknown overload '${info.overload}'.`); + continue; + } + + const code = utils.normalizeExampleText(fileLoader.readFileAsString(examplePath)); + if (!code.trim()) { + log.error(`Command example file '${examplePath}' is empty.`); + continue; + } + const examples = overload ? overload.overload_examples : commandJson.command_examples; + examples.push({ + title: info.title ?? filename, + code, + description: info.description ? splitStringByNewline(info.description) : [], + }); + commandJson.has_comments = true; + if (overload) { + overload.has_comments = true; + } + } +} + function addCommandDescriptions(fileLoader: FileLoader, commandJson: MinecraftCommand, moduleFolderPath: string) { commandJson.has_comments = false; @@ -1040,17 +1100,21 @@ function addCommandDescriptions(fileLoader: FileLoader, commandJson: MinecraftCo if (infoJson.overloads) { for (const overloadJson of commandJson.overloads) { const overloadInfoJson = infoJson.overloads.find(o => Number(overloadJson.name) === o.id); - if (overloadInfoJson && overloadInfoJson.description) { - overloadJson.has_comments = true; - overloadJson.overload_description = splitStringByNewline(overloadInfoJson.description); - if (overloadInfoJson.header) { - overloadJson.overload_header = overloadInfoJson.header; + if (overloadInfoJson) { + overloadJson.overload_header = overloadInfoJson.header; + if (overloadInfoJson.description) { + overloadJson.has_comments = true; + overloadJson.overload_description = splitStringByNewline(overloadInfoJson.description); } } } } if (infoJson.arguments) { - for (const argumentJson of commandJson.arguments) { + const argumentsToDocument = new Set([ + ...commandJson.arguments, + ...commandJson.overloads.flatMap(overload => overload.params), + ]); + for (const argumentJson of argumentsToDocument) { const argInfoJson = infoJson.arguments.find(a => argumentJson.directory_name === a.name); if (argInfoJson && argInfoJson.description) { argumentJson.has_comments = true; @@ -1060,6 +1124,8 @@ function addCommandDescriptions(fileLoader: FileLoader, commandJson: MinecraftCo } } + addCommandExamples(fileLoader, commandJson, commandFolderPath); + if (commandJson.command_enums) { for (const enumJson of commandJson.command_enums) { addCommandEnumDescriptions(fileLoader, enumJson, commandFolderPath); diff --git a/tools/api-docs-generator/src/modules/MinecraftCommandModule.ts b/tools/api-docs-generator/src/modules/MinecraftCommandModule.ts index e6aef6e5..306d472c 100644 --- a/tools/api-docs-generator/src/modules/MinecraftCommandModule.ts +++ b/tools/api-docs-generator/src/modules/MinecraftCommandModule.ts @@ -71,6 +71,13 @@ export const MinecraftCommandArgumentRecord = Record({ }); export type MinecraftCommandArgument = Static; +export const MinecraftCommandExampleRecord = Record({ + title: String, + code: String, + description: Optional(Array(String)), +}); +export type MinecraftCommandExample = Static; + export const MinecraftCommandOverloadRecord = Record({ name: String, params: Array(MinecraftCommandArgumentRecord), @@ -79,6 +86,7 @@ export const MinecraftCommandOverloadRecord = Record({ has_comments: Optional(Boolean), overload_description: Optional(Array(String)), overload_header: Optional(String), + overload_examples: Optional(Array(MinecraftCommandExampleRecord)), }); export type MinecraftCommandOverload = Static; @@ -100,6 +108,7 @@ export const MinecraftCommandRecord = Record({ has_comments: Optional(Boolean), command_description: Optional(Array(String)), + command_examples: Optional(Array(MinecraftCommandExampleRecord)), }); export type MinecraftCommand = Static; diff --git a/tools/api-docs-generator/src/modules/MinecraftDocsTypes.ts b/tools/api-docs-generator/src/modules/MinecraftDocsTypes.ts index fcdbe4d2..3d73d901 100644 --- a/tools/api-docs-generator/src/modules/MinecraftDocsTypes.ts +++ b/tools/api-docs-generator/src/modules/MinecraftDocsTypes.ts @@ -83,6 +83,15 @@ export type ScriptNestedFunctionDocsData = Static; +export const CommandExampleDocsValidator = Intersect( + CommonDocsDescriptionValidator, + Record({ + title: Optional(String), + overload: Optional(Number), + }) +); +export type CommandExampleDocsData = Static; + export const CommandDocsValidator = Intersect( CommonDocsDescriptionValidator, Record({ diff --git a/tools/api-docs-generator/src/test/normalizeExampleText.test.ts b/tools/api-docs-generator/src/test/normalizeExampleText.test.ts new file mode 100644 index 00000000..55d212a5 --- /dev/null +++ b/tools/api-docs-generator/src/test/normalizeExampleText.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { FileLoader } from '../FileLoader'; +import { CommonFilters } from '../filters'; +import { MinecraftRelease } from '../MinecraftRelease'; +import { MinecraftScriptModule } from '../modules/MinecraftScriptModule'; +import { normalizeExampleText } from '../utilities'; + +describe('normalizeExampleText', () => { + it.each([ + { name: 'text without a final newline', input: 'first\nsecond', expected: 'first\nsecond' }, + { name: 'a leading BOM', input: '\uFEFFfirst', expected: 'first' }, + { name: 'an embedded BOM', input: 'first\uFEFFsecond', expected: 'first\uFEFFsecond' }, + { name: 'LF line endings', input: 'first\nsecond\n', expected: 'first\nsecond' }, + { name: 'CRLF line endings', input: 'first\r\nsecond\r\n', expected: 'first\nsecond' }, + { name: 'CR line endings', input: 'first\rsecond\r', expected: 'first\nsecond' }, + { name: 'mixed line endings', input: 'first\r\nsecond\rthird\n', expected: 'first\nsecond\nthird' }, + { name: 'leading blank lines', input: '\n\nfirst\n', expected: '\n\nfirst' }, + { name: 'extra trailing blank lines', input: 'first\n\n\n', expected: 'first\n\n' }, + { name: 'indentation and trailing spaces', input: '\tfirst \n second \n', expected: '\tfirst \n second ' }, + { name: 'empty input', input: '', expected: '' }, + { name: 'a newline only', input: '\n', expected: '' }, + { name: 'a BOM only', input: '\uFEFF', expected: '' }, + { name: 'spaces only', input: ' ', expected: ' ' }, + ])('handles $name', ({ input, expected }) => { + expect(normalizeExampleText(input)).toBe(expected); + }); +}); + +describe('Script example normalization', () => { + let directory: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'script-example-normalization-')); + }); + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it.each([ + { + name: 'LF input with whitespace and a blank line', + input: '/* comment */\n const value = 1; \n\n', + expected: ['/* comment */', ' const value = 1; ', ''], + }, + { + name: 'a BOM and mixed line endings', + input: '\uFEFF/* comment */\rconst first = 1;\r\nconst second = 2;\n', + expected: ['/* comment */', 'const first = 1;', 'const second = 2;'], + }, + { name: 'empty input', input: '', expected: [] }, + { name: 'one blank line', input: '\n', expected: [''] }, + { name: 'two blank lines', input: '\r\n\r\n', expected: ['', ''] }, + ])('normalizes local and shared examples with $name', ({ input, expected }) => { + const module: MinecraftScriptModule = { + name: 'test-module', + uuid: '411a0018-bbd6-43a5-96eb-ce7c4162f491', + version: '1.0.0', + minecraft_version: '1.0.0', + module_type: 'script', + }; + const moduleDirectory = path.join(directory, module.name); + fs.mkdirSync(path.join(moduleDirectory, '_examples'), { recursive: true }); + fs.mkdirSync(path.join(moduleDirectory, '_shared_examples')); + fs.writeFileSync(path.join(moduleDirectory, '_examples', 'local.ts'), input); + fs.writeFileSync(path.join(moduleDirectory, '_shared_examples', 'shared.ts'), input); + fs.writeFileSync(path.join(moduleDirectory, '_example_files.json'), JSON.stringify(['shared.ts'])); + + const release = new MinecraftRelease('1.0.0'); + release.script_modules = [module]; + const loader = new FileLoader(directory, ['.json', '.ts']); + for (const [, filter] of CommonFilters.filters) { + filter([release], loader); + } + + expect(module.examples.map(example => example.name)).toEqual(['local.ts', 'shared.ts']); + for (const example of module.examples) { + expect(example.code.text).toEqual(expected); + expect(example.code.escaped_text).toEqual( + expected.map(line => line.replace(/\/\*/g, '/\\*').replace(/\*\//g, '*\\/')) + ); + } + }); +}); diff --git a/tools/api-docs-generator/src/utilities/FormatMarkdownTableCell.ts b/tools/api-docs-generator/src/utilities/FormatMarkdownTableCell.ts new file mode 100644 index 00000000..46aa7a6d --- /dev/null +++ b/tools/api-docs-generator/src/utilities/FormatMarkdownTableCell.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Converts LF/CRLF line breaks to HTML breaks and escapes unescaped table delimiters. + */ +export function formatMarkdownTableCell(text: string): string { + return text.replace(/\r?\n/g, '
').replace(/(? { + let directory: string; + let give: string; + let other: string; + let outputDirectory: string; + + const parameter = (name: string, type: string, optional = false): MinecraftCommandArgument => ({ + name, + is_optional: optional, + type: { name: type }, + }); + + beforeAll(async () => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'command-docs-')); + outputDirectory = path.join(directory, 'output'); + const docsDirectory = path.join(directory, 'docs'); + const writeInfo = (segments: string[], data: unknown) => { + const filename = path.join(docsDirectory, 'test-commands', ...segments, 'info.json'); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, JSON.stringify(data)); + }; + writeInfo(['commands', 'give'], { + description: 'Give an item.', + overloads: [ + { + id: 1, + header: 'Give items', + }, + ], + arguments: [ + { name: 'player', description: 'A **player** or `selector`.' }, + { name: 'amount-int', description: ['Use `1|2`.', 'Default: `1`.'] }, + { name: 'amount-string', description: 'A textual amount.' }, + ], + }); + const examplesDirectory = path.join(docsDirectory, 'test-commands', 'commands', 'give', '_examples'); + fs.mkdirSync(examplesDirectory); + fs.writeFileSync(path.join(examplesDirectory, 'building-materials.mcfunction'), '/give @s stone 64\n'); + fs.writeFileSync( + path.join(examplesDirectory, 'building-materials.json'), + JSON.stringify({ title: 'Command example' }) + ); + fs.writeFileSync( + path.join(examplesDirectory, 'components.mcfunction'), + '/give @p diamond_pickaxe 1 0 {"minecraft:can_destroy":{"blocks":["stone"]}}\r\n/give @s torch\r\n' + ); + fs.writeFileSync( + path.join(examplesDirectory, 'components.json'), + JSON.stringify({ + title: 'Component example', + description: ['Keep the JSON unchanged.', 'Run this command:'], + overload: 1, + }) + ); + writeInfo(['command_types', 'target'], { description: 'Select a player using `@s`.' }); + writeInfo(['command_types', 'int'], { description: 'An integer.' }); + const module: MinecraftCommandModule = { + name: 'test-commands', + module_type: 'commands', + minecraft_version: '1.0.0', + commands: [ + { + name: 'give', + description: 'Raw description.', + aliases: [{ name: 'gift' }, { name: 'present' }], + permission_level: 1, + requires_cheats: true, + overloads: [ + { + name: '1', + params: [ + parameter('player', 'SELECTION'), + parameter('itemName', 'ITEM'), + parameter('amount', 'INT', true), + parameter('components', 'JSON_OBJECT', true), + ], + }, + { + name: '2', + params: [parameter('player', 'SELECTION', true), parameter('amount', 'INT', true)], + }, + { + name: '3', + params: [ + parameter('amount', 'ID'), + parameter('mode', 'LOCALMODE'), + parameter('keyword', 'KEYWORD', true), + parameter('objective', 'SCOREBOARDOBJECTIVES', true), + ], + }, + ], + }, + { + name: 'other', + description: 'Metadata-only command.', + permission_level: 0, + requires_cheats: false, + overloads: [ + { name: '1', params: [] }, + { name: '2', params: [parameter('mode', 'KEYWORD')] }, + { + name: '3', + params: [ + parameter('mode', 'KEYWORD', true), + parameter('item', 'ITEM'), + parameter('value', 'WILDCARDINT', true), + parameter('objective', 'SCOREBOARDOBJECTIVES', true), + ], + }, + ], + }, + ], + command_enums: [ + { name: 'Item', values: [{ value: 'stone' }, { value: 'torch' }] }, + { name: 'Keyword', values: [{ value: 'reset' }] }, + { name: 'LocalMode', values: [{ value: 'first' }, { value: 'second' }] }, + { name: 'ScoreboardObjectives', values: [] }, + ], + }; + const release = new MinecraftRelease('1.0.0'); + release.command_modules = [module]; + const context = await GeneratorContext.Init({ + inputDirectory: directory, + outputDirectory, + documentationDirectory: docsDirectory, + ignoreConfig: true, + generatorsToRun: ['msdocs'], + log: { level: 'error' }, + }); + for (const [, filter] of CommonFilters.filters) { + filter([release], context.documentationFileLoader); + } + await new MSDocsMarkdownGenerator().generateFiles(context, [release], outputDirectory); + give = fs + .readFileSync(path.join(outputDirectory, 'commands', 'commands', 'give.md'), 'utf8') + .replace(/\r\n/g, '\n'); + other = fs + .readFileSync(path.join(outputDirectory, 'commands', 'commands', 'other.md'), 'utf8') + .replace(/\r\n/g, '\n'); + }); + + afterAll(() => { + if (directory) { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('renders syntax, metadata, and usage tables in parameter order', () => { + expect(give).toContain('| **Aliases** | `/gift`, `/present` |'); + expect(give).toContain('| **Permission Level** | Game Directors |'); + expect(give).toContain('| **Requires Cheats** | Yes |'); + expect(give).toContain('## Syntax Overview'); + expect(give).toContain('`/give [amount: int] [components: json]`'); + expect(give).toContain('### Give items'); + expect(give).toContain('| Argument | Type | Required | Description |'); + expect(give).toContain('| `player` | [target](../types/target.md) | Required | A **player** or `selector`. |'); + expect(give).toContain('| `player` | [target](../types/target.md) | Optional | A **player** or `selector`. |'); + expect(give.indexOf('| `player`')).toBeLessThan(give.indexOf('| `itemName`')); + expect(give.indexOf('| `itemName`')).toBeLessThan(give.indexOf('| `amount`')); + }); + + it('documents every overload and escapes table descriptions without losing Markdown', () => { + expect(give.match(/\| Optional \| Use `1\\\|2`\.
Default: `1`\. \|/g)).toHaveLength(2); + expect(give).toContain('| `amount` | [string](../types/string.md) | Required | A textual amount. |'); + const reference = give.slice(give.indexOf('## Arguments Reference')); + expect(reference).toContain('| `amount` | [int](../types/int.md) | Use `1\\|2`.
Default: `1`. |'); + expect(reference).toContain('| `amount` | [string](../types/string.md) | A textual amount. |'); + expect(reference.indexOf('| `amount`')).toBeLessThan(reference.indexOf('| `player`')); + }); + + it('renders command and overload examples once, retaining JSON and multiple lines', () => { + expect(give.match(/## Examples/g)).toHaveLength(1); + expect(give.match(/\*\*Examples:\*\*/g)).toHaveLength(1); + expect(give.match(/\/give @s stone 64/g)).toHaveLength(1); + expect(give).toContain('### Command example\n\n```text\n/give @s stone 64\n```'); + expect(give).toContain('Keep the JSON unchanged.\n\nRun this command:'); + expect(give).toContain( + '```text\n/give @p diamond_pickaxe 1 0 {"minecraft:can_destroy":{"blocks":["stone"]}}\n/give @s torch\n```' + ); + }); + + it('preserves metadata-only commands, literal syntax, optional keywords, and inline enums', () => { + expect(other).toContain('Metadata-only command.'); + expect(other).toContain('| **Requires Cheats** | No |'); + expect(other).not.toContain('Examples'); + expect(other).toContain('`/other reset`'); + expect(other).toContain( + '`/other [reset: Keyword] [value: wildcard int] [objective: ScoreboardObjectives]`' + ); + expect(other.match(/\| Argument \| Type \| Required \| Description \|/g)).toHaveLength(1); + expect(other).toContain('| `reset` | Keyword | Optional | |'); + expect(give).toContain('### `LocalMode`'); + expect(give).toContain('| `mode` | LocalMode | Required | |'); + expect(give).toContain('| `objective` | ScoreboardObjectives | Optional | |'); + }); + + it('generates primitive type pages and keeps every argument link resolvable', () => { + const commandDirectory = path.join(outputDirectory, 'commands', 'commands'); + for (const markdown of [give, other]) { + for (const match of markdown.matchAll(/\]\((\.\.\/(?:types|enums)\/[^)]+)\)/g)) { + expect(fs.existsSync(path.resolve(commandDirectory, decodeURIComponent(match[1])))).toBe(true); + } + } + expect(fs.readFileSync(path.join(outputDirectory, 'commands', 'types', 'target.md'), 'utf8')).toContain( + 'Select a player using `@s`.' + ); + expect(other).toContain('[wildcard int](../types/wildcard%20int.md)'); + expect(fs.readFileSync(path.join(outputDirectory, 'commands', 'commands.md'), 'utf8')).toContain( + '[wildcard int](./types/wildcard%20int.md)' + ); + expect(fs.readFileSync(path.join(outputDirectory, 'commands', 'TOC.yml'), 'utf8')).toContain( + 'href: types/target.md' + ); + }); + + it('adds a section TOC before enum values without listing individual values', () => { + const markdown = fs + .readFileSync(path.join(outputDirectory, 'commands', 'enums', 'Item.md'), 'utf8') + .replace(/\r\n/g, '\n'); + const contents = markdown.slice(markdown.indexOf('## Contents'), markdown.indexOf('## Values')); + expect(contents).toBe('## Contents\n\n- [Values](#values)\n- [References](#references)\n\n'); + expect(markdown).toContain('## Values\n- `stone`\n- `torch`'); + expect(markdown).toContain('## References'); + expect(markdown).toContain('- [give](../commands/give.md)'); + }); + + it.each([{ commandReferences: undefined }, { commandReferences: [] }])( + 'omits the References TOC entry when references are $commandReferences', + ({ commandReferences }) => { + const template = fs.readFileSync( + path.join(__dirname, '..', 'templates', 'msdocs', 'commands', 'enum.mustache'), + 'utf8' + ); + const markdown = mustache.render(template, { + enum_name: 'Test', + values: [{ value: 'first' }, { value: 'second' }], + command_references: commandReferences, + }); + expect(markdown).toContain('- [Values](#values)'); + expect(markdown).toContain('## Values'); + expect(markdown).not.toContain('- [References](#references)'); + expect(markdown).not.toContain('## References'); + } + ); + + it('accepts existing documentation and validates optional example sidecars', () => { + expect(CommandDocsValidator.guard({ description: 'Existing docs.', overloads: [{ id: 1 }] })).toBe(true); + expect(CommandExampleDocsValidator.guard({})).toBe(true); + expect( + CommandExampleDocsValidator.guard({ + title: 'Example', + description: 'A description.', + overload: 1, + }) + ).toBe(true); + expect(CommandExampleDocsValidator.guard({ title: 42 })).toBe(false); + expect(CommandExampleDocsValidator.guard({ overload: '1' })).toBe(false); + }); +}); diff --git a/tools/markup-generators-plugin/src/generators/MSDocsMarkdownGenerator.ts b/tools/markup-generators-plugin/src/generators/MSDocsMarkdownGenerator.ts index 5e479fe6..509b8933 100644 --- a/tools/markup-generators-plugin/src/generators/MSDocsMarkdownGenerator.ts +++ b/tools/markup-generators-plugin/src/generators/MSDocsMarkdownGenerator.ts @@ -18,6 +18,8 @@ import { MinecraftBlockProperty, MinecraftClass, MinecraftCommand, + MinecraftCommandArgument, + MinecraftCommandArgumentType, MinecraftCommandEnum, MinecraftCommandModule, MinecraftEnum, @@ -386,9 +388,27 @@ export class MSDocsMarkdownGenerator implements MarkupGenerator { outputDirectory: string ): void { const msdocsTemplateFileData = mdTemplateFiles.readFileAsString('commands/command.mustache'); - const msdocsProcessedData = mustache.render(msdocsTemplateFileData, commandJson, { + const formatArgument = (argument: MinecraftCommandArgument) => ({ + ...argument, + table_description: Utils.formatMarkdownTableCell((argument.argument_description ?? []).join('\n')), + table_type: argument.type.has_link + ? `[${argument.type.name}](../${argument.type.is_enum ? 'enums' : 'types'}/${encodeURIComponent(argument.type.name)}.md)` + : argument.type.name, + }); + const commandView = { + ...commandJson, + arguments: commandJson.arguments?.map(formatArgument), + overloads: commandJson.overloads?.map(overload => ({ + ...overload, + usage_arguments: overload.params + .filter(argument => !argument.type.is_keyword || argument.is_optional) + .map(formatArgument), + })), + }; + const msdocsProcessedData = mustache.render(msdocsTemplateFileData, commandView, { overload: mdTemplateFiles.readFileAsString('commands/overload.mustache'), parameter: mdTemplateFiles.readFileAsString('commands/parameter.mustache'), + example: mdTemplateFiles.readFileAsString('commands/example.mustache'), inline_enum: mdTemplateFiles.readFileAsString('commands/inline_enum.mustache'), default_metadata: mdTemplateFiles.readFileAsString('commands/default_metadata.mustache'), }); @@ -411,14 +431,30 @@ export class MSDocsMarkdownGenerator implements MarkupGenerator { fs.writeFileSync(msdocsEnumFilePath, msdocsProcessedData); } + private generateCommandTypeFiles( + typeJson: MinecraftCommandArgumentType, + mdTemplateFiles: FileLoader, + outputDirectory: string + ): void { + const msdocsProcessedData = mustache.render( + mdTemplateFiles.readFileAsString('commands/type.mustache'), + typeJson, + { default_metadata: mdTemplateFiles.readFileAsString('commands/default_metadata.mustache') } + ); + const msdocsTypeFilePath = path.join(outputDirectory, 'types', `${typeJson.name}.md`); + fs.mkdirSync(path.dirname(msdocsTypeFilePath), { recursive: true }); + fs.writeFileSync(msdocsTypeFilePath, msdocsProcessedData); + } + private generateCommandsTableOfContents( - commandsJson: MinecraftCommand[], + moduleJson: MinecraftCommandModule, mdTemplateFiles: FileLoader, outputDirectory: string ): void { const msdocsTemplateFileData = mdTemplateFiles.readFileAsString('commands/toc.mustache'); const msdocsProcessedData = mustache.render(msdocsTemplateFileData, { - commands: commandsJson, + commands: moduleJson.commands, + command_types: moduleJson.command_types, }); const msdocsModuleFilePath = path.join(outputDirectory, `TOC.yml`); fs.mkdirSync(path.dirname(msdocsModuleFilePath), { recursive: true }); @@ -436,7 +472,10 @@ export class MSDocsMarkdownGenerator implements MarkupGenerator { { commands: moduleJson.commands, command_enums: moduleJson.command_enums, - command_types: moduleJson.command_types, + command_types: moduleJson.command_types?.map(type => ({ + ...type, + type_path: encodeURIComponent(type.name), + })), }, { default_metadata: mdTemplateFiles.readFileAsString('commands/default_metadata.mustache'), @@ -568,9 +607,13 @@ export class MSDocsMarkdownGenerator implements MarkupGenerator { this.generateCommandEnumFiles(enumJson, mdTemplateFiles, commandsMSDocsOutputPath); } + for (const typeJson of moduleJson.command_types ?? []) { + this.generateCommandTypeFiles(typeJson, mdTemplateFiles, commandsMSDocsOutputPath); + } + this.generateCommandsSummaryFile(moduleJson, mdTemplateFiles, commandsMSDocsOutputPath); - this.generateCommandsTableOfContents(moduleJson.commands, mdTemplateFiles, commandsMSDocsOutputPath); + this.generateCommandsTableOfContents(moduleJson, mdTemplateFiles, commandsMSDocsOutputPath); } /// diff --git a/tools/markup-generators-plugin/templates/msdocs/commands/command.mustache b/tools/markup-generators-plugin/templates/msdocs/commands/command.mustache index 27022c3f..df5bba45 100644 --- a/tools/markup-generators-plugin/templates/msdocs/commands/command.mustache +++ b/tools/markup-generators-plugin/templates/msdocs/commands/command.mustache @@ -12,35 +12,43 @@ description: Description and usage of the {{{command_name}}} command {{{description}}} {{/command_description}} - +| | | +| --- | --- | {{#aliases.length}} - - - - +| **Aliases** | {{#aliases}}`/{{{name}}}`{{^is_last}}, {{/is_last}}{{/aliases}} | {{/aliases.length}} - - - - - - - - -
Aliases{{#aliases}}/{{{name}}}{{^is_last}}, {{/is_last}}{{/aliases}}
Permission Level{{permission_level_name}}
Requires Cheats?{{#requires_cheats}}Yes{{/requires_cheats}}{{^requires_cheats}}No{{/requires_cheats}}
+| **Permission Level** | {{permission_level_name}} | +| **Requires Cheats** | {{#requires_cheats}}Yes{{/requires_cheats}}{{^requires_cheats}}No{{/requires_cheats}} | +{{#overloads.length}} + +## Syntax Overview +{{#overloads}} + +`/{{{command_name}}}{{#params}} {{> parameter}}{{/params}}` +{{/overloads}} ## Usage {{#overloads}} {{> overload}} {{/overloads}} +{{/overloads.length}} +{{#command_examples.length}} + +## Examples +{{#command_examples}} + +### {{{title}}} +{{> example}} +{{/command_examples}} +{{/command_examples.length}} {{#arguments.length}} -## Arguments +## Arguments Reference + +| Argument | Type | Description | +| --- | --- | --- | {{#arguments}} -- `{{{param_name}}}{{#type.syntax}}: {{{.}}}{{/type.syntax}}`: {{#type.has_link}}[{{{type.name}}}]{{#type.is_enum}}(../enums/{{{type.name}}}.md){{/type.is_enum}}{{^type.is_enum}}(../types/{{{type.name}}}.md){{/type.is_enum}}{{/type.has_link}}{{^type.has_link}}{{{type.name}}}{{/type.has_link}} -{{#argument_description}} -{{{.}}} -{{/argument_description}} +| `{{{param_name}}}{{#type.syntax}}: {{{.}}}{{/type.syntax}}` | {{{table_type}}} | {{{table_description}}} | {{/arguments}} {{/arguments.length}} {{#command_enums.length}} diff --git a/tools/markup-generators-plugin/templates/msdocs/commands/enum.mustache b/tools/markup-generators-plugin/templates/msdocs/commands/enum.mustache index 18a0ce59..a17b32d6 100644 --- a/tools/markup-generators-plugin/templates/msdocs/commands/enum.mustache +++ b/tools/markup-generators-plugin/templates/msdocs/commands/enum.mustache @@ -8,6 +8,13 @@ description: Description and values of the {{{enum_name}}} enum {{{.}}} {{/enum_description}} +## Contents + +- [Values](#values) +{{#command_references.length}} +- [References](#references) +{{/command_references.length}} + ## Values {{#values}} - `{{{value}}}` diff --git a/tools/markup-generators-plugin/templates/msdocs/commands/example.mustache b/tools/markup-generators-plugin/templates/msdocs/commands/example.mustache new file mode 100644 index 00000000..e4a537ad --- /dev/null +++ b/tools/markup-generators-plugin/templates/msdocs/commands/example.mustache @@ -0,0 +1,8 @@ + +{{#description}} +{{{.}}} + +{{/description}} +```text +{{{code}}} +``` diff --git a/tools/markup-generators-plugin/templates/msdocs/commands/overload.mustache b/tools/markup-generators-plugin/templates/msdocs/commands/overload.mustache index c0516f3c..22e1b8ac 100644 --- a/tools/markup-generators-plugin/templates/msdocs/commands/overload.mustache +++ b/tools/markup-generators-plugin/templates/msdocs/commands/overload.mustache @@ -6,6 +6,23 @@ {{#overload_description}} {{{.}}} {{/overload_description}} +{{#usage_arguments.length}} + +| Argument | Type | Required | Description | +| --- | --- | --- | --- | +{{#usage_arguments}} +| `{{{param_name}}}{{#type.syntax}}: {{{.}}}{{/type.syntax}}` | {{{table_type}}} | {{#is_optional}}Optional{{/is_optional}}{{^is_optional}}Required{{/is_optional}} | {{{table_description}}} | +{{/usage_arguments}} +{{/usage_arguments.length}} +{{#overload_examples.length}} + +**Examples:** +{{#overload_examples}} + +{{{title}}} +{{> example}} +{{/overload_examples}} +{{/overload_examples.length}} {{^is_last}} {{/is_last}} \ No newline at end of file diff --git a/tools/markup-generators-plugin/templates/msdocs/commands/summary.mustache b/tools/markup-generators-plugin/templates/msdocs/commands/summary.mustache index eaa5cf25..45517142 100644 --- a/tools/markup-generators-plugin/templates/msdocs/commands/summary.mustache +++ b/tools/markup-generators-plugin/templates/msdocs/commands/summary.mustache @@ -14,7 +14,7 @@ description: List and descriptions of Minecraft commands and enums |Type |Description |Is Enum? | |----------|-----------|-----------| {{#command_types}} -| {{{name}}} | {{type_description}} | | +| [{{{name}}}](./types/{{{type_path}}}.md) | {{type_description}} | | {{/command_types}} {{#command_enums}} | [`{{{name}}}`](./enums/{{{name}}}.md) | {{enum_description}} | Yes | diff --git a/tools/markup-generators-plugin/templates/msdocs/commands/toc.mustache b/tools/markup-generators-plugin/templates/msdocs/commands/toc.mustache index 10bcaff4..b375dc65 100644 --- a/tools/markup-generators-plugin/templates/msdocs/commands/toc.mustache +++ b/tools/markup-generators-plugin/templates/msdocs/commands/toc.mustache @@ -4,3 +4,11 @@ - name: {{{name}}} href: commands/{{{name}}}.md {{/commands}} +{{#command_types.length}} +- name: Command argument types + items: +{{#command_types}} + - name: {{{name}}} + href: types/{{{name}}}.md +{{/command_types}} +{{/command_types.length}} diff --git a/tools/markup-generators-plugin/templates/msdocs/commands/type.mustache b/tools/markup-generators-plugin/templates/msdocs/commands/type.mustache new file mode 100644 index 00000000..0106c2b5 --- /dev/null +++ b/tools/markup-generators-plugin/templates/msdocs/commands/type.mustache @@ -0,0 +1,10 @@ +--- +{{ >default_metadata }} +title: {{{type_name}}} Command Argument Type +description: Description of the {{{type_name}}} command argument type +--- +# `{{{type_name}}}` Command Argument Type +{{#type_description}} +{{{.}}} + +{{/type_description}}