From 7fd8afd719bc6325349c6be2e1dd109aff2706a7 Mon Sep 17 00:00:00 2001 From: Ryan Waskiewicz Date: Fri, 19 Apr 2024 13:59:55 -0400 Subject: [PATCH] feat(css): add shadow part detection (#6) add shadow part detection to the output target. similar to stencil's auto generation of docs data, we pull the shadow part information from the compiler metadata. a new example was added to the project to showcase how shadow parts work and where autocompletion can help the development experience here. the readme was updated to clarify scenarios where overriding the local dependency might not give the latest/greatest features. unused styles from my-component (where i originally intended to showcase this functionality) has been removed --- example/readme.md | 2 +- example/src/components.d.ts | 29 +++++ .../components/my-component/my-component.css | 3 - .../components/my-component/my-component.tsx | 1 - .../components/shadow-parts/shadow-parts.tsx | 28 +++++ example/src/index.html | 19 ++++ example/web-types.json | 35 +++++- src/contributions/html-contributions.test.ts | 78 +++++++++++++- src/contributions/html-contributions.ts | 102 +++++++++++++++++- src/index.ts | 21 ++++ 10 files changed, 306 insertions(+), 12 deletions(-) delete mode 100644 example/src/components/my-component/my-component.css create mode 100644 example/src/components/shadow-parts/shadow-parts.tsx diff --git a/example/readme.md b/example/readme.md index 6ccce5d..7864c8f 100644 --- a/example/readme.md +++ b/example/readme.md @@ -7,7 +7,7 @@ This project demonstrates the usage of the `@stencil-community/web-types-output- ## Set Up To set up this project, you may either first build the output target from source, or override this project's dependency on `@stencil-community/web-types-output-target` with a version published to the NPM registry. -Both allow you to take the output target for a 'test drive' - the only difference is the former allows you to tweak the output target's source code and see how it affects the example project. +Both allow you to take the output target for a 'test drive' - however, the former will allow you to try out potentially unreleased functionality. After setting up the dependencies, continue to the next section. diff --git a/example/src/components.d.ts b/example/src/components.d.ts index f38e6fc..a2e86b6 100644 --- a/example/src/components.d.ts +++ b/example/src/components.d.ts @@ -28,6 +28,12 @@ export namespace Components { */ "suffix": string; } + /** + * An example using Shadow Parts. + * The 'label' part is declared in the component-level JSDoc using "@part NAME - DESCRIPTION". + */ + interface ShadowParts { + } interface SlotExample { } } @@ -41,6 +47,16 @@ declare global { prototype: HTMLMyComponentElement; new (): HTMLMyComponentElement; }; + /** + * An example using Shadow Parts. + * The 'label' part is declared in the component-level JSDoc using "@part NAME - DESCRIPTION". + */ + interface HTMLShadowPartsElement extends Components.ShadowParts, HTMLStencilElement { + } + var HTMLShadowPartsElement: { + prototype: HTMLShadowPartsElement; + new (): HTMLShadowPartsElement; + }; interface HTMLSlotExampleElement extends Components.SlotExample, HTMLStencilElement { } var HTMLSlotExampleElement: { @@ -49,6 +65,7 @@ declare global { }; interface HTMLElementTagNameMap { "my-component": HTMLMyComponentElement; + "shadow-parts": HTMLShadowPartsElement; "slot-example": HTMLSlotExampleElement; } } @@ -75,10 +92,17 @@ declare namespace LocalJSX { */ "suffix"?: string; } + /** + * An example using Shadow Parts. + * The 'label' part is declared in the component-level JSDoc using "@part NAME - DESCRIPTION". + */ + interface ShadowParts { + } interface SlotExample { } interface IntrinsicElements { "my-component": MyComponent; + "shadow-parts": ShadowParts; "slot-example": SlotExample; } } @@ -90,6 +114,11 @@ declare module "@stencil/core" { * A component for displaying a person's name */ "my-component": LocalJSX.MyComponent & JSXBase.HTMLAttributes; + /** + * An example using Shadow Parts. + * The 'label' part is declared in the component-level JSDoc using "@part NAME - DESCRIPTION". + */ + "shadow-parts": LocalJSX.ShadowParts & JSXBase.HTMLAttributes; "slot-example": LocalJSX.SlotExample & JSXBase.HTMLAttributes; } } diff --git a/example/src/components/my-component/my-component.css b/example/src/components/my-component/my-component.css deleted file mode 100644 index 5d4e87f..0000000 --- a/example/src/components/my-component/my-component.css +++ /dev/null @@ -1,3 +0,0 @@ -:host { - display: block; -} diff --git a/example/src/components/my-component/my-component.tsx b/example/src/components/my-component/my-component.tsx index 1108eff..cc4a056 100644 --- a/example/src/components/my-component/my-component.tsx +++ b/example/src/components/my-component/my-component.tsx @@ -5,7 +5,6 @@ import { Component, Prop, h } from '@stencil/core'; */ @Component({ tag: 'my-component', - styleUrl: 'my-component.css', shadow: true, }) export class MyComponent { diff --git a/example/src/components/shadow-parts/shadow-parts.tsx b/example/src/components/shadow-parts/shadow-parts.tsx new file mode 100644 index 0000000..692dd8b --- /dev/null +++ b/example/src/components/shadow-parts/shadow-parts.tsx @@ -0,0 +1,28 @@ +import { Component, h } from '@stencil/core'; + +/** + * An example using Shadow Parts. + * + * The 'label' part is declared in the component-level JSDoc using "@part NAME - DESCRIPTION". + * + * @part first-msg - The text describing the first message of the component. + * @part second-msg - The text describing the second message of the component. + */ +@Component({ + tag: 'shadow-parts', + styles: 'div { background: LightGray; }', + shadow: true, +}) +export class ShadowParts { + + render() { + return ( +
+
I am styled with Shadow Parts!
+
I am also styled with Shadow Parts!
+
I am not styled with Shadow Parts
+
+ ); + } + +} diff --git a/example/src/index.html b/example/src/index.html index 9402ee1..ebd9c1d 100644 --- a/example/src/index.html +++ b/example/src/index.html @@ -7,6 +7,19 @@ + + + + + + @@ -31,5 +44,11 @@

