diff --git a/docs/enforce-slot-jsdoc.md b/docs/enforce-slot-jsdoc.md new file mode 100644 index 0000000..4fa81e3 --- /dev/null +++ b/docs/enforce-slot-jsdoc.md @@ -0,0 +1,18 @@ +# enforce-slot-jsdoc + +This rule ensures proper slot documentation by verifying: + +* Every slot defined in the component has a corresponding @slot JSDoc tag. +* Every @slot JSDoc tag matches a slot in the component. + +**Note**: Only slots with static names can be detected for mismatches. + +## Config + +No config is needed + +## Usage + +```json +{ "@stencil-community/enforce-slot-jsdoc": "error" } +``` diff --git a/src/rules/enforce-slot-jsdoc.ts b/src/rules/enforce-slot-jsdoc.ts new file mode 100644 index 0000000..30c515c --- /dev/null +++ b/src/rules/enforce-slot-jsdoc.ts @@ -0,0 +1,64 @@ +import { Rule } from 'eslint'; +import { stencilComponentContext } from '../utils'; + +const rule: Rule.RuleModule = { + meta: { + docs: { + description: 'Ensures slots are documented with JSDoc.', + category: 'Possible Errors', + recommended: true, + }, + schema: [], + type: 'problem', + }, + + create(context): Rule.RuleListener { + const stencil = stencilComponentContext(); + const { parserServices } = context; + const implementedSlots = new Set(); + + return { + ...stencil.rules, + 'ClassDeclaration:exit': (node: any) => { + if (stencil.isComponent()) { + const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node); + const jsDoc = originalNode.jsDoc; + const documentedSlots: Set = new Set(jsDoc[0].tags + .filter((tag: any) => tag.tagName.escapedText === "slot") + .map((tag: any) => tag.comment.split("-")[0].trim() || "") + ); + + const missingDocSlots = Array.from(implementedSlots).filter(slot => !documentedSlots.has(slot)); + const nonImplementedSlots = Array.from(documentedSlots).filter(slot => !implementedSlots.has(slot)); + + missingDocSlots.forEach(slot => { + context.report({ + node, + message: slot === "" ? "The default @slot must be documented." : `The @slot '${slot}' must be documented.`, + }); + }); + + nonImplementedSlots.forEach(slot => { + context.report({ + node, + message: slot === "" ? "The default @slot is not implemented." : `The @slot '${slot}' is not implemented.`, + }); + }); + } + + implementedSlots.clear(); + stencil.rules["ClassDeclaration:exit"](node); + }, + + JSXElement(node: any): void { + if (node.openingElement.name.name !== "slot") return; + + const nameAttribute = node.openingElement.attributes.find((attribute: any) => attribute.name.name === "name"); + const slotName = nameAttribute && nameAttribute.value ? nameAttribute.value.value : ""; + implementedSlots.add(slotName); + }, + }; + }, +}; + +export default rule; diff --git a/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.good.tsx b/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.good.tsx new file mode 100644 index 0000000..457c7e7 --- /dev/null +++ b/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.good.tsx @@ -0,0 +1,17 @@ +/** + * @slot - The default slot + * @slot header - The header slot + * @slot footer - The footer slot + */ +@Component({ tag: 'sample-tag' }) +export class TheSampleTag { + render() { + return ( +
+ hello + + +
+ ); + } +} diff --git a/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.spec.ts b/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.spec.ts new file mode 100644 index 0000000..7e811d4 --- /dev/null +++ b/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.spec.ts @@ -0,0 +1,29 @@ +import rule from '../../../../src/rules/enforce-slot-jsdoc'; +import { ruleTester } from '../rule-tester'; +import * as path from 'path'; +import * as fs from 'fs'; + +describe('stencil rules', () => { + const files = { + good: path.resolve(__dirname, 'enforce-slot-jsdoc.good.tsx'), + wrong: path.resolve(__dirname, 'enforce-slot-jsdoc.wrong.tsx') + }; + const validCode = fs.readFileSync(files.good, 'utf8'); + + ruleTester.run('enforce-slot-jsdoc', rule, { + valid: [ + { + code: validCode, + filename: files.good + } + ], + + invalid: [ + { + code: fs.readFileSync(files.wrong, 'utf8'), + filename: files.wrong, + errors: 3, + } + ] + }); +}); diff --git a/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.wrong.tsx b/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.wrong.tsx new file mode 100644 index 0000000..173e56a --- /dev/null +++ b/tests/lib/rules/enforce-slot-jsdoc/enforce-slot-jsdoc.wrong.tsx @@ -0,0 +1,14 @@ +/** + * @slot - The default slot (not implemented) + * @slot header - The header slot (not implemented) + */ +@Component({ tag: 'sample-tag' }) +export class TheSampleTag { + render() { + return ( +
+ not documented +
+ ); + } +} diff --git a/tests/lib/rules/methods-must-be-public/methods-must-be-public.spec.ts b/tests/lib/rules/methods-must-be-public/methods-must-be-public.spec.ts index eec96fe..bb670ad 100644 --- a/tests/lib/rules/methods-must-be-public/methods-must-be-public.spec.ts +++ b/tests/lib/rules/methods-must-be-public/methods-must-be-public.spec.ts @@ -20,7 +20,7 @@ describe('stencil rules', () => { { code: fs.readFileSync(files.wrong, 'utf8'), filename: files.wrong, - errors: 2 + errors: 3 } ] });