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; +};