Slot Example (<slot-example>)

Primary Content
Secondary Content
+ +
+ + +

CSS Shadow Part Example (<shadow-parts>)

+ diff --git a/example/web-types.json b/example/web-types.json index 64460bc..75924e4 100644 --- a/example/web-types.json +++ b/example/web-types.json @@ -44,7 +44,29 @@ "priority": "high" } ], - "slots": [] + "slots": [], + "css": { + "parts": [] + } + }, + { + "name": "shadow-parts", + "deprecated": false, + "description": "An example using Shadow Parts.\n\nThe 'label' part is declared in the component-level JSDoc using \"@part NAME - DESCRIPTION\".", + "attributes": [], + "slots": [], + "css": { + "parts": [ + { + "name": "first-msg", + "description": "The text describing the first message of the component." + }, + { + "name": "second-msg", + "description": "The text describing the second message of the component." + } + ] + } }, { "name": "slot-example", @@ -64,7 +86,10 @@ "name": "secondary", "description": "" } - ] + ], + "css": { + "parts": [] + } } ] }, @@ -73,6 +98,9 @@ { "events": [] }, + { + "events": [] + }, { "events": [] } @@ -94,6 +122,9 @@ } ] }, + { + "properties": [] + }, { "properties": [] } diff --git a/src/contributions/html-contributions.test.ts b/src/contributions/html-contributions.test.ts index 8978600..5a4ee3a 100644 --- a/src/contributions/html-contributions.test.ts +++ b/src/contributions/html-contributions.test.ts @@ -25,6 +25,7 @@ describe('generateElementInfo', () => { description: 'a simple component that shows us your name', attributes: [], slots: [], + css: {}, }; const actual: ElementInfo[] = generateElementInfo([cmpMeta]); @@ -54,6 +55,7 @@ describe('generateElementInfo', () => { description: 'a simple component that shows us your name', attributes: [], slots: [], + css: {}, }; cmpMeta.properties = []; @@ -117,6 +119,7 @@ describe('generateElementInfo', () => { }, ], slots: [], + css: {}, }; cmpMeta.properties = [ @@ -158,6 +161,7 @@ describe('generateElementInfo', () => { }, ], slots: [], + css: {}, }; cmpMeta.properties = [ @@ -201,16 +205,17 @@ describe('generateElementInfo', () => { it('parses a component with no slots', () => { const expected: ElementInfo = { name: 'my-component', - deprecated: false, + deprecated: true, description: 'a simple component that shows us your name', attributes: [], slots: [], + css: {}, }; cmpMeta.docs.tags = [ { - name: 'part', - text: 'label - The label text describing the component', + name: 'deprecated', + text: "please don't use this", }, ]; const actual: ElementInfo[] = generateElementInfo([cmpMeta]); @@ -231,6 +236,7 @@ describe('generateElementInfo', () => { description: 'Content is placed between the named slots if provided without a slot.', }, ], + css: {}, }; cmpMeta.docs.tags = [ @@ -257,6 +263,7 @@ describe('generateElementInfo', () => { description: '', }, ], + css: {}, }; cmpMeta.docs.tags = [ @@ -283,6 +290,7 @@ describe('generateElementInfo', () => { description: 'Content is placed to the right of the main slotted in text', }, ], + css: {}, }; cmpMeta.docs.tags = [ @@ -297,6 +305,70 @@ describe('generateElementInfo', () => { expect(actual[0]).toEqual(expected); }); }); + + describe('shadow parts', () => { + let cmpMeta: ComponentCompilerMeta; + + beforeEach(() => { + cmpMeta = stubComponentCompilerMeta({ + tagName: 'my-component', + docs: { + text: 'a simple component that shows us your name', + tags: [], + }, + properties: [], + }); + }); + + it('parses a component with shadow parts', () => { + const expected: ElementInfo = { + name: 'my-component', + deprecated: false, + description: 'a simple component that shows us your name', + attributes: [], + slots: [], + css: { + parts: [ + // note how these will be sorted by name + { name: 'another-label', description: 'Another label describing the component' }, + { name: 'label', description: 'The label describing the component' }, + ], + }, + }; + + cmpMeta.docs.tags = [ + { + name: 'part', + text: 'label - The label describing the component', + }, + { + name: 'part', + text: 'another-label - Another label describing the component', + }, + ]; + const actual: ElementInfo[] = generateElementInfo([cmpMeta]); + + expect(actual).toHaveLength(1); + expect(actual[0]).toEqual(expected); + }); + + it('omits the parts section when there are no shadow parts', () => { + const expected: ElementInfo = { + name: 'my-component', + deprecated: false, + description: 'a simple component that shows us your name', + attributes: [], + slots: [], + css: {}, + }; + + cmpMeta.docs.tags = []; + const actual: ElementInfo[] = generateElementInfo([cmpMeta]); + + expect(actual).toHaveLength(1); + expect(actual[0]).toEqual(expected); + }); + }); }); /** diff --git a/src/contributions/html-contributions.ts b/src/contributions/html-contributions.ts index 81ada52..0428542 100644 --- a/src/contributions/html-contributions.ts +++ b/src/contributions/html-contributions.ts @@ -1,5 +1,11 @@ -import type { CompilerJsDocTagInfo, ComponentCompilerMeta, ComponentCompilerProperty } from '@stencil/core/internal'; -import { ElementInfo } from '../index'; +import type { + CompilerJsDocTagInfo, + ComponentCompilerMeta, + ComponentCompilerProperty, + JsonDocsTag, +} from '@stencil/core/internal'; +import { CssPart, ElementInfo } from '../index'; +import { JsonDocsPart } from '@stencil/core/internal/stencil-public-docs'; // https://plugins.jetbrains.com/docs/intellij/websymbols-web-types.html#web-components // https://github.com/JetBrains/web-types/blob/2c07137416e4151bfaf44bf3226dca7f1a5e9bd3/schema/web-types.json#L303 @@ -11,6 +17,12 @@ import { ElementInfo } from '../index'; */ export const generateElementInfo = (compnentMetadata: ComponentCompilerMeta[]): ElementInfo[] => { return compnentMetadata.map((cmpMeta: ComponentCompilerMeta): ElementInfo => { + // avoid serializing parts for css contributions for an element if we can avoid it + let cssParts: CssPart[] | undefined = getDocsParts(cmpMeta.htmlParts, cmpMeta.docs.tags).map((parts) => { + return { name: parts.name, description: parts.docs }; + }); + cssParts = cssParts.length ? cssParts : undefined; + return { name: cmpMeta.tagName, deprecated: !!cmpMeta.docs.tags.find((tag) => tag.name.toLowerCase() === 'deprecated'), @@ -48,6 +60,92 @@ export const generateElementInfo = (compnentMetadata: ComponentCompilerMeta[]): description: rest.join(' ').trim(), }; }), + css: { + parts: cssParts, + }, }; }); }; + +/** + * Attribution: https://github.com/ionic-team/stencil/blob/6bfba1dda502f4ad67263b31b2945fa38a04b338/src/compiler/docs/generate-doc-data.ts#L352 + * Find all JSDoc `@part` tags + * @param vdom auto-detected shadow parts from the vdom + * @param tags any JSDoc tags associated with the component + * @returns the found docs for shadow parts + */ +const getDocsParts = (vdom: string[], tags: JsonDocsTag[]): JsonDocsPart[] => { + const docsParts = getNameText('part', tags).map(([name, docs]) => ({ name, docs })); + const vdomParts = vdom.map((name) => ({ name, docs: '' })); + return sortBy( + unique([...docsParts, ...vdomParts], (p) => p.name), + (p) => p.name, + ); +}; + +/** + * Attribution: https://github.com/ionic-team/stencil/blob/6bfba1dda502f4ad67263b31b2945fa38a04b338/src/compiler/docs/generate-doc-data.ts#L361 + * Search for one or more JSDoc tags with the provided `name` value + * @param name the JSDoc name to search for + * @param tags the list of JSDoc tags to search through + * @returns an array of tuples containing the name of the desired tag and its description text + */ +const getNameText = (name: string, tags: JsonDocsTag[]): [name: string, description: string][] => { + return tags + .filter((tag): tag is JsonDocsTag & { text: string } => tag.name.toLowerCase() === name.toLowerCase() && !!tag.text) + .map(({ text }) => { + const [namePart, ...rest] = (' ' + text).split(' - '); + return [namePart.trim(), rest.join('').trim()]; + }); +}; + +/** + * Attribution: https://github.com/ionic-team/stencil/blob/84e1a14048bc34e64a866659d39376af605f8f9a/src/utils/helpers.ts#L79 + * + * Sort an array without mutating it in-place (as `Array.prototype.sort` + * unfortunately does). + * + * We use this instead of `toSorted`, as only Node 20+ supports it (and Stencil v4 can run on Node 16, 18). + * + * @param array the array you'd like to sort + * @param prop a function for deriving sortable values (strings or numbers) + * from array members + * @returns a new array of all items `x` in `array` ordered by `prop(x)` + */ +export const sortBy = (array: T[], prop: (item: T) => string | number): T[] => { + return array.slice().sort((a, b) => { + const nameA = prop(a); + const nameB = prop(b); + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + return 0; + }); +}; + +/** + * Attribution: https://github.com/ionic-team/stencil/blob/84e1a14048bc34e64a866659d39376af605f8f9a/src/utils/helpers.ts#L118 + * + * Deduplicate an array, retaining items at the earliest position in which + * they appear. + * + * So `unique([1,3,2,1,1,4])` would be `[1,3,2,4]`. + * + * @param array the array to deduplicate + * @param predicate an optional function used to generate the key used to + * determine uniqueness + * @returns a new, deduplicated array + */ +export const unique = (array: T[], predicate: (item: T) => K = (i) => i as any): T[] => { + const set = new Set(); + return array.filter((item) => { + const key = predicate(item); + if (key == null) { + return true; + } + if (set.has(key)) { + return false; + } + set.add(key); + return true; + }); +}; diff --git a/src/index.ts b/src/index.ts index e68f710..c1b8a4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,13 @@ export type ElementInfo = { * Slots are detected using the `@slot` JSDoc tag on a Stencil component's class JSDoc. */ slots: SlotInfo[]; + css: { + /** + * All shadow parts associated with the component. + * Shadow parts are detected using the `@part` JSDoc tag on a Stencil component's class JSDoc. + */ + parts?: CssPart[]; + }; }; type AttributeInfo = { @@ -82,3 +89,17 @@ export type SlotInfo = { */ description: string; }; + +/** + * Describes a CSS Shadow Part in a Stencil component + */ +export type CssPart = { + /** + * The name of the part. + */ + name: string; + /** + * A string of text explaining the purpose/usage of the part. + */ + description: string; +};