From a060420dae017277ac9a36e2e96aba6afd33e7f9 Mon Sep 17 00:00:00 2001 From: Gerrit Birkeland Date: Sun, 5 May 2024 15:49:32 -0600 Subject: [PATCH] Support cascading modifier tags Resolves #2056 --- CHANGELOG.md | 2 + src/lib/converter/plugins/CommentPlugin.ts | 66 +++++++++++++++++++ src/lib/internationalization/translatable.ts | 1 + src/lib/utils/options/declaration.ts | 1 + src/lib/utils/options/sources/typedoc.ts | 15 +++++ src/lib/utils/set.ts | 9 +++ src/test/behavior.c2.test.ts | 20 ++++++ .../converter2/behavior/cascadedModifiers.ts | 14 ++++ 8 files changed, 128 insertions(+) create mode 100644 src/lib/utils/set.ts create mode 100644 src/test/converter2/behavior/cascadedModifiers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ace22d51c..ac03136a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ - Added support for a `packageOptions` object which specifies options that should be applied to each entry point when running with `--entryPointStrategy packages`, #2523. - `--hostedBaseUrl` will now be used to generate a `` element in the project root page, #2550. - New option, `--customFooterHtml` to add custom HTML to the generated page footer, #2559. +- TypeDoc will now copy modifier tags to children if specified in the `--cascadedModifierTags` option, #2056. +- TypeDoc will now warn if mutually exclusive modifier tags are specified for a comment (e.g. both `@alpha` and `@beta`), #2056. - Added three new sort strategies `documents-first`, `documents-last`, and `alphabetical-ignoring-documents` to order markdown documents. - Added new `--alwaysCreateEntryPointModule` option. When set, TypeDoc will always create a `Module` for entry points, even if only one is provided. If `--projectDocuments` is used to add documents, this option defaults to `true`, otherwise, defaults to `false`. diff --git a/src/lib/converter/plugins/CommentPlugin.ts b/src/lib/converter/plugins/CommentPlugin.ts index d8a0c5f47..567b6f180 100644 --- a/src/lib/converter/plugins/CommentPlugin.ts +++ b/src/lib/converter/plugins/CommentPlugin.ts @@ -24,6 +24,7 @@ import { removeIf, } from "../../utils"; import { CategoryPlugin } from "./CategoryPlugin"; +import { setIntersection } from "../../utils/set"; /** * These tags are not useful to display in the generated documentation. @@ -47,6 +48,18 @@ const NEVER_RENDERED = [ "@typedef", ] as const; +// We might make this user configurable at some point, but for now, +// this set is configured here. +const MUTUALLY_EXCLUSIVE_MODIFIERS = [ + new Set<`@${string}`>([ + "@alpha", + "@beta", + "@experimental", + "@internal", + "@public", + ]), +] as const; + /** * Handles most behavior triggered by comments. `@group` and `@category` are handled by their respective plugins, but everything else is here. * @@ -108,6 +121,9 @@ export class CommentPlugin extends ConverterComponent { @Option("excludeTags") accessor excludeTags!: `@${string}`[]; + @Option("cascadedModifierTags") + accessor cascadedModifierTags!: `@${string}`[]; + @Option("excludeInternal") accessor excludeInternal!: boolean; @@ -262,6 +278,8 @@ export class CommentPlugin extends ConverterComponent { * @param node The node that is currently processed if available. */ private onDeclaration(_context: Context, reflection: Reflection) { + this.cascadeModifiers(reflection); + const comment = reflection.comment; if (!comment) return; @@ -356,6 +374,23 @@ export class CommentPlugin extends ConverterComponent { ); } + for (const group of MUTUALLY_EXCLUSIVE_MODIFIERS) { + const intersect = setIntersection( + group, + reflection.comment.modifierTags, + ); + if (intersect.size > 1) { + const [a, b] = intersect; + context.logger.warn( + context.i18n.modifier_tag_0_is_mutually_exclusive_with_1_in_comment_for_2( + a, + b, + reflection.getFriendlyFullName(), + ), + ); + } + } + mergeSeeTags(reflection.comment); movePropertyTags(reflection.comment, reflection); } @@ -381,6 +416,14 @@ export class CommentPlugin extends ConverterComponent { reflection.comment.removeTags("@returns"); } } + + // Any cascaded tags will show up twice, once on this and once on our signatures + // This is completely redundant, so remove them from the wrapping function. + if (sigs.length) { + for (const mod of this.cascadedModifierTags) { + reflection.comment.modifierTags.delete(mod); + } + } } if (reflection instanceof SignatureReflection) { @@ -448,6 +491,29 @@ export class CommentPlugin extends ConverterComponent { } } + private cascadeModifiers(reflection: Reflection) { + const parentComment = reflection.parent?.comment; + if (!parentComment) return; + + const childMods = reflection.comment?.modifierTags ?? new Set(); + + for (const mod of this.cascadedModifierTags) { + if (parentComment.hasModifier(mod)) { + const exclusiveSet = MUTUALLY_EXCLUSIVE_MODIFIERS.find((tags) => + tags.has(mod), + ); + + if ( + !exclusiveSet || + Array.from(exclusiveSet).every((tag) => !childMods.has(tag)) + ) { + reflection.comment ||= new Comment(); + reflection.comment.modifierTags.add(mod); + } + } + } + } + /** * Determines whether or not a reflection has been hidden * diff --git a/src/lib/internationalization/translatable.ts b/src/lib/internationalization/translatable.ts index 13c51c759..379890620 100644 --- a/src/lib/internationalization/translatable.ts +++ b/src/lib/internationalization/translatable.ts @@ -96,6 +96,7 @@ export const translatable = { comment_for_0_includes_categoryDescription_for_1_but_no_child_in_group: `Comment for {0} includes @categoryDescription for "{1}", but no child is placed in that category.`, comment_for_0_includes_groupDescription_for_1_but_no_child_in_group: `Comment for {0} includes @groupDescription for "{1}", but no child is placed in that group.`, label_0_for_1_cannot_be_referenced: `The label "{0}" for {1} cannot be referenced with a declaration reference. Labels may only contain A-Z, 0-9, and _, and may not start with a number.`, + modifier_tag_0_is_mutually_exclusive_with_1_in_comment_for_2: `The modifier tag {0} is mutually exclusive with {1} in the comment for {2}.`, signature_0_has_unused_param_with_name_1: `The signature {0} has an @param with name "{1}", which was not used.`, declaration_reference_in_inheritdoc_for_0_not_fully_parsed: `Declaration reference in @inheritDoc for {0} was not fully parsed and may resolve incorrectly.`, failed_to_find_0_to_inherit_comment_from_in_1: `Failed to find "{0}" to inherit the comment from in the comment for {1}`, diff --git a/src/lib/utils/options/declaration.ts b/src/lib/utils/options/declaration.ts index 3780208ec..97a06be35 100644 --- a/src/lib/utils/options/declaration.ts +++ b/src/lib/utils/options/declaration.ts @@ -202,6 +202,7 @@ export interface TypeDocOptionMap { externalSymbolLinkMappings: ManuallyValidatedOption< Record> >; + cascadedModifierTags: `@${string}`[]; // Organization categorizeByGroup: boolean; diff --git a/src/lib/utils/options/sources/typedoc.ts b/src/lib/utils/options/sources/typedoc.ts index 9448cb04b..b7380f750 100644 --- a/src/lib/utils/options/sources/typedoc.ts +++ b/src/lib/utils/options/sources/typedoc.ts @@ -705,6 +705,21 @@ export function addTypeDocOptions(options: Pick) { } }, }); + options.addDeclaration({ + name: "cascadedModifierTags", + help: (i18n) => i18n.help_modifierTags(), + type: ParameterType.Array, + defaultValue: ["@alpha", "@beta", "@experimental"], + validate(value, i18n) { + if (!Validation.validate([Array, Validation.isTagString], value)) { + throw new Error( + i18n.option_0_values_must_be_array_of_tags( + "cascadedModifierTags", + ), + ); + } + }, + }); /////////////////////////// // Organization Options /// diff --git a/src/lib/utils/set.ts b/src/lib/utils/set.ts new file mode 100644 index 000000000..d8018d950 --- /dev/null +++ b/src/lib/utils/set.ts @@ -0,0 +1,9 @@ +export function setIntersection(a: Set, b: Set): Set { + const result = new Set(); + for (const elem of a) { + if (b.has(elem)) { + result.add(elem); + } + } + return result; +} diff --git a/src/test/behavior.c2.test.ts b/src/test/behavior.c2.test.ts index 52bd9cefd..07c59684d 100644 --- a/src/test/behavior.c2.test.ts +++ b/src/test/behavior.c2.test.ts @@ -1205,4 +1205,24 @@ describe("Behavior Tests", () => { const sig2 = querySig(project, "isNonNullish"); equal(sig2.type?.toString(), "x is NonNullable"); }); + + it("Cascades specified modifier tags to child reflections, #2056", () => { + const project = convert("cascadedModifiers"); + + const mods = (s: string) => query(project, s).comment?.modifierTags; + const sigMods = (s: string) => + querySig(project, s).comment?.modifierTags; + + equal(mods("BetaStuff"), new Set(["@beta"])); + equal(mods("BetaStuff.AlsoBeta"), new Set(["@beta"])); + equal(mods("BetaStuff.AlsoBeta.betaFish"), new Set()); + equal(mods("BetaStuff.AlsoBeta.alphaFish"), new Set()); + + equal(sigMods("BetaStuff.AlsoBeta.betaFish"), new Set(["@beta"])); + equal(sigMods("BetaStuff.AlsoBeta.alphaFish"), new Set(["@alpha"])); + + logger.expectMessage( + "warn: The modifier tag @alpha is mutually exclusive with @beta in the comment for mutuallyExclusive.", + ); + }); }); diff --git a/src/test/converter2/behavior/cascadedModifiers.ts b/src/test/converter2/behavior/cascadedModifiers.ts new file mode 100644 index 000000000..a5730a522 --- /dev/null +++ b/src/test/converter2/behavior/cascadedModifiers.ts @@ -0,0 +1,14 @@ +/** + * @beta + */ +export namespace BetaStuff { + export class AlsoBeta { + betaFish() {} + + /** @alpha */ + alphaFish() {} + } +} + +/** @alpha @beta */ +export const mutuallyExclusive = true;