From aad5815a06086ae121e11d41f3e297c03c750f48 Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 24 Jun 2023 15:01:02 +0100 Subject: [PATCH] Styles API docs (#1641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds some basic API docs for the new styles API. ### Change Type - [x] `documentation` — Changes to the documentation only[^2] ### Test Plan -- ### Release Notes -- --------- Co-authored-by: Steve Ruiz --- .../CardShape/card-shape-props.ts | 11 +- packages/editor/api-report.md | 6 +- packages/editor/src/lib/editor/Editor.ts | 22 ++- .../editor/src/lib/utils/SharedStylesMap.ts | 20 ++- packages/tlschema/api-report.md | 13 +- packages/tlschema/src/styles/StyleProp.ts | 79 +++++++++- scripts/lib/docs/docs-utils.ts | 137 ++++++++++++++++-- scripts/lib/docs/generateApiContent.ts | 25 ++-- scripts/lib/docs/getApiMarkdown.ts | 27 +++- 9 files changed, 281 insertions(+), 59 deletions(-) diff --git a/apps/examples/src/3-custom-config/CardShape/card-shape-props.ts b/apps/examples/src/3-custom-config/CardShape/card-shape-props.ts index 06d7e56eb..e472a27fd 100644 --- a/apps/examples/src/3-custom-config/CardShape/card-shape-props.ts +++ b/apps/examples/src/3-custom-config/CardShape/card-shape-props.ts @@ -1,12 +1,11 @@ import { DefaultColorStyle, ShapeProps, StyleProp } from '@tldraw/tldraw' import { T } from '@tldraw/validate' -import { ICardShape, IWeightStyle } from './card-shape-types' +import { ICardShape } from './card-shape-types' -export const WeightStyle = new StyleProp( - 'myApp:weight', - 'regular', - T.literalEnum('regular', 'bold') -) +export const WeightStyle = StyleProp.defineEnum('myApp:weight', { + defaultValue: 'regular', + values: ['regular', 'bold'], +}) // Validation for our custom card shape's props, using our custom style + one of tldraw's default styles export const cardShapeProps: ShapeProps = { diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 01f13fa67..818de3c12 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -1786,7 +1786,7 @@ export function preventDefault(event: Event | React_2.BaseSyntheticEvent): void; export { react } -// @public (undocumented) +// @public export class ReadonlySharedStyleMap { // (undocumented) [Symbol.iterator](): IterableIterator<[StyleProp, SharedStyle]>; @@ -1801,7 +1801,7 @@ export class ReadonlySharedStyleMap { getAsKnownValue(prop: StyleProp): T | undefined; // (undocumented) keys(): IterableIterator>; - // (undocumented) + // @internal (undocumented) protected map: Map, SharedStyle>; // (undocumented) get size(): number; @@ -1918,7 +1918,7 @@ export abstract class ShapeUtil { static type: string; } -// @public (undocumented) +// @public export type SharedStyle = { readonly type: 'mixed'; } | { diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 91839c08f..f282d74e9 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -7769,9 +7769,16 @@ export class Editor extends EventEmitter { } /** - * A derived object containing either all current styles among the user's selected shapes, or - * else the user's most recent style choices that correspond to the current active state (i.e. - * the selected tool). + * A map of all the current styles either in the current selection, or that are relevant to the + * current tool. + * + * @example + * ```ts + * const color = editor.sharedStyles.get(DefaultColorStyle) + * if (color && color.type === 'shared') { + * console.log('All selected shapes have the same color:', color.value) + * } + * ``` * * @public */ @@ -7901,7 +7908,8 @@ export class Editor extends EventEmitter { } /** - * Set the current styles + * Set the value of a {@link @tldraw/tlschema#StyleProp}. This change will be applied to any + * selected shapes, and any subsequently created shapes. * * @example * ```ts @@ -7911,8 +7919,10 @@ export class Editor extends EventEmitter { * * @param style - The style to set. * @param value - The value to set. - * @param ephemeral - Whether the style change is ephemeral. Ephemeral changes don't get added to the undo/redo stack. Defaults to false. - * @param squashing - Whether the style change will be squashed into the existing history entry rather than creating a new one. Defaults to false. + * @param ephemeral - Whether the style change is ephemeral. Ephemeral changes don't get added + * to the undo/redo stack. Defaults to false. + * @param squashing - Whether the style change will be squashed into the existing history entry + * rather than creating a new one. Defaults to false. * * @public */ diff --git a/packages/editor/src/lib/utils/SharedStylesMap.ts b/packages/editor/src/lib/utils/SharedStylesMap.ts index 59fa60112..c4e55356e 100644 --- a/packages/editor/src/lib/utils/SharedStylesMap.ts +++ b/packages/editor/src/lib/utils/SharedStylesMap.ts @@ -1,7 +1,16 @@ import { StyleProp } from '@tldraw/tlschema' import { exhaustiveSwitchError } from '@tldraw/utils' -/** @public */ +/** + * The value of a particular {@link @tldraw/tlschema#StyleProp}. + * + * A `mixed` style means that in the current selection, there are lots of different values for the + * same style prop - e.g. a red and a blue shape are selected. + * + * A `shared` style means that all shapes in the selection share the same value for this style prop. + * + * @public + */ export type SharedStyle = | { readonly type: 'mixed' } | { readonly type: 'shared'; readonly value: T } @@ -18,9 +27,16 @@ function sharedStyleEquals(a: SharedStyle, b: SharedStyle | undefined) } } -/** @public */ +/** + * A map of {@link @tldraw/tlschema#StyleProp | StyleProps} to their {@link SharedStyle} values. See + * {@link Editor.sharedStyles}. + * + * @public + */ export class ReadonlySharedStyleMap { + /** @internal */ protected map: Map, SharedStyle> + constructor(entries?: Iterable<[StyleProp, SharedStyle]>) { this.map = new Map(entries) } diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index a74028413..8f6ccf979 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -440,7 +440,7 @@ export const embedShapeProps: { url: T.Validator; }; -// @public (undocumented) +// @public export class EnumStyleProp extends StyleProp { // @internal constructor(id: string, defaultValue: T, values: readonly T[]); @@ -723,18 +723,17 @@ export type ShapeProps> = { [K in keyof Shape['props']]: T.Validatable; }; -// @public (undocumented) +// @public export class StyleProp implements T.Validatable { - constructor(id: string, defaultValue: Type, type: T.Validatable); + // @internal + protected constructor(id: string, defaultValue: Type, type: T.Validatable); // (undocumented) readonly defaultValue: Type; - // (undocumented) - static define(uniqueId: string, { defaultValue, type }: { + static define(uniqueId: string, options: { defaultValue: Type; type?: T.Validatable; }): StyleProp; - // (undocumented) - static defineEnum(uniqueId: string, { defaultValue, values }: { + static defineEnum(uniqueId: string, options: { defaultValue: Values[number]; values: Values; }): EnumStyleProp; diff --git a/packages/tlschema/src/styles/StyleProp.ts b/packages/tlschema/src/styles/StyleProp.ts index fbd059d6e..e6a4e9012 100644 --- a/packages/tlschema/src/styles/StyleProp.ts +++ b/packages/tlschema/src/styles/StyleProp.ts @@ -1,22 +1,87 @@ import { T } from '@tldraw/validate' -/** @public */ +/** + * A `StyleProp` is a property of a shape that follows some special rules. + * + * 1. The same value can be set on lots of shapes at the same time. + * + * 2. The last used value is automatically saved and applied to new shapes. + * + * For example, {@link DefaultColorStyle} is a style prop used by tldraw's default shapes to set + * their color. If you try selecting several shapes on tldraw.com and changing their color, you'll + * see that the color is applied to all of them. Then, if you draw a new shape, it'll have the same + * color as the one you just set. + * + * You can use styles in your own shapes by either defining your own (see {@link StyleProp.define} + * and {@link StyleProp.defineEnum}) or using tldraw's default ones, like {@link DefaultColorStyle}. + * When you define a shape, pass a `props` object describing all of your shape's properties, using + * `StyleProp`s for the ones you want to be styles. See the + * {@link https://github.com/tldraw/tldraw/tree/main/apps/examples/src/16-custom-styles | custom styles example} + * for more. + * + * @public + */ export class StyleProp implements T.Validatable { + /** + * Define a new {@link StyleProp}. + * + * @param uniqueId - Each StyleProp must have a unique ID. We recommend you prefix this with + * your app/library name. + * @param options - + * - `defaultValue`: The default value for this style prop. + * + * - `type`: Optionally, describe what type of data you expect for this style prop. + * + * @example + * ```ts + * import {T} from '@tldraw/validate' + * import {StyleProp} from '@tldraw/tlschema' + * + * const MyLineWidthProp = StyleProp.define('myApp:lineWidth', { + * defaultValue: 1, + * type: T.number, + * }) + * ``` + * @public + */ static define( uniqueId: string, - { defaultValue, type = T.any }: { defaultValue: Type; type?: T.Validatable } + options: { defaultValue: Type; type?: T.Validatable } ) { + const { defaultValue, type = T.any } = options return new StyleProp(uniqueId, defaultValue, type) } + /** + * Define a new {@link StyleProp} as a list of possible values. + * + * @param uniqueId - Each StyleProp must have a unique ID. We recommend you prefix this with + * your app/library name. + * @param options - + * - `defaultValue`: The default value for this style prop. + * + * - `values`: An array of possible values of this style prop. + * + * @example + * ```ts + * import {StyleProp} from '@tldraw/tlschema' + * + * const MySizeProp = StyleProp.defineEnum('myApp:size', { + * defaultValue: 'medium', + * values: ['small', 'medium', 'large'], + * }) + * ``` + */ static defineEnum( uniqueId: string, - { defaultValue, values }: { defaultValue: Values[number]; values: Values } + options: { defaultValue: Values[number]; values: Values } ) { + const { defaultValue, values } = options return new EnumStyleProp(uniqueId, defaultValue, values) } - constructor( + /** @internal */ + protected constructor( readonly id: string, readonly defaultValue: Type, readonly type: T.Validatable @@ -27,7 +92,11 @@ export class StyleProp implements T.Validatable { } } -/** @public */ +/** + * See {@link StyleProp} & {@link StyleProp.defineEnum} + * + * @public + */ export class EnumStyleProp extends StyleProp { /** @internal */ constructor(id: string, defaultValue: T, readonly values: readonly T[]) { diff --git a/scripts/lib/docs/docs-utils.ts b/scripts/lib/docs/docs-utils.ts index 6b9ae6fba..4833e12ac 100644 --- a/scripts/lib/docs/docs-utils.ts +++ b/scripts/lib/docs/docs-utils.ts @@ -1,29 +1,67 @@ -import { ApiItem } from '@microsoft/api-extractor-model' +import { ApiItem, ApiItemKind, ApiModel } from '@microsoft/api-extractor-model' import { DocCodeSpan, DocEscapedText, DocFencedCode, + DocLinkTag, DocNode, DocParagraph, DocPlainText, DocSection, DocSoftBreak, } from '@microsoft/tsdoc' +import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils' import prettier from 'prettier' -export function getPath(item: { canonicalReference: ApiItem['canonicalReference'] }): string { - return item.canonicalReference - .toString() - .replace(/^@tldraw\/([^!]+)/, '$1/') - .replace(/[!:()#.]/g, '-') +function isOnParentPage(itemKind: ApiItemKind) { + switch (itemKind) { + case ApiItemKind.CallSignature: + case ApiItemKind.Class: + case ApiItemKind.EntryPoint: + case ApiItemKind.Enum: + case ApiItemKind.Function: + case ApiItemKind.Interface: + case ApiItemKind.Model: + case ApiItemKind.Namespace: + case ApiItemKind.Package: + case ApiItemKind.TypeAlias: + case ApiItemKind.Variable: + case ApiItemKind.None: + return false + case ApiItemKind.Constructor: + case ApiItemKind.ConstructSignature: + case ApiItemKind.EnumMember: + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + case ApiItemKind.Property: + case ApiItemKind.PropertySignature: + case ApiItemKind.IndexSignature: + return true + default: + exhaustiveSwitchError(itemKind) + } +} + +function sanitizeReference(reference: string) { + return reference + .replace(/[!:()#.[\]]/g, '-') .replace(/-+/g, '-') .replace(/^-/, '') .replace(/\/-/, '/') .replace(/-$/, '') } -export function getSlug(item: { canonicalReference: ApiItem['canonicalReference'] }): string { - return getPath(item).replace(/^[^/]+\//, '') +export function getSlug(item: ApiItem): string { + return sanitizeReference(item.canonicalReference.toString().replace(/^@tldraw\/[^!]+!/, '')) +} + +export function getPath(item: ApiItem): string { + if (isOnParentPage(item.kind)) { + const parentPath = getPath(assertExists(item.parent)) + const childSlug = getSlug(item) + return `${parentPath}#${childSlug}` + } + return sanitizeReference(item.canonicalReference.toString().replace(/^@tldraw\/([^!]+)/, '$1/')) } const prettierConfigPromise = prettier.resolveConfig(__dirname) @@ -57,12 +95,14 @@ export async function formatWithPrettier( } export class MarkdownWriter { - static async docNodeToMarkdown(docNode: DocNode) { - const writer = new MarkdownWriter() + static async docNodeToMarkdown(apiContext: ApiItem, docNode: DocNode) { + const writer = new MarkdownWriter(apiContext) await writer.writeDocNode(docNode) return writer.toString() } + private constructor(private readonly apiContext: ApiItem) {} + private result = '' write(...parts: string[]): this { @@ -102,6 +142,37 @@ export class MarkdownWriter { ) } else if (docNode instanceof DocEscapedText) { this.write(docNode.encodedText) + } else if (docNode instanceof DocLinkTag) { + if (docNode.urlDestination) { + this.write( + '[', + docNode.linkText ?? docNode.urlDestination, + '](', + docNode.urlDestination, + ')' + ) + } else { + assert(docNode.codeDestination) + const apiModel = getTopLevelModel(this.apiContext) + const refResult = apiModel.resolveDeclarationReference( + docNode.codeDestination, + this.apiContext + ) + + if (refResult.errorMessage) { + throw new Error(refResult.errorMessage) + } + const linkedItem = assertExists(refResult.resolvedApiItem) + const path = getPath(linkedItem) + + this.write( + '[', + docNode.linkText ?? getDefaultReferenceText(linkedItem), + '](/gen/', + path, + ')' + ) + } } else { throw new Error(`Unknown docNode kind: ${docNode.kind}`) } @@ -118,3 +189,49 @@ export class MarkdownWriter { return this.result } } + +function getDefaultReferenceText(item: ApiItem): string { + function parentPrefix(str: string, sep = '.'): string { + if (!item.parent) return str + return `${getDefaultReferenceText(item.parent)}${sep}${str}` + } + switch (item.kind) { + case ApiItemKind.CallSignature: + return parentPrefix(`${item.displayName}()`) + case ApiItemKind.Constructor: + case ApiItemKind.ConstructSignature: { + const parent = assertExists(item.parent) + return `new ${getDefaultReferenceText(parent)}()` + } + case ApiItemKind.EnumMember: + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + case ApiItemKind.Property: + case ApiItemKind.PropertySignature: + return parentPrefix(item.displayName) + case ApiItemKind.IndexSignature: + return parentPrefix(`[${item.displayName}]`, '') + case ApiItemKind.Class: + case ApiItemKind.EntryPoint: + case ApiItemKind.Enum: + case ApiItemKind.Function: + case ApiItemKind.Interface: + case ApiItemKind.Model: + case ApiItemKind.Namespace: + case ApiItemKind.Package: + case ApiItemKind.TypeAlias: + case ApiItemKind.Variable: + case ApiItemKind.None: + return item.displayName + default: + exhaustiveSwitchError(item.kind) + } +} + +function getTopLevelModel(item: ApiItem): ApiModel { + const model = assertExists(item.getAssociatedModel()) + if (model.parent) { + return getTopLevelModel(model.parent) + } + return model +} diff --git a/scripts/lib/docs/generateApiContent.ts b/scripts/lib/docs/generateApiContent.ts index becf5d557..173490d5e 100644 --- a/scripts/lib/docs/generateApiContent.ts +++ b/scripts/lib/docs/generateApiContent.ts @@ -28,25 +28,22 @@ async function generateApiDocs() { fs.mkdirSync(OUTPUT_DIR) // to include more packages in docs, add them to devDependencies in package.json - const tldrawPackagesToIncludeInDocs = [ - '@tldraw/editor', - '@tldraw/file-format', - '@tldraw/primitives', - '@tldraw/store', - '@tldraw/tldraw', - '@tldraw/tlschema', - '@tldraw/ui', - '@tldraw/validate', - ] - + const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')) + const tldrawPackagesToIncludeInDocs = Object.keys(packageJson.devDependencies).filter((dep) => + dep.startsWith('@tldraw/') + ) const model = new ApiModel() + const packageModels = [] + for (const packageName of tldrawPackagesToIncludeInDocs) { // Get the file contents const filePath = path.join(INPUT_DIR, packageName.replace('@tldraw/', ''), 'api', 'api.json') - try { - const packageModel = model.loadPackage(filePath) + packageModels.push(model.loadPackage(filePath)) + } + for (const packageModel of packageModels) { + try { const categoryName = packageModel.name.replace(`@tldraw/`, '') if (!addedCategories.has(categoryName)) { @@ -97,7 +94,7 @@ async function generateApiDocs() { fs.writeFileSync(path.join(OUTPUT_DIR, outputFileName), result.markdown) } } catch (e: any) { - throw Error(`Could not create API docs for ${packageName}: ${e.message}`) + throw Error(`Could not create API docs for ${packageModel.name}: ${e.message}`) } } diff --git a/scripts/lib/docs/getApiMarkdown.ts b/scripts/lib/docs/getApiMarkdown.ts index 4fedd7c3d..735e60b0f 100644 --- a/scripts/lib/docs/getApiMarkdown.ts +++ b/scripts/lib/docs/getApiMarkdown.ts @@ -22,7 +22,7 @@ import { Excerpt, ReleaseTag, } from '@microsoft/api-extractor-model' -import assert from 'assert' +import { assert, assertExists } from '@tldraw/utils' import { MarkdownWriter, formatWithPrettier, getPath, getSlug } from './docs-utils' type Result = { markdown: string } @@ -171,7 +171,10 @@ async function addDocComment(result: Result, member: ApiItem) { } if (member.tsdocComment) { - result.markdown += await MarkdownWriter.docNodeToMarkdown(member.tsdocComment.summarySection) + result.markdown += await MarkdownWriter.docNodeToMarkdown( + member, + member.tsdocComment.summarySection + ) const exampleBlocks = member.tsdocComment.customBlocks.filter( (block) => block.blockTag.tagNameWithUpperCase === '@EXAMPLE' @@ -181,7 +184,7 @@ async function addDocComment(result: Result, member: ApiItem) { result.markdown += `\n\n` result.markdown += `##### Example\n\n` for (const example of exampleBlocks) { - result.markdown += await MarkdownWriter.docNodeToMarkdown(example.content) + result.markdown += await MarkdownWriter.docNodeToMarkdown(member, example.content) } } } @@ -214,7 +217,10 @@ async function addDocComment(result: Result, member: ApiItem) { }) result.markdown += `\n\n` if (param.tsdocParamBlock) { - result.markdown += await MarkdownWriter.docNodeToMarkdown(param.tsdocParamBlock.content) + result.markdown += await MarkdownWriter.docNodeToMarkdown( + member, + param.tsdocParamBlock.content + ) } result.markdown += `\n\n\n` result.markdown += `\n` @@ -230,6 +236,7 @@ async function addDocComment(result: Result, member: ApiItem) { result.markdown += `\n\n` if (member.tsdocComment && member.tsdocComment.returnsBlock) { result.markdown += await MarkdownWriter.docNodeToMarkdown( + member, member.tsdocComment.returnsBlock.content ) } @@ -254,7 +261,7 @@ async function addDocComment(result: Result, member: ApiItem) { result.markdown += `\`${block.parameterName}\`\n\n` result.markdown += `\n` result.markdown += `\n\n` - result.markdown += await MarkdownWriter.docNodeToMarkdown(block.content) + result.markdown += await MarkdownWriter.docNodeToMarkdown(member, block.content) result.markdown += `\n\n\n` result.markdown += `\n` } @@ -347,7 +354,15 @@ function addReferences(result: Result, member: ApiItem) { member.excerptTokens.forEach((token) => { if (token.kind !== 'Reference') return - const url = `/gen/${getPath(token as { canonicalReference: ApiItem['canonicalReference'] })}` + const apiItemResult = assertExists(member.getAssociatedModel()).resolveDeclarationReference( + assertExists(token.canonicalReference), + member + ) + if (apiItemResult.errorMessage) { + return + } + const apiItem = assertExists(apiItemResult.resolvedApiItem) + const url = `/gen/${getPath(apiItem)}` references.add(`[${token.text}](${url})`) })