diff --git a/README.md b/README.md index 1a394375..1b683a41 100644 --- a/README.md +++ b/README.md @@ -335,9 +335,11 @@ of different hooks that will be called at different stages of the documentation All hooks can be async functions, allowing you to make asynchronous operations, like calling an external API. -📒 Note: The extension hook functions are only available when generating Markdown files (not OpenApi). +There are hooks for both Markdown and Changelog operations (but not for OpenApi). -#### **transformReferenceGuide** +#### Markdown Hooks + +##### **transformReferenceGuide** Allows changing the Allows changing the frontmatter and content of the reference guide, or even if creating a reference guide page should be skipped. @@ -374,7 +376,7 @@ export default defineMarkdownConfig({ }); ``` -#### **transformDocs** +##### **transformDocs** The main purpose of this hook is to allow you to skip the generation of specific pages, by returning a filtered array of `DocPageData` objects. @@ -397,7 +399,7 @@ export default { }; ``` -#### **transformDocPage** +##### **transformDocPage** Allows changing the frontmatter and content of the doc page. @@ -421,7 +423,7 @@ export default { }; ``` -#### **transformReference** +##### **transformReference** Allows changing where the files are written to and how files are linked to each other. @@ -449,6 +451,32 @@ export default { }; ``` +#### Changelog Hooks + +##### **transformChangeLogPage** + +Allows changing the frontmatter and content of the changelog page. + +**Type** + +```typescript +type TransformChangeLogPage = ( + changelog: ChangeLogPageData, +) => Partial | Promise> +``` + +Example + +```typescript +export default { + transformChangeLogPage: (changelog) => { + return { + frontmatter: { example: 'example' } + }; + } +}; +``` + ## ⤵︎ Importing to your project ### Reflection diff --git a/examples/vitepress/apexdocs.config.ts b/examples/vitepress/apexdocs.config.ts index 15db7a60..d32405b7 100644 --- a/examples/vitepress/apexdocs.config.ts +++ b/examples/vitepress/apexdocs.config.ts @@ -30,6 +30,13 @@ export default { previousVersionDir: 'previous', currentVersionDir: 'force-app', scope: ['global', 'public', 'protected', 'private', 'namespaceaccessible'], + transformChangeLogPage: () => { + return { + frontmatter: { + title: 'Changelog', + }, + }; + }, }), markdown: defineMarkdownConfig({ sourceDir: 'force-app', diff --git a/examples/vitepress/docs/.vitepress/sidebar.json b/examples/vitepress/docs/.vitepress/sidebar.json index dd5afb72..6f2444a3 100644 --- a/examples/vitepress/docs/.vitepress/sidebar.json +++ b/examples/vitepress/docs/.vitepress/sidebar.json @@ -58,6 +58,10 @@ { "text": "Object Reference", "items": [ + { + "text": "Contact", + "link": "custom-objects/Contact.md" + }, { "text": "Event__c", "link": "custom-objects/Event__c.md" @@ -70,6 +74,10 @@ "text": "Product__c", "link": "custom-objects/Product__c.md" }, + { + "text": "Product_Inline_Fields__c", + "link": "custom-objects/Product_Inline_Fields__c.md" + }, { "text": "Product_Price_Component__c", "link": "custom-objects/Product_Price_Component__c.md" diff --git a/examples/vitepress/docs/changelog.md b/examples/vitepress/docs/changelog.md index fedd3cc0..3cc6f539 100644 --- a/examples/vitepress/docs/changelog.md +++ b/examples/vitepress/docs/changelog.md @@ -1,3 +1,7 @@ +--- +title: Changelog +--- + # Changelog ## New Classes @@ -51,6 +55,9 @@ These custom objects are new. Represents an event that people can register for. ### Price_Component__c +### Product_Inline_Fields__c + +Products ### Product_Price_Component__c ### Product__c @@ -64,4 +71,12 @@ Represents a line item on a sales order. Custom object for tracking sales orders. ### Speaker__c -Represents a speaker at an event. \ No newline at end of file +Represents a speaker at an event. + +## New or Removed Fields to Custom Objects or Standard Objects + +These custom fields have been added or removed. + +### Contact + +- New Field: PhotoUrl__c. URL of the contact's photo \ No newline at end of file diff --git a/examples/vitepress/docs/custom-objects/Contact.md b/examples/vitepress/docs/custom-objects/Contact.md new file mode 100644 index 00000000..1297e574 --- /dev/null +++ b/examples/vitepress/docs/custom-objects/Contact.md @@ -0,0 +1,21 @@ +--- +title: Contact +--- + +# Contact + +## API Name +`apexdocs__Contact` + +## Fields +### PhotoUrl + +URL of the contact's photo + +**API Name** + +`apexdocs__PhotoUrl__c` + +**Type** + +*Url* \ No newline at end of file diff --git a/examples/vitepress/docs/custom-objects/Event__c.md b/examples/vitepress/docs/custom-objects/Event__c.md index 892379a4..f280e216 100644 --- a/examples/vitepress/docs/custom-objects/Event__c.md +++ b/examples/vitepress/docs/custom-objects/Event__c.md @@ -22,6 +22,7 @@ Represents an event that people can register for. --- ### End Date +**Required** **API Name** @@ -33,6 +34,7 @@ Represents an event that people can register for. --- ### Location +**Required** **API Name** @@ -44,6 +46,7 @@ Represents an event that people can register for. --- ### Start Date +**Required** **API Name** diff --git a/examples/vitepress/docs/custom-objects/Price_Component__c.md b/examples/vitepress/docs/custom-objects/Price_Component__c.md index fa4be586..d82b34c6 100644 --- a/examples/vitepress/docs/custom-objects/Price_Component__c.md +++ b/examples/vitepress/docs/custom-objects/Price_Component__c.md @@ -59,6 +59,7 @@ Use this when the Price Component represents a Flat Price. To represent a Percen --- ### Type +**Required** **API Name** diff --git a/examples/vitepress/docs/custom-objects/Product_Inline_Fields__c.md b/examples/vitepress/docs/custom-objects/Product_Inline_Fields__c.md new file mode 100644 index 00000000..91d94d0c --- /dev/null +++ b/examples/vitepress/docs/custom-objects/Product_Inline_Fields__c.md @@ -0,0 +1,114 @@ +--- +title: Product_Inline_Fields__c +--- + +# Products + +Products + +## API Name +`apexdocs__Product_Inline_Fields__c` + +## Fields +### DiscontinuedDate + +The date the product got discontinued + +**API Name** + +`apexdocs__DiscontinuedDate__c` + +**Type** + +*DateTime* + +--- +### ID + +**API Name** + +`apexdocs__ID__c` + +**Type** + +*Number* + +--- +### Name +**Required** + +Product name + +**API Name** + +`apexdocs__Name__c` + +**Type** + +*Text* + +--- +### Price + +Product price in the default currency + +**API Name** + +`apexdocs__Price__c` + +**Type** + +*Number* + +--- +### Products + +**API Name** + +`apexdocs__Products__c` + +**Type** + +*ExternalLookup* + +--- +### Rating + +Rating + +**API Name** + +`apexdocs__Rating__c` + +**Type** + +*Number* + +--- +### ReleaseDate + +ReleaseDate + +**API Name** + +`apexdocs__ReleaseDate__c` + +**Type** + +*DateTime* + +--- +### Type +**Required** + +**API Name** + +`apexdocs__Type__c` + +**Type** + +*Picklist* + +#### Possible values are +* Merchandise +* Bundle \ No newline at end of file diff --git a/examples/vitepress/docs/custom-objects/Product__c.md b/examples/vitepress/docs/custom-objects/Product__c.md index ba3e70d6..dc74fc31 100644 --- a/examples/vitepress/docs/custom-objects/Product__c.md +++ b/examples/vitepress/docs/custom-objects/Product__c.md @@ -22,6 +22,7 @@ Product that is sold or available for sale. --- ### Event +**Required** **API Name** diff --git a/examples/vitepress/docs/custom-objects/Sales_Order_Line__c.md b/examples/vitepress/docs/custom-objects/Sales_Order_Line__c.md index bc1ac4f7..3539f0f5 100644 --- a/examples/vitepress/docs/custom-objects/Sales_Order_Line__c.md +++ b/examples/vitepress/docs/custom-objects/Sales_Order_Line__c.md @@ -11,6 +11,7 @@ Represents a line item on a sales order. ## Fields ### Amount +**Required** **API Name** @@ -22,6 +23,7 @@ Represents a line item on a sales order. --- ### Product +**Required** **API Name** @@ -55,6 +57,7 @@ Represents a line item on a sales order. --- ### Type +**Required** **API Name** diff --git a/examples/vitepress/docs/index.md b/examples/vitepress/docs/index.md index e8629caa..6d8ce3bb 100644 --- a/examples/vitepress/docs/index.md +++ b/examples/vitepress/docs/index.md @@ -19,6 +19,8 @@ hero: ## Custom Objects +### [Contact](custom-objects/Contact) + ### [Event__c](custom-objects/Event__c) Represents an event that people can register for. @@ -29,6 +31,10 @@ Represents an event that people can register for. Product that is sold or available for sale. +### [Product_Inline_Fields__c](custom-objects/Product_Inline_Fields__c) + +Products + ### [Product_Price_Component__c](custom-objects/Product_Price_Component__c) ### [Sales_Order__c](custom-objects/Sales_Order__c) diff --git a/examples/vitepress/docs/samplegroup/SampleClass.md b/examples/vitepress/docs/samplegroup/SampleClass.md index 38de2d1f..52776120 100644 --- a/examples/vitepress/docs/samplegroup/SampleClass.md +++ b/examples/vitepress/docs/samplegroup/SampleClass.md @@ -10,6 +10,8 @@ aliquip ex sunt officia ullamco anim deserunt magna aliquip nisi eiusmod in sit **Group** SampleGroup +**See** [Event__c](../custom-objects/Event__c) + ## Namespace apexdocs diff --git a/examples/vitepress/force-app/main/default/classes/feature-a/SampleClass.cls b/examples/vitepress/force-app/main/default/classes/feature-a/SampleClass.cls index f81fcb6d..1b3956bc 100644 --- a/examples/vitepress/force-app/main/default/classes/feature-a/SampleClass.cls +++ b/examples/vitepress/force-app/main/default/classes/feature-a/SampleClass.cls @@ -2,6 +2,7 @@ * @description aliquip ex sunt officia ullamco anim deserunt magna aliquip nisi eiusmod in sit officia veniam ex * **deserunt** ea officia exercitation laboris enim in duis quis enim eiusmod eu amet cupidatat. * @group SampleGroup + * @see Event__c * @example * SampleClass sample = new SampleClass(); * sample.doSomething(); diff --git a/examples/vitepress/force-app/main/default/objects/Contact/fields/PhotoUrl__c.field-meta.xml b/examples/vitepress/force-app/main/default/objects/Contact/fields/PhotoUrl__c.field-meta.xml index a9117781..aad01dc8 100644 --- a/examples/vitepress/force-app/main/default/objects/Contact/fields/PhotoUrl__c.field-meta.xml +++ b/examples/vitepress/force-app/main/default/objects/Contact/fields/PhotoUrl__c.field-meta.xml @@ -6,4 +6,5 @@ false false Url + URL of the contact's photo diff --git a/examples/vitepress/force-app/main/default/objects/Product_Inline_Fields__c/Product_Inline_Fields__c.object-meta.xml b/examples/vitepress/force-app/main/default/objects/Product_Inline_Fields__c/Product_Inline_Fields__c.object-meta.xml new file mode 100644 index 00000000..54eddcc8 --- /dev/null +++ b/examples/vitepress/force-app/main/default/objects/Product_Inline_Fields__c/Product_Inline_Fields__c.object-meta.xml @@ -0,0 +1,175 @@ + + + + CancelEdit + Default + + + Delete + Default + + + Edit + Default + + + Follow + Default + + + List + Default + + + New + Default + + + SaveEdit + Default + + + Tab + Default + + + View + Default + + Deployed + Products + false + OData + false + Products + + DiscontinuedDate__c + The date the product got discontinued + DiscontinuedDate + false + false + false + false + + false + DateTime + + + ID__c + ID + false + false + false + false + + 18 + false + 0 + Number + false + + + Name__c + Product name + Name + false + false + false + false + + 128 + true + Text + false + + + Price__c + Product price in the default currency + Price + false + false + false + false + + 16 + false + 2 + Number + false + + + Products__c + Products + false + false + false + false + + 20 + Products__x + Products + Products + ExternalLookup + + + Rating__c + Rating + Rating + false + false + false + false + + 18 + false + 0 + Number + false + + + ReleaseDate__c + ReleaseDate + ReleaseDate + false + false + false + false + + false + DateTime + + + Type__c + false + + true + false + Picklist + + true + + false + + Merchandise + false + + + + Bundle + false + + + + + + + Products + + ExternalId + ExternalId + ExternalId + ExternalId + DisplayUrl + ID__c + + diff --git a/package-lock.json b/package-lock.json index 73c8a990..b0db8aca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@cparra/apexdocs", - "version": "3.3.2", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cparra/apexdocs", - "version": "3.3.2", + "version": "3.5.1", "license": "MIT", "dependencies": { - "@cparra/apex-reflection": "2.15.0", + "@cparra/apex-reflection": "2.16.0", "@salesforce/source-deploy-retrieve": "^12.8.1", "@types/js-yaml": "^4.0.9", "@types/yargs": "^17.0.32", @@ -574,9 +574,9 @@ "dev": true }, "node_modules/@cparra/apex-reflection": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/@cparra/apex-reflection/-/apex-reflection-2.15.0.tgz", - "integrity": "sha512-BWYw9zzzVvKHXB2itlyMQ5v5EyTn7mCk4xQ6UH4VnSYmKbdK3VVXOZRHmtoUyFmitaM8OGs8eoBYID6RejN7Ug==", + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@cparra/apex-reflection/-/apex-reflection-2.16.0.tgz", + "integrity": "sha512-xcSlsS2wVXe3XV9l2pde7nzcT7ZjuIssjXHVbghALhkiWYx+l+y71vTpS40pkPQ/7up8lDJYBXpiDv7uPp0m0g==", "license": "ISC" }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 8fa6595b..4893cd04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cparra/apexdocs", - "version": "3.5.0", + "version": "3.6.0", "description": "Library with CLI capabilities to generate documentation for Salesforce Apex classes.", "keywords": [ "apex", @@ -61,7 +61,7 @@ ] }, "dependencies": { - "@cparra/apex-reflection": "2.15.0", + "@cparra/apex-reflection": "2.16.0", "@salesforce/source-deploy-retrieve": "^12.8.1", "@types/js-yaml": "^4.0.9", "@types/yargs": "^17.0.32", diff --git a/src/application/generators/changelog.ts b/src/application/generators/changelog.ts index 00899238..e332c6c8 100644 --- a/src/application/generators/changelog.ts +++ b/src/application/generators/changelog.ts @@ -1,8 +1,14 @@ import { pipe } from 'fp-ts/function'; -import { PageData, Skip, UnparsedSourceBundle, UserDefinedChangelogConfig } from '../../core/shared/types'; +import { + ChangeLogPageData, + PageData, + Skip, + UnparsedSourceBundle, + UserDefinedChangelogConfig, +} from '../../core/shared/types'; import * as TE from 'fp-ts/TaskEither'; import { writeFiles } from '../file-writer'; -import { ChangeLogPageData, generateChangeLog } from '../../core/changelog/generate-change-log'; +import { generateChangeLog } from '../../core/changelog/generate-change-log'; import { FileWritingError } from '../errors'; import { isSkip } from '../../core/shared/utils'; diff --git a/src/core/changelog/__test__/generating-change-log.spec.ts b/src/core/changelog/__test__/generating-change-log.spec.ts index fb97c418..8dbbefa4 100644 --- a/src/core/changelog/__test__/generating-change-log.spec.ts +++ b/src/core/changelog/__test__/generating-change-log.spec.ts @@ -1,8 +1,15 @@ -import { UnparsedApexBundle, UnparsedCustomObjectBundle, UnparsedSourceBundle } from '../../shared/types'; -import { ChangeLogPageData, generateChangeLog } from '../generate-change-log'; +import { + ChangeLogPageData, + UnparsedApexBundle, + UnparsedCustomObjectBundle, + UnparsedSourceBundle, +} from '../../shared/types'; +import { generateChangeLog } from '../generate-change-log'; import { assertEither } from '../../test-helpers/assert-either'; import { isSkip } from '../../shared/utils'; -import { customObjectGenerator, unparsedFieldBundleFromRawString } from '../../test-helpers/test-data-builders'; +import { unparsedFieldBundleFromRawString } from '../../test-helpers/test-data-builders'; +import { CustomObjectXmlBuilder } from '../../test-helpers/test-data-builders/custom-object-xml-builder'; +import { CustomFieldXmlBuilder } from '../../test-helpers/test-data-builders/custom-field-xml-builder'; const config = { fileName: 'changelog', @@ -186,7 +193,7 @@ describe('when generating a changelog', () => { describe('that include new custom objects', () => { it('should include a section for new custom objects', async () => { - const newObjectSource = customObjectGenerator(); + const newObjectSource = new CustomObjectXmlBuilder().build(); const oldBundle: UnparsedCustomObjectBundle[] = []; const newBundle: UnparsedCustomObjectBundle[] = [ @@ -244,7 +251,7 @@ describe('when generating a changelog', () => { describe('that includes removed custom objects', () => { it('should include a section for removed custom objects', async () => { - const oldObjectSource = customObjectGenerator(); + const oldObjectSource = new CustomObjectXmlBuilder().build(); const oldBundle: UnparsedCustomObjectBundle[] = [ { type: 'customobject', name: 'MyTestObject', content: oldObjectSource, filePath: 'MyTestObject.object' }, @@ -259,7 +266,7 @@ describe('when generating a changelog', () => { }); it('should include the removed custom object name', async () => { - const oldObjectSource = customObjectGenerator(); + const oldObjectSource = new CustomObjectXmlBuilder().build(); const oldBundle: UnparsedCustomObjectBundle[] = [ { type: 'customobject', name: 'MyTestObject', content: oldObjectSource, filePath: 'MyTestObject.object' }, @@ -329,8 +336,8 @@ describe('when generating a changelog', () => { describe('that includes modifications to custom fields', () => { it('should include a section for new or removed custom fields', async () => { - const oldObjectSource = customObjectGenerator(); - const newObjectSource = customObjectGenerator(); + const oldObjectSource = new CustomObjectXmlBuilder().build(); + const newObjectSource = new CustomObjectXmlBuilder().build(); const oldBundle: UnparsedSourceBundle[] = [ { type: 'customobject', name: 'MyTestObject', content: oldObjectSource, filePath: 'MyTestObject.object' }, @@ -346,13 +353,15 @@ describe('when generating a changelog', () => { const result = await generateChangeLog(oldBundle, newBundle, config)(); assertEither(result, (data) => - expect((data as ChangeLogPageData).content).toContain('## New or Removed Fields in Existing Objects'), + expect((data as ChangeLogPageData).content).toContain( + '## New or Removed Fields to Custom Objects or Standard Objects', + ), ); }); it('should include new custom field names', async () => { - const oldObjectSource = customObjectGenerator(); - const newObjectSource = customObjectGenerator(); + const oldObjectSource = new CustomObjectXmlBuilder().build(); + const newObjectSource = new CustomObjectXmlBuilder().build(); const oldBundle: UnparsedSourceBundle[] = [ { type: 'customobject', name: 'MyTestObject', content: oldObjectSource, filePath: 'MyTestObject.object' }, @@ -371,8 +380,8 @@ describe('when generating a changelog', () => { }); it('should include removed custom field names', async () => { - const oldObjectSource = customObjectGenerator(); - const newObjectSource = customObjectGenerator(); + const oldObjectSource = new CustomObjectXmlBuilder().build(); + const newObjectSource = new CustomObjectXmlBuilder().build(); const oldBundle: UnparsedSourceBundle[] = [ { type: 'customobject', name: 'MyTestObject', content: oldObjectSource, filePath: 'MyTestObject.object' }, @@ -392,4 +401,86 @@ describe('when generating a changelog', () => { ); }); }); + + describe('that includes extension fields', () => { + it('does not include the fields when they are in both versions', async () => { + const fieldSource = new CustomFieldXmlBuilder().build(); + + const oldBundle: UnparsedSourceBundle[] = [ + { + type: 'customfield', + name: 'PhotoUrl__c', + content: fieldSource, + filePath: 'PhotoUrl__c.field-meta.xml', + parentName: 'MyTestObject', + }, + ]; + const newBundle: UnparsedSourceBundle[] = [ + { + type: 'customfield', + name: 'PhotoUrl__c', + content: fieldSource, + filePath: 'PhotoUrl__c.field-meta.xml', + parentName: 'MyTestObject', + }, + ]; + + const result = await generateChangeLog(oldBundle, newBundle, config)(); + + assertEither(result, (data) => expect((data as ChangeLogPageData).content).not.toContain('MyTestObject')); + assertEither(result, (data) => expect((data as ChangeLogPageData).content).not.toContain('PhotoUrl__c')); + }); + + it('includes added fields when they are not in the old version', async () => { + const fieldSource = new CustomFieldXmlBuilder().build(); + + const oldBundle: UnparsedSourceBundle[] = []; + const newBundle: UnparsedSourceBundle[] = [ + { + type: 'customfield', + name: 'PhotoUrl__c', + content: fieldSource, + filePath: 'PhotoUrl__c.field-meta.xml', + parentName: 'MyTestObject', + }, + ]; + + const result = await generateChangeLog(oldBundle, newBundle, config)(); + + assertEither(result, (data) => expect((data as ChangeLogPageData).content).toContain('MyTestObject')); + assertEither(result, (data) => expect((data as ChangeLogPageData).content).toContain('PhotoUrl__c')); + }); + + it('includes removed fields when they are not in the new version', async () => { + const fieldSource = new CustomFieldXmlBuilder().build(); + + const oldBundle: UnparsedSourceBundle[] = [ + { + type: 'customfield', + name: 'PhotoUrl__c', + content: fieldSource, + filePath: 'PhotoUrl__c.field-meta.xml', + parentName: 'MyTestObject', + }, + ]; + const newBundle: UnparsedSourceBundle[] = []; + + const result = await generateChangeLog(oldBundle, newBundle, config)(); + + assertEither(result, (data) => expect((data as ChangeLogPageData).content).toContain('MyTestObject')); + assertEither(result, (data) => expect((data as ChangeLogPageData).content).toContain('PhotoUrl__c')); + }); + }); + + describe('and a custom hook is provided to customize the frontmatter', () => { + it('includes the custom frontmatter', async () => { + const hook = () => ({ + frontmatter: '---\ntitle: Custom Title\n---', + }); + + const result = await generateChangeLog([], [], { ...config, transformChangeLogPage: hook })(); + + assertEither(result, (data) => expect((data as ChangeLogPageData).frontmatter).toContain('title: Custom Title')); + }); + }); }); diff --git a/src/core/changelog/__test__/processing-changelog.spec.ts b/src/core/changelog/__test__/processing-changelog.spec.ts index 9bb8a964..ad8ea5a1 100644 --- a/src/core/changelog/__test__/processing-changelog.spec.ts +++ b/src/core/changelog/__test__/processing-changelog.spec.ts @@ -13,14 +13,28 @@ function apexTypeFromRawString(raw: string): Type { } class CustomFieldMetadataBuilder { + name: string = 'MyField'; + description: string | null = null; + + withName(name: string): CustomFieldMetadataBuilder { + this.name = name; + return this; + } + + withDescription(testDescription: string) { + this.description = testDescription; + return this; + } + build(): CustomFieldMetadata { return { type: 'Text', type_name: 'customfield', label: 'MyField', - name: 'MyField', - description: null, + name: this.name, + description: this.description, parentName: 'MyObject', + required: false, }; } } @@ -29,11 +43,6 @@ class CustomObjectMetadataBuilder { label: string = 'MyObject'; fields: CustomFieldMetadata[] = []; - withLabel(label: string): CustomObjectMetadataBuilder { - this.label = label; - return this; - } - withField(field: CustomFieldMetadata): CustomObjectMetadataBuilder { this.fields.push(field); return this; @@ -176,6 +185,7 @@ describe('when generating a changelog', () => { { __typename: 'NewField', name: newField.name, + description: null, }, ], }, @@ -570,4 +580,92 @@ describe('when generating a changelog', () => { ]); }); }); + + describe('with custom field code', () => { + it('does not list fields that are the same in both versions', () => { + // A field that is the same in both versions is defined as one + // with both the same name and the same parent + + const oldField = new CustomFieldMetadataBuilder().build(); + const newField = new CustomFieldMetadataBuilder().build(); + + const oldManifest = { types: [oldField] }; + const newManifest = { types: [newField] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([]); + }); + + it('lists new fields of a custom object', () => { + const oldField = new CustomFieldMetadataBuilder().build(); + const newField = new CustomFieldMetadataBuilder().withName('NewField').build(); + + const oldManifest = { types: [oldField] }; + const newManifest = { types: [oldField, newField] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([ + { + typeName: newField.parentName, + modifications: [ + { + __typename: 'NewField', + name: newField.name, + description: null, + }, + ], + }, + ]); + }); + + it('includes the description of new fields', () => { + const oldField = new CustomFieldMetadataBuilder().build(); + const newField = new CustomFieldMetadataBuilder() + .withName('NewField') + .withDescription('Test description') + .build(); + + const oldManifest = { types: [oldField] }; + const newManifest = { types: [oldField, newField] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([ + { + typeName: newField.parentName, + modifications: [ + { + __typename: 'NewField', + name: newField.name, + description: 'Test description', + }, + ], + }, + ]); + }); + + it('lists removed fields of a custom object', () => { + const oldField = new CustomFieldMetadataBuilder().withName('OldField').build(); + const unchangedField = new CustomFieldMetadataBuilder().build(); + + const oldManifest = { types: [unchangedField, oldField] }; + const newManifest = { types: [unchangedField] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([ + { + typeName: oldField.parentName, + modifications: [ + { + __typename: 'RemovedField', + name: oldField.name, + }, + ], + }, + ]); + }); + }); }); diff --git a/src/core/changelog/generate-change-log.ts b/src/core/changelog/generate-change-log.ts index 4071b6df..a19564d5 100644 --- a/src/core/changelog/generate-change-log.ts +++ b/src/core/changelog/generate-change-log.ts @@ -1,6 +1,8 @@ import { + ChangeLogPageData, ParsedFile, Skip, + TransformChangelogPage, UnparsedApexBundle, UnparsedSourceBundle, UserDefinedChangelogConfig, @@ -12,25 +14,24 @@ import { Changelog, hasChanges, processChangelog, VersionManifest } from './proc import { convertToRenderableChangelog, RenderableChangelog } from './renderable-changelog'; import { CompilationRequest, Template } from '../template'; import { changelogTemplate } from './templates/changelog-template'; -import { ReflectionErrors } from '../errors/errors'; +import { HookError, ReflectionErrors } from '../errors/errors'; import { apply } from '#utils/fp'; import { filterScope } from '../reflection/apex/filter-scope'; -import { skip } from '../shared/utils'; +import { isInSource, isSkip, passThroughHook, skip, toFrontmatterString } from '../shared/utils'; import { reflectCustomFieldsAndObjects } from '../reflection/sobject/reflectCustomFieldsAndObjects'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { Type } from '@cparra/apex-reflection'; import { filterApexSourceFiles, filterCustomObjectsAndFields } from '#utils/source-bundle-utils'; +import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; +import { hookableTemplate } from '../markdown/templates/hookable'; -export type ChangeLogPageData = { - content: string; - outputDocPath: string; -}; +type Config = Omit; export function generateChangeLog( oldBundles: UnparsedSourceBundle[], newBundles: UnparsedSourceBundle[], - config: Omit, -): TE.TaskEither { + config: Config, +): TE.TaskEither { const convertToPageData = apply(toPageData, config.fileName); function handleConversion({ changelog, newManifest }: { changelog: Changelog; newManifest: VersionManifest }) { @@ -50,6 +51,8 @@ export function generateChangeLog( newManifest, })), TE.map(handleConversion), + TE.flatMap(transformChangelogPageHook(config)), + TE.map(postHookCompile), ); } @@ -71,16 +74,20 @@ function reflect(bundles: UnparsedSourceBundle[], config: Omit[]; - newVersion: ParsedFile[]; -}) { - function parsedFilesToManifest(parsedFiles: ParsedFile[]): VersionManifest { +function toManifests({ oldVersion, newVersion }: { oldVersion: ParsedFile[]; newVersion: ParsedFile[] }) { + function parsedFilesToManifest(parsedFiles: ParsedFile[]): VersionManifest { return { - types: parsedFiles.map((parsedFile) => parsedFile.type), + types: parsedFiles.reduce( + (previousValue: (Type | CustomObjectMetadata | CustomFieldMetadata)[], parsedFile: ParsedFile) => { + if (!isInSource(parsedFile.source) && parsedFile.type.type_name === 'customobject') { + // When we are dealing with a custom object that was not in the source (for extension fields), we return all + // of its fields. + return [...previousValue, ...parsedFile.type.fields]; + } + return [...previousValue, parsedFile.type]; + }, + [] as (Type | CustomObjectMetadata | CustomFieldMetadata)[], + ), }; } @@ -101,7 +108,45 @@ function compile(renderable: RenderableChangelog): string { function toPageData(fileName: string, content: string): ChangeLogPageData { return { + frontmatter: null, content, outputDocPath: `${fileName}.md`, }; } + +function transformChangelogPageHook(config: Config) { + return (page: ChangeLogPageData | Skip) => + TE.tryCatch( + () => transformChangelogPage(page, config.transformChangeLogPage), + (error) => new HookError(error), + ); +} + +async function transformChangelogPage( + page: ChangeLogPageData | Skip, + hook: TransformChangelogPage = passThroughHook, +): Promise { + if (isSkip(page)) { + return page; + } + return { + ...page, + ...(await hook(page)), + }; +} + +function postHookCompile(page: ChangeLogPageData | Skip): ChangeLogPageData | Skip { + if (isSkip(page)) { + return page; + } + return { + ...page, + content: Template.getInstance().compile({ + source: { + frontmatter: toFrontmatterString(page.frontmatter), + content: page.content, + }, + template: hookableTemplate, + }), + }; +} diff --git a/src/core/changelog/process-changelog.ts b/src/core/changelog/process-changelog.ts index b9d8695d..d689db40 100644 --- a/src/core/changelog/process-changelog.ts +++ b/src/core/changelog/process-changelog.ts @@ -2,9 +2,10 @@ import { ClassMirror, EnumMirror, InterfaceMirror, MethodMirror, Type } from '@c import { pipe } from 'fp-ts/function'; import { areMethodsEqual } from './method-changes-checker'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; +import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; export type VersionManifest = { - types: (Type | CustomObjectMetadata)[]; + types: (Type | CustomObjectMetadata | CustomFieldMetadata)[]; }; type ModificationTypes = @@ -22,6 +23,7 @@ type ModificationTypes = export type MemberModificationType = { __typename: ModificationTypes; name: string; + description?: string | null | undefined; }; export type NewOrModifiedMember = { @@ -53,7 +55,10 @@ export function processChangelog(oldVersion: VersionManifest, newVersion: Versio newOrModifiedApexMembers: getNewOrModifiedApexMembers(oldVersion, newVersion), newCustomObjects: getNewCustomObjects(oldVersion, newVersion), removedCustomObjects: getRemovedCustomObjects(oldVersion, newVersion), - customObjectModifications: getCustomObjectModifications(oldVersion, newVersion), + customObjectModifications: [ + ...getCustomObjectModifications(oldVersion, newVersion), + ...getNewOrModifiedExtensionFields(oldVersion, newVersion), + ], }; } @@ -105,6 +110,60 @@ function getCustomObjectModifications(oldVersion: VersionManifest, newVersion: V ); } +function getNewOrModifiedExtensionFields( + oldVersion: VersionManifest, + newVersion: VersionManifest, +): NewOrModifiedMember[] { + const extensionFieldsInOldVersion = oldVersion.types.filter( + (type): type is CustomFieldMetadata => type.type_name === 'customfield', + ); + const extensionFieldsInNewVersion = newVersion.types.filter( + (type): type is CustomFieldMetadata => type.type_name === 'customfield', + ); + + // An extension field is equal if it has the same name and the same parent name + function areFieldEquals(oldField: CustomFieldMetadata, newField: CustomFieldMetadata): boolean { + return ( + oldField.name.toLowerCase() === newField.name.toLowerCase() && + oldField.parentName.toLowerCase() === newField.parentName.toLowerCase() + ); + } + + const fieldsOnlyInNewVersion = extensionFieldsInNewVersion.filter( + (newField) => !extensionFieldsInOldVersion.some((oldField) => areFieldEquals(oldField, newField)), + ); + const fieldsOnlyInOldVersion = extensionFieldsInOldVersion.filter( + (oldField) => !extensionFieldsInNewVersion.some((newField) => areFieldEquals(oldField, newField)), + ); + + const newMemberModifications = fieldsOnlyInNewVersion.reduce((previous, currentField) => { + const parentName = currentField.parentName; + const additionsToParent = previous.find((parent) => parent.typeName === parentName)?.modifications ?? []; + return [ + ...previous.filter((parent) => parent.typeName !== parentName), + { + typeName: parentName, + modifications: [ + ...additionsToParent, + { __typename: 'NewField', name: currentField.name, description: currentField.description }, + ], + }, + ] as NewOrModifiedMember[]; + }, [] as NewOrModifiedMember[]); + + return fieldsOnlyInOldVersion.reduce((previous, currentField) => { + const parentName = currentField.parentName; + const removalsFromParent = previous.find((parent) => parent.typeName === parentName)?.modifications ?? []; + return [ + ...previous.filter((parent) => parent.typeName !== parentName), + { + typeName: parentName, + modifications: [...removalsFromParent, { __typename: 'RemovedField', name: currentField.name }], + }, + ] as NewOrModifiedMember[]; + }, newMemberModifications); +} + function getNewOrRemovedCustomFields(typesInBoth: TypeInBoth[]): NewOrModifiedMember[] { return typesInBoth.map(({ oldType, newType }) => { const oldCustomObject = oldType; @@ -229,6 +288,7 @@ function getCustomObjectsInBothVersions( type NameAware = { name: string; + description?: string | null; }; type AreEqualFn = (oldValue: T, newValue: T) => boolean; @@ -246,8 +306,7 @@ function getNewValues !oldPlaceToSearch[keyToSearch].some((oldValue) => areEqualFn(oldValue, newValue))) - .map((value) => value.name) - .map((name) => ({ __typename: typeName, name })); + .map(({ name, description }) => ({ __typename: typeName, name, description })); } function getRemovedValues, K extends keyof T>( diff --git a/src/core/changelog/renderable-changelog.ts b/src/core/changelog/renderable-changelog.ts index dead2477..0b72ed1c 100644 --- a/src/core/changelog/renderable-changelog.ts +++ b/src/core/changelog/renderable-changelog.ts @@ -3,6 +3,7 @@ import { ClassMirror, EnumMirror, InterfaceMirror, Type } from '@cparra/apex-ref import { RenderableContent } from '../renderables/types'; import { adaptDescribable } from '../renderables/documentables'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; +import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; type NewTypeRenderable = { name: string; @@ -46,7 +47,7 @@ export type RenderableChangelog = { export function convertToRenderableChangelog( changelog: Changelog, - newManifest: (Type | CustomObjectMetadata)[], + newManifest: (Type | CustomObjectMetadata | CustomFieldMetadata)[], ): RenderableChangelog { const allNewTypes = [...changelog.newApexTypes, ...changelog.newCustomObjects].map( (newType) => newManifest.find((type) => type.name.toLowerCase() === newType.toLowerCase())!, @@ -122,7 +123,7 @@ export function convertToRenderableChangelog( newOrRemovedCustomFields: changelog.customObjectModifications.length > 0 ? { - heading: 'New or Removed Fields in Existing Objects', + heading: 'New or Removed Fields to Custom Objects or Standard Objects', description: 'These custom fields have been added or removed.', modifications: changelog.customObjectModifications.map(toRenderableModification), } @@ -150,25 +151,32 @@ function toRenderableModification(newOrModifiedMember: NewOrModifiedMember): New } function toRenderableModificationDescription(memberModificationType: MemberModificationType): string { + function withDescription(memberModificationType: MemberModificationType): string { + if (memberModificationType.description) { + return `${memberModificationType.name}. ${memberModificationType.description}`; + } + return memberModificationType.name; + } + switch (memberModificationType.__typename) { case 'NewEnumValue': - return `New Enum Value: ${memberModificationType.name}`; + return `New Enum Value: ${withDescription(memberModificationType)}`; case 'RemovedEnumValue': return `Removed Enum Value: ${memberModificationType.name}`; case 'NewMethod': - return `New Method: ${memberModificationType.name}`; + return `New Method: ${withDescription(memberModificationType)}`; case 'RemovedMethod': return `Removed Method: ${memberModificationType.name}`; case 'NewProperty': - return `New Property: ${memberModificationType.name}`; + return `New Property: ${withDescription(memberModificationType)}`; case 'RemovedProperty': return `Removed Property: ${memberModificationType.name}`; case 'NewField': - return `New Field: ${memberModificationType.name}`; + return `New Field: ${withDescription(memberModificationType)}`; case 'RemovedField': return `Removed Field: ${memberModificationType.name}`; case 'NewType': - return `New Type: ${memberModificationType.name}`; + return `New Type: ${withDescription(memberModificationType)}`; case 'RemovedType': return `Removed Type: ${memberModificationType.name}`; } diff --git a/src/core/markdown/__test__/generating-custom-object-docs.spec.ts b/src/core/markdown/__test__/generating-custom-object-docs.spec.ts index b5768434..feef912e 100644 --- a/src/core/markdown/__test__/generating-custom-object-docs.spec.ts +++ b/src/core/markdown/__test__/generating-custom-object-docs.spec.ts @@ -1,7 +1,8 @@ import { extendExpect } from './expect-extensions'; import { customFieldPickListValues, generateDocs, unparsedObjectBundleFromRawString } from './test-helpers'; import { assertEither } from '../../test-helpers/assert-either'; -import { customObjectGenerator, unparsedFieldBundleFromRawString } from '../../test-helpers/test-data-builders'; +import { unparsedFieldBundleFromRawString } from '../../test-helpers/test-data-builders'; +import { CustomObjectXmlBuilder } from '../../test-helpers/test-data-builders/custom-object-xml-builder'; describe('Generates Custom Object documentation', () => { beforeAll(() => { @@ -11,7 +12,7 @@ describe('Generates Custom Object documentation', () => { describe('documentation content', () => { it('displays the object label as a heading', async () => { const input = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); @@ -22,7 +23,7 @@ describe('Generates Custom Object documentation', () => { it('displays the object description', async () => { const input = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); @@ -33,7 +34,7 @@ describe('Generates Custom Object documentation', () => { it('displays the object api name', async () => { const input = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); @@ -44,7 +45,7 @@ describe('Generates Custom Object documentation', () => { it('displays the Fields heading if fields are present', async () => { const customObjectBundle = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); @@ -60,7 +61,7 @@ describe('Generates Custom Object documentation', () => { it('displays the pick list values name', async () => { const customObjectBundle = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); @@ -79,7 +80,7 @@ describe('Generates Custom Object documentation', () => { it('does not display the Fields heading if no fields are present', async () => { const input = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); @@ -90,7 +91,7 @@ describe('Generates Custom Object documentation', () => { it('displays the field label as a heading', async () => { const customObjectBundle = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); @@ -106,7 +107,7 @@ describe('Generates Custom Object documentation', () => { it('displays the field description', async () => { const customObjectBundle = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); @@ -122,7 +123,7 @@ describe('Generates Custom Object documentation', () => { it('displays the field api name', async () => { const customObjectBundle = unparsedObjectBundleFromRawString({ - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }); diff --git a/src/core/markdown/__test__/generating-docs.spec.ts b/src/core/markdown/__test__/generating-docs.spec.ts index fd22690a..bce18c9e 100644 --- a/src/core/markdown/__test__/generating-docs.spec.ts +++ b/src/core/markdown/__test__/generating-docs.spec.ts @@ -2,7 +2,7 @@ import { DocPageData, PostHookDocumentationBundle } from '../../shared/types'; import { extendExpect } from './expect-extensions'; import { unparsedApexBundleFromRawString, generateDocs, unparsedObjectBundleFromRawString } from './test-helpers'; import { assertEither } from '../../test-helpers/assert-either'; -import { customObjectGenerator } from '../../test-helpers/test-data-builders'; +import { CustomObjectXmlBuilder } from '../../test-helpers/test-data-builders/custom-object-xml-builder'; function aSingleDoc(result: PostHookDocumentationBundle): DocPageData { expect(result.docs).toHaveLength(1); @@ -51,14 +51,14 @@ describe('When generating documentation', () => { ][] = [ [ { - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/MyFirstObject__c.object-meta.xml', }, 'custom-objects', ], [ { - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/MySecondObject__c.object-meta.xml', }, 'custom-objects', @@ -127,7 +127,7 @@ describe('When generating documentation', () => { ][] = [ [ { - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/MyFirstObject__c.object-meta.xml', }, 'customobject', @@ -154,14 +154,14 @@ describe('When generating documentation', () => { }); it('does not return non-deployed custom objects', async () => { - const input = customObjectGenerator({ deploymentStatus: 'InDevelopment', visibility: 'Public' }); + const input = new CustomObjectXmlBuilder().withDeploymentStatus('InDevelopment').build(); const result = await generateDocs([unparsedObjectBundleFromRawString({ rawContent: input, filePath: 'test' })])(); expect(result).documentationBundleHasLength(0); }); it('does not return non-public custom objects', async () => { - const input = customObjectGenerator({ deploymentStatus: 'Deployed', visibility: 'Protected' }); + const input = new CustomObjectXmlBuilder().withVisibility('Protected').build(); const result = await generateDocs([unparsedObjectBundleFromRawString({ rawContent: input, filePath: 'test' })])(); expect(result).documentationBundleHasLength(0); @@ -196,7 +196,7 @@ describe('When generating documentation', () => { }); it('includes a heading with the Custom Object label', async () => { - const input = customObjectGenerator(); + const input = new CustomObjectXmlBuilder().build(); const result = await generateDocs([unparsedObjectBundleFromRawString({ rawContent: input, filePath: 'test' })])(); expect(result).documentationBundleHasLength(1); diff --git a/src/core/markdown/__test__/generating-reference-guide.spec.ts b/src/core/markdown/__test__/generating-reference-guide.spec.ts index 6650c71d..26c073a8 100644 --- a/src/core/markdown/__test__/generating-reference-guide.spec.ts +++ b/src/core/markdown/__test__/generating-reference-guide.spec.ts @@ -4,7 +4,7 @@ import * as E from 'fp-ts/Either'; import { unparsedApexBundleFromRawString, generateDocs, unparsedObjectBundleFromRawString } from './test-helpers'; import { ReferenceGuidePageData } from '../../shared/types'; import { assertEither } from '../../test-helpers/assert-either'; -import { customObjectGenerator } from '../../test-helpers/test-data-builders'; +import { CustomObjectXmlBuilder } from '../../test-helpers/test-data-builders/custom-object-xml-builder'; describe('When generating the Reference Guide', () => { beforeAll(() => { @@ -40,7 +40,7 @@ describe('When generating the Reference Guide', () => { `; const input3 = { - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }; @@ -99,7 +99,7 @@ describe('When generating the Reference Guide', () => { it('group SObjects under the Custom Objects group by default', async () => { const input = { - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }; @@ -112,7 +112,7 @@ describe('When generating the Reference Guide', () => { it('groups SObjects under the provided group', async () => { const input = { - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }; @@ -180,7 +180,7 @@ describe('When generating the Reference Guide', () => { `; const input3 = { - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/TestObject__c.object-meta.xml', }; @@ -222,7 +222,7 @@ describe('When generating the Reference Guide', () => { `; const input3 = { - rawContent: customObjectGenerator(), + rawContent: new CustomObjectXmlBuilder().build(), filePath: 'src/object/ATestObject__c.object-meta.xml', }; diff --git a/src/core/markdown/adapters/type-to-renderable.ts b/src/core/markdown/adapters/type-to-renderable.ts index 85936078..46226a0f 100644 --- a/src/core/markdown/adapters/type-to-renderable.ts +++ b/src/core/markdown/adapters/type-to-renderable.ts @@ -17,9 +17,9 @@ import { adaptDescribable, adaptDocumentable } from '../../renderables/documenta import { adaptConstructor, adaptMethod } from './methods-and-constructors'; import { adaptFieldOrProperty } from './fields-and-properties'; import { MarkdownGeneratorConfig } from '../generate-docs'; -import { SourceFileMetadata } from '../../shared/types'; +import { ExternalMetadata, SourceFileMetadata } from '../../shared/types'; import { CustomObjectMetadata } from '../../reflection/sobject/reflect-custom-object-sources'; -import { getTypeGroup } from '../../shared/utils'; +import { getTypeGroup, isInSource } from '../../shared/utils'; import { CustomFieldMetadata } from '../../reflection/sobject/reflect-custom-field-source'; type GetReturnRenderable = T extends InterfaceMirror @@ -31,10 +31,10 @@ type GetReturnRenderable = T extends Inte : RenderableCustomObject; export function typeToRenderable( - parsedFile: { source: SourceFileMetadata; type: T }, + parsedFile: { source: SourceFileMetadata | ExternalMetadata; type: T }, linkGenerator: GetRenderableContentByTypeName, config: MarkdownGeneratorConfig, -): GetReturnRenderable & { filePath: string; namespace?: string } { +): GetReturnRenderable & { filePath: string | undefined; namespace?: string } { function getRenderable(): RenderableInterface | RenderableClass | RenderableEnum | RenderableCustomObject { const { type } = parsedFile; switch (type.type_name) { @@ -51,7 +51,7 @@ export function typeToRenderable( return { ...(getRenderable() as GetReturnRenderable), - filePath: parsedFile.source.filePath, + filePath: isInSource(parsedFile.source) ? parsedFile.source.filePath : undefined, namespace: config.namespace, }; } @@ -281,11 +281,14 @@ function fieldMetadataToRenderable( description: field.description ? [field.description] : [], apiName: getApiName(field.name, config), fieldType: field.type, - pickListValues: field.pickListValues ? { - headingLevel: headingLevel + 1, - heading: 'Possible values are', - value: field.pickListValues, - } : undefined, + required: field.required, + pickListValues: field.pickListValues + ? { + headingLevel: headingLevel + 1, + heading: 'Possible values are', + value: field.pickListValues, + } + : undefined, }; } diff --git a/src/core/markdown/generate-docs.ts b/src/core/markdown/generate-docs.ts index a0464ac3..fbffc05a 100644 --- a/src/core/markdown/generate-docs.ts +++ b/src/core/markdown/generate-docs.ts @@ -1,12 +1,10 @@ import { pipe } from 'fp-ts/function'; import * as TE from 'fp-ts/TaskEither'; -import yaml from 'js-yaml'; import { apply } from '#utils/fp'; import { DocPageData, DocumentationBundle, - Frontmatter, PostHookDocumentationBundle, ReferenceGuidePageData, UnparsedApexBundle, @@ -28,7 +26,7 @@ import { filterScope } from '../reflection/apex/filter-scope'; import { Template } from '../template'; import { hookableTemplate } from './templates/hookable'; import { sortTypesAndMembers } from '../reflection/sort-types-and-members'; -import { isSkip } from '../shared/utils'; +import { isSkip, passThroughHook, toFrontmatterString } from '../shared/utils'; import { parsedFilesToReferenceGuide } from './adapters/reference-guide'; import { removeExcludedTags } from '../reflection/apex/remove-excluded-tags'; import { HookError } from '../errors/errors'; @@ -129,9 +127,6 @@ function transformDocumentationBundleHook(config: MarkdownGeneratorConfig) { } // Configurable hooks -function passThroughHook(value: T): T { - return value; -} const execTransformReferenceHook = async ( references: DocPageReference[], @@ -219,16 +214,3 @@ function postHookCompile(bundle: PostHookDocumentationBundle) { })), }; } - -function toFrontmatterString(frontmatter: Frontmatter): string { - if (typeof frontmatter === 'string') { - return frontmatter; - } - - if (!frontmatter) { - return ''; - } - - const yamlString = yaml.dump(frontmatter); - return `---\n${yamlString}---\n`; -} diff --git a/src/core/markdown/templates/custom-object-template.ts b/src/core/markdown/templates/custom-object-template.ts index b182c4cb..16d2f99c 100644 --- a/src/core/markdown/templates/custom-object-template.ts +++ b/src/core/markdown/templates/custom-object-template.ts @@ -10,6 +10,9 @@ export const customObjectTemplate = ` {{ heading fields.headingLevel fields.heading }} {{#each fields.value}} {{ heading headingLevel heading }} +{{#if required}} +**Required** +{{/if}} {{#if description}} {{{renderContent description}}} diff --git a/src/core/reflection/apex/reflect-apex-source.ts b/src/core/reflection/apex/reflect-apex-source.ts index 67206fcb..cbbb149a 100644 --- a/src/core/reflection/apex/reflect-apex-source.ts +++ b/src/core/reflection/apex/reflect-apex-source.ts @@ -11,6 +11,7 @@ import { Semigroup } from 'fp-ts/Semigroup'; import { ParsedFile, UnparsedApexBundle } from '../../shared/types'; import { ReflectionError, ReflectionErrors } from '../../errors/errors'; import { parseApexMetadata } from './parse-apex-metadata'; +import { isInSource } from '../../shared/utils'; async function reflectAsync(rawSource: string): Promise { return new Promise((resolve, reject) => { @@ -69,7 +70,9 @@ function addMetadata( parsedFile.type, (type) => addFileMetadataToTypeAnnotation(type as Type, rawMetadataContent), E.map((type) => ({ ...parsedFile, type })), - E.mapLeft((error) => errorToReflectionErrors(error, parsedFile.source.filePath)), + E.mapLeft((error) => + errorToReflectionErrors(error, isInSource(parsedFile.source) ? parsedFile.source.filePath : ''), + ), ), ); } diff --git a/src/core/reflection/sobject/__test__/reflect-custom-field-sources.spec.ts b/src/core/reflection/sobject/__test__/reflect-custom-field-sources.spec.ts index 02e75eef..8b1e776b 100644 --- a/src/core/reflection/sobject/__test__/reflect-custom-field-sources.spec.ts +++ b/src/core/reflection/sobject/__test__/reflect-custom-field-sources.spec.ts @@ -2,6 +2,7 @@ import { UnparsedCustomFieldBundle } from '../../../shared/types'; import { reflectCustomFieldSources } from '../reflect-custom-field-source'; import { assertEither } from '../../../test-helpers/assert-either'; import * as E from 'fp-ts/Either'; +import { isInSource } from '../../../shared/utils'; const customFieldContent = ` @@ -27,7 +28,13 @@ describe('when parsing custom field metadata', () => { const result = await reflectCustomFieldSources([unparsed])(); - assertEither(result, (data) => expect(data[0].source.filePath).toBe('src/field/PhotoUrl__c.field-meta.xml')); + assertEither(result, (data) => { + if (isInSource(data[0].source)) { + expect(data[0].source.filePath).toBe('src/field/PhotoUrl__c.field-meta.xml'); + } else { + fail('Expected the source to be in the source'); + } + }); }); test('the resulting type contains the correct name', async () => { @@ -100,7 +107,7 @@ describe('when parsing custom field metadata', () => { assertEither(result, (data) => expect(data[0].type.description).toBe('A Photo URL field')); }); - test('can parse picklist values', async() => { + test('can parse picklist values', async () => { const unparsed: UnparsedCustomFieldBundle = { type: 'customfield', name: 'Status__c', diff --git a/src/core/reflection/sobject/__test__/reflect-custom-object-sources.spec.ts b/src/core/reflection/sobject/__test__/reflect-custom-object-sources.spec.ts index b02f10bc..e92ed976 100644 --- a/src/core/reflection/sobject/__test__/reflect-custom-object-sources.spec.ts +++ b/src/core/reflection/sobject/__test__/reflect-custom-object-sources.spec.ts @@ -2,16 +2,11 @@ import { reflectCustomObjectSources } from '../reflect-custom-object-sources'; import { UnparsedCustomObjectBundle } from '../../../shared/types'; import { assertEither } from '../../../test-helpers/assert-either'; import * as E from 'fp-ts/Either'; - -const sObjectContent = ` - - - Deployed - test object for testing - - MyFirstObjects - Public - `; +import { + CustomObjectXmlBuilder, + InlineFieldBuilder, +} from '../../../test-helpers/test-data-builders/custom-object-xml-builder'; +import { isInSource } from '../../../shared/utils'; describe('when parsing SObject metadata', () => { test('the resulting type contains the file path', async () => { @@ -19,12 +14,18 @@ describe('when parsing SObject metadata', () => { type: 'customobject', name: 'MyFirstObject__c', filePath: 'src/object/MyFirstObject__c.object-meta.xml', - content: sObjectContent, + content: new CustomObjectXmlBuilder().build(), }; const result = await reflectCustomObjectSources([unparsed])(); - assertEither(result, (data) => expect(data[0].source.filePath).toBe('src/object/MyFirstObject__c.object-meta.xml')); + assertEither(result, (data) => { + if (isInSource(data[0].source)) { + expect(data[0].source.filePath).toBe('src/object/MyFirstObject__c.object-meta.xml'); + } else { + fail('Expected the source to be in the source'); + } + }); }); test('the resulting type contains the correct label', async () => { @@ -32,7 +33,7 @@ describe('when parsing SObject metadata', () => { type: 'customobject', name: 'MyFirstObject__c', filePath: 'src/object/MyFirstObject__c.object-meta.xml', - content: sObjectContent, + content: new CustomObjectXmlBuilder().withLabel('MyFirstObject').build(), }; const result = await reflectCustomObjectSources([unparsed])(); @@ -47,7 +48,7 @@ describe('when parsing SObject metadata', () => { type: 'customobject', name: 'MyFirstObject__c', filePath: 'src/object/MyFirstObject__c.object-meta.xml', - content: sObjectContent, + content: new CustomObjectXmlBuilder().build(), }; const result = await reflectCustomObjectSources([unparsed])(); @@ -62,7 +63,7 @@ describe('when parsing SObject metadata', () => { type: 'customobject', name: 'MyFirstObject__c', filePath: 'src/object/MyFirstObject__c.object-meta.xml', - content: sObjectContent, + content: new CustomObjectXmlBuilder().build(), }; const result = await reflectCustomObjectSources([unparsed])(); @@ -100,7 +101,7 @@ describe('when parsing SObject metadata', () => { type: 'customobject', name: 'MyFirstObject__c', filePath: 'src/object/MyFirstObject__c.object-meta.xml', - content: sObjectContent, + content: new CustomObjectXmlBuilder().build(), }; const result = await reflectCustomObjectSources([unparsed])(); @@ -156,4 +157,67 @@ describe('when parsing SObject metadata', () => { expect(E.isLeft(result)).toBe(true); }); + + test('has no fields when there are no fields in the XML', async () => { + const sObjectContent = ` + + + Deployed + test object for testing + + MyFirstObjects + Public + `; + + const unparsed: UnparsedCustomObjectBundle = { + type: 'customobject', + name: 'MyFirstObject__c', + filePath: 'src/object/MyFirstObject__c.object-meta.xml', + content: sObjectContent, + }; + + const result = await reflectCustomObjectSources([unparsed])(); + + assertEither(result, (data) => { + expect(data[0].type.fields).toEqual([]); + }); + }); + + test('has a field when one is present in the XML', async () => { + const unparsed: UnparsedCustomObjectBundle = { + type: 'customobject', + name: 'MyFirstObject__c', + filePath: 'src/object/MyFirstObject__c.object-meta.xml', + content: new CustomObjectXmlBuilder().withFields([new InlineFieldBuilder().withFullName('TestField__c')]).build(), + }; + + const result = await reflectCustomObjectSources([unparsed])(); + + assertEither(result, (data) => { + expect(data[0].type.fields).toHaveLength(1); + expect(data[0].type.fields[0].name).toBe('TestField__c'); + }); + }); + + test('has fields when multiple fields are present in the XML', async () => { + const unparsed: UnparsedCustomObjectBundle = { + type: 'customobject', + name: 'MyFirstObject__c', + filePath: 'src/object/MyFirstObject__c.object-meta.xml', + content: new CustomObjectXmlBuilder() + .withFields([ + new InlineFieldBuilder().withFullName('TestField1__c'), + new InlineFieldBuilder().withFullName('TestField2__c'), + ]) + .build(), + }; + + const result = await reflectCustomObjectSources([unparsed])(); + + assertEither(result, (data) => { + expect(data[0].type.fields).toHaveLength(2); + expect(data[0].type.fields[0].name).toBe('TestField1__c'); + expect(data[0].type.fields[1].name).toBe('TestField2__c'); + }); + }); }); diff --git a/src/core/reflection/sobject/parse-picklist-values.ts b/src/core/reflection/sobject/parse-picklist-values.ts new file mode 100644 index 00000000..1316b752 --- /dev/null +++ b/src/core/reflection/sobject/parse-picklist-values.ts @@ -0,0 +1,32 @@ +import { CustomFieldMetadata } from './reflect-custom-field-source'; + +type MaybeTyped = { + type?: string; +}; + +export function getPickListValues(customField: MaybeTyped): string[] | undefined { + return hasType(customField) && isPicklist(customField) ? toPickListValues(customField) : undefined; +} + +function hasType(customField: MaybeTyped): customField is { type: string } { + return !!(customField as CustomFieldMetadata).type; +} + +function isPicklist(typedCustomField: { type: string }) { + return typedCustomField.type.toLowerCase() === 'picklist'; +} + +function toPickListValues(customField: MaybeTyped): string[] | undefined { + if ('valueSet' in customField) { + const valueSet = customField.valueSet as object; + if ('valueSetDefinition' in valueSet) { + const valueSetDefinition = valueSet.valueSetDefinition as object; + if ('value' in valueSetDefinition) { + const pickListValues = valueSetDefinition.value as object[]; + return pickListValues.filter((each) => 'fullName' in each).map((each) => each.fullName as string); + } + } + } + + return undefined; +} diff --git a/src/core/reflection/sobject/reflect-custom-field-source.ts b/src/core/reflection/sobject/reflect-custom-field-source.ts index 4d03b3e6..698864aa 100644 --- a/src/core/reflection/sobject/reflect-custom-field-source.ts +++ b/src/core/reflection/sobject/reflect-custom-field-source.ts @@ -7,6 +7,7 @@ import { pipe } from 'fp-ts/function'; import * as A from 'fp-ts/Array'; import { XMLParser } from 'fast-xml-parser'; import * as E from 'fp-ts/Either'; +import { getPickListValues } from './parse-picklist-values'; export type CustomFieldMetadata = { type_name: 'customfield'; @@ -16,6 +17,7 @@ export type CustomFieldMetadata = { type?: string | null; parentName: string; pickListValues?: string[]; + required: boolean; }; export function reflectCustomFieldSources( @@ -63,30 +65,15 @@ function toCustomFieldMetadata(parserResult: { CustomField: unknown }): CustomFi parserResult?.CustomField != null && typeof parserResult.CustomField === 'object' ? parserResult.CustomField : {}; const defaultValues = { description: null, + required: false, }; - const pickListValues = - hasType(customField) && customField.type?.toLowerCase() === 'picklist' ? toPickListValues(customField) : undefined; - return { ...defaultValues, ...customField, type_name: 'customfield', pickListValues } as CustomFieldMetadata; -} - -function toPickListValues(customField: object): string[] { - if ('valueSet' in customField) { - const valueSet = customField.valueSet as object; - if ('valueSetDefinition' in valueSet) { - const valueSetDefinition = valueSet.valueSetDefinition as object; - if ('value' in valueSetDefinition) { - const pickListValues = valueSetDefinition.value as object[]; - return pickListValues.filter((each) => 'fullName' in each).map((each) => each.fullName as string); - } - } - } - - return []; -} - -function hasType(customField: object): customField is CustomFieldMetadata { - return !!(customField as CustomFieldMetadata).type; + return { + ...defaultValues, + ...customField, + type_name: 'customfield', + pickListValues: getPickListValues(customField), + } as CustomFieldMetadata; } function addName(metadata: CustomFieldMetadata, name: string): CustomFieldMetadata { diff --git a/src/core/reflection/sobject/reflect-custom-object-sources.ts b/src/core/reflection/sobject/reflect-custom-object-sources.ts index f48ed6e9..6f22780d 100644 --- a/src/core/reflection/sobject/reflect-custom-object-sources.ts +++ b/src/core/reflection/sobject/reflect-custom-object-sources.ts @@ -8,6 +8,7 @@ import { pipe } from 'fp-ts/function'; import * as A from 'fp-ts/Array'; import * as E from 'fp-ts/Either'; import { CustomFieldMetadata } from './reflect-custom-field-source'; +import { getPickListValues } from './parse-picklist-values'; export type CustomObjectMetadata = { type_name: 'customobject'; @@ -38,6 +39,7 @@ function reflectCustomObjectSource( E.flatMap(validate), E.map(toObjectMetadata), E.map((metadata) => addName(metadata, objectSource.name)), + E.map(parseInlineFields), E.map(addTypeName), E.map((metadata) => toParsedFile(objectSource.filePath, metadata)), E.mapLeft((error) => new ReflectionErrors([new ReflectionError(objectSource.filePath, error.message)])), @@ -78,6 +80,42 @@ function addName(objectMetadata: CustomObjectMetadata, name: string): CustomObje }; } +function parseInlineFields(metadata: CustomObjectMetadata): CustomObjectMetadata { + // if "fields" is present, it might be a single object (if it only has one field) + // or an array + if (!Array.isArray(metadata.fields)) { + metadata.fields = [metadata.fields]; + } + + return { + ...metadata, + fields: metadata.fields.map((field) => convertInlineFieldsToCustomFieldMetadata(field, metadata.name)), + }; +} + +function convertInlineFieldsToCustomFieldMetadata( + inlineField: Record, + parentName: string, +): CustomFieldMetadata { + // Based on Salesforce's documentation, the only required field is "fullName" + const name = inlineField.fullName as string; + const description = inlineField.description ? (inlineField.description as string) : null; + const label = inlineField.label ? (inlineField.label as string) : name; + const type = inlineField.type ? (inlineField.type as string) : null; + const required = inlineField.required ? (inlineField.required as boolean) : false; + + return { + type_name: 'customfield', + description, + label, + name, + parentName, + type, + required, + pickListValues: getPickListValues(inlineField), + }; +} + function addTypeName(objectMetadata: CustomObjectMetadata): CustomObjectMetadata { return { ...objectMetadata, diff --git a/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts b/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts index 9cfcec6c..083865a4 100644 --- a/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts +++ b/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts @@ -38,18 +38,66 @@ export function reflectCustomFieldsAndObjects( TE.map(filterNonPublic), TE.bindTo('objects'), TE.bind('fields', () => generateForFields(customFields)), - // Locate the fields for each object by using the parentName property TE.map(({ objects, fields }) => { - return objects.map((object) => { - const objectFields = fields.filter((field) => field.type.parentName === object.type.name); - return { - ...object, - type: { - ...object.type, - fields: objectFields.map((field) => field.type), - }, - }; - }); + return [...mapFieldsToObjects(objects, fields), ...mapExtensionFields(objects, fields)]; }), ); } + +function mapFieldsToObjects( + objects: ParsedFile[], + fields: ParsedFile[], +): ParsedFile[] { + // Locate the fields for each object by using the parentName property + return objects.map((object) => { + const objectFields = fields.filter((field) => field.type.parentName === object.type.name); + return { + ...object, + type: { + ...object.type, + fields: [...object.type.fields, ...objectFields.map((field) => field.type)], + }, + }; + }); +} + +// "Extension" fields are fields that are in the source code without the corresponding object-meta.xml file. +// These are fields that either extend a standard Salesforce object, or an object in a different package. +function mapExtensionFields( + objects: ParsedFile[], + fields: ParsedFile[], +): ParsedFile[] { + const extensionFields = fields.filter( + (field) => !objects.some((object) => object.type.name === field.type.parentName), + ); + // There might be many objects for the same parent name, so we need to group the fields by parent name + const extensionFieldsByParent = extensionFields.reduce( + (acc, field) => { + if (!acc[field.type.parentName]) { + acc[field.type.parentName] = []; + } + acc[field.type.parentName].push(field.type); + return acc; + }, + {} as Record, + ); + + return Object.keys(extensionFieldsByParent).map((key) => { + const fields = extensionFieldsByParent[key]; + return { + source: { + name: key, + type: 'customobject', + }, + type: { + type_name: 'customobject', + deploymentStatus: 'Deployed', + visibility: 'Public', + label: key, + name: key, + description: null, + fields: fields, + }, + }; + }); +} diff --git a/src/core/renderables/types.d.ts b/src/core/renderables/types.d.ts index 3b646264..f54c5fad 100644 --- a/src/core/renderables/types.d.ts +++ b/src/core/renderables/types.d.ts @@ -189,11 +189,12 @@ export type RenderableCustomField = { heading: string; apiName: string; description: RenderableContent[]; - pickListValues?: RenderableSection + pickListValues?: RenderableSection; type: 'field'; fieldType?: string | null; + required: boolean; }; export type Renderable = (RenderableClass | RenderableInterface | RenderableEnum | RenderableCustomObject) & { - filePath: string; + filePath: string | undefined; }; diff --git a/src/core/shared/types.d.ts b/src/core/shared/types.d.ts index e9ce8fd3..70c7c1fd 100644 --- a/src/core/shared/types.d.ts +++ b/src/core/shared/types.d.ts @@ -1,5 +1,4 @@ import { Type } from '@cparra/apex-reflection'; -import { ChangeLogPageData } from '../changelog/generate-change-log'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; @@ -34,7 +33,7 @@ export type UserDefinedMarkdownConfig = { excludeTags: string[]; exclude: string[]; } & CliConfigurableMarkdownConfig & - Partial; + Partial; export type UserDefinedOpenApiConfig = { targetGenerator: 'openapi'; @@ -56,7 +55,7 @@ export type UserDefinedChangelogConfig = { scope: string[]; exclude: string[]; skipIfNoChanges: boolean; -}; +} & Partial; export type UserDefinedConfig = UserDefinedMarkdownConfig | UserDefinedOpenApiConfig | UserDefinedChangelogConfig; @@ -85,21 +84,34 @@ export type UnparsedApexBundle = { metadataContent: string | null; }; +type MetadataTypes = 'interface' | 'class' | 'enum' | 'customobject' | 'customfield'; + export type SourceFileMetadata = { filePath: string; name: string; - type: 'interface' | 'class' | 'enum' | 'customobject' | 'customfield'; + type: MetadataTypes; +}; + +// External metadata is metadata that does not live directly in the source code, and thus we don't +// have a file path for it. +// This is metadata derived from other information. +// For example, for an "extension" +// field that extends a Salesforce object or object in a different package, we want to capture the parent +// object, even if the file for that object was not parsed. +export type ExternalMetadata = { + name: string; + type: MetadataTypes; }; export type ParsedFile< T extends Type | CustomObjectMetadata | CustomFieldMetadata = Type | CustomObjectMetadata | CustomFieldMetadata, > = { - source: SourceFileMetadata; + source: SourceFileMetadata | ExternalMetadata; type: T; }; export type DocPageReference = { - source: SourceFileMetadata; + source: SourceFileMetadata | ExternalMetadata; // The name under which the type should be displayed in the documentation. // By default, this will match the source.name, but it can be configured by the user. displayName: string; @@ -121,7 +133,7 @@ export type ReferenceGuidePageData = { }; export type DocPageData = { - source: SourceFileMetadata; + source: SourceFileMetadata | ExternalMetadata; group: string | null; outputDocPath: string; frontmatter: Frontmatter; @@ -131,6 +143,12 @@ export type DocPageData = { export type OpenApiPageData = Omit; +export type ChangeLogPageData = { + frontmatter: Frontmatter; + content: string; + outputDocPath: string; +}; + export type PageData = DocPageData | OpenApiPageData | ReferenceGuidePageData | ChangeLogPageData; export type DocumentationBundle = { @@ -153,9 +171,9 @@ export type PostHookDocumentationBundle = { // CONFIGURABLE HOOKS /** - * The configurable hooks that can be used to modify the output of the generator. + * The configurable hooks that can be used to modify the output of the Markdown generator. */ -export type ConfigurableHooks = { +export type MarkdownConfigurableHooks = { transformReferenceGuide: TransformReferenceGuide; transformDocs: TransformDocs; transformDocPage: TransformDocPage; @@ -193,3 +211,11 @@ export type TransformDocs = (docs: DocPageData[]) => DocPageData[] | Promise Partial | Promise>; + +export type ChangelogConfigurableHooks = { + transformChangeLogPage: TransformChangelogPage; +}; + +export type TransformChangelogPage = ( + page: ChangeLogPageData, +) => Partial | Promise>; diff --git a/src/core/shared/utils.ts b/src/core/shared/utils.ts index 3d11cebc..05cf60bf 100644 --- a/src/core/shared/utils.ts +++ b/src/core/shared/utils.ts @@ -1,8 +1,9 @@ -import { Skip } from './types'; +import { ExternalMetadata, Frontmatter, Skip, SourceFileMetadata } from './types'; import { Type } from '@cparra/apex-reflection'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { MarkdownGeneratorConfig } from '../markdown/generate-docs'; import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; +import yaml from 'js-yaml'; /** * Represents a file to be skipped. @@ -25,6 +26,10 @@ export function isApexType(type: Type | CustomObjectMetadata | CustomFieldMetada return !isObjectType(type); } +export function isInSource(source: SourceFileMetadata | ExternalMetadata): source is SourceFileMetadata { + return 'filePath' in source; +} + export function getTypeGroup(type: Type | CustomObjectMetadata, config: MarkdownGeneratorConfig): string { function getGroup(type: Type, config: MarkdownGeneratorConfig): string { const groupAnnotation = type.docComment?.annotations.find( @@ -40,3 +45,20 @@ export function getTypeGroup(type: Type | CustomObjectMetadata, config: Markdown return getGroup(type, config); } } + +export function passThroughHook(value: T): T { + return value; +} + +export function toFrontmatterString(frontmatter: Frontmatter): string { + if (typeof frontmatter === 'string') { + return frontmatter; + } + + if (!frontmatter) { + return ''; + } + + const yamlString = yaml.dump(frontmatter); + return `---\n${yamlString}---\n`; +} diff --git a/src/core/test-helpers/test-data-builders.ts b/src/core/test-helpers/test-data-builders.ts index be47b271..4bd4a20e 100644 --- a/src/core/test-helpers/test-data-builders.ts +++ b/src/core/test-helpers/test-data-builders.ts @@ -1,19 +1,5 @@ import { UnparsedCustomFieldBundle } from '../shared/types'; -export function customObjectGenerator( - config: { deploymentStatus: string; visibility: string } = { deploymentStatus: 'Deployed', visibility: 'Public' }, -) { - return ` - - - ${config.deploymentStatus} - test object for testing - - MyFirstObjects - ${config.visibility} - `; -} - export const customField = ` diff --git a/src/core/test-helpers/test-data-builders/custom-field-xml-builder.ts b/src/core/test-helpers/test-data-builders/custom-field-xml-builder.ts new file mode 100644 index 00000000..94327216 --- /dev/null +++ b/src/core/test-helpers/test-data-builders/custom-field-xml-builder.ts @@ -0,0 +1,15 @@ +export class CustomFieldXmlBuilder { + build(): string { + return ` + + + PhotoUrl__c + false + + false + false + Url + A URL that points to a photo + `; + } +} diff --git a/src/core/test-helpers/test-data-builders/custom-object-xml-builder.ts b/src/core/test-helpers/test-data-builders/custom-object-xml-builder.ts new file mode 100644 index 00000000..9eba1bee --- /dev/null +++ b/src/core/test-helpers/test-data-builders/custom-object-xml-builder.ts @@ -0,0 +1,76 @@ +export class CustomObjectXmlBuilder { + deploymentStatus = 'Deployed'; + visibility = 'Public'; + label = 'MyTestObject'; + fields: InlineFieldBuilder[] = []; + + withDeploymentStatus(deploymentStatus: string): CustomObjectXmlBuilder { + this.deploymentStatus = deploymentStatus; + return this; + } + + withVisibility(visibility: string): CustomObjectXmlBuilder { + this.visibility = visibility; + return this; + } + + withLabel(label: string): CustomObjectXmlBuilder { + this.label = label; + return this; + } + + withFields(fields: InlineFieldBuilder[]): CustomObjectXmlBuilder { + this.fields = fields; + return this; + } + + build(): string { + return ` + + + ${this.deploymentStatus} + test object for testing + + MyFirstObjects + ${this.visibility} + ${this.fields.map((field) => field.build()).join('')} + `; + } +} + +export class InlineFieldBuilder { + fullName = 'TestField__c'; + description = 'Test field for testing'; + label = 'TestField'; + type = 'Text'; + + withFullName(fullName: string): InlineFieldBuilder { + this.fullName = fullName; + return this; + } + + withDescription(description: string): InlineFieldBuilder { + this.description = description; + return this; + } + + withLabel(label: string): InlineFieldBuilder { + this.label = label; + return this; + } + + withType(type: string): InlineFieldBuilder { + this.type = type; + return this; + } + + build(): string { + return ` + + ${this.fullName} + ${this.description} + + ${this.type} + `; + } +} diff --git a/src/index.ts b/src/index.ts index dc7c2aa1..ed38ae86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ import type { - ConfigurableHooks, + MarkdownConfigurableHooks, Skip, UserDefinedMarkdownConfig, ReferenceGuidePageData, DocPageData, DocPageReference, + ChangeLogPageData, ConfigurableDocPageData, TransformReferenceGuide, TransformDocs, @@ -13,6 +14,8 @@ import type { ConfigurableDocPageReference, UserDefinedOpenApiConfig, UserDefinedChangelogConfig, + ChangelogConfigurableHooks, + TransformChangelogPage, } from './core/shared/types'; import { skip } from './core/shared/utils'; import { changeLogDefaults, markdownDefaults, openApiDefaults } from './defaults'; @@ -72,12 +75,15 @@ export { TransformDocs, TransformDocPage, TransformReference, - ConfigurableHooks, + MarkdownConfigurableHooks, ReferenceGuidePageData, DocPageData, + ChangeLogPageData, DocPageReference, Skip, ConfigurableDocPageData, ConfigurableDocPageReference, process, + ChangelogConfigurableHooks, + TransformChangelogPage, };