Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion docs/.vitepress/components/api-docs/method-parameters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const { parameters } = defineProps<{ parameters: ApiDocsMethodParameter[] }>();
>
{{ name }}
</td>
<td>{{ type }}</td>
<td class="type" v-html="type"></td>
<td>
<code v-if="def">{{ def }}</code>
</td>
Expand All @@ -44,4 +44,48 @@ const { parameters } = defineProps<{ parameters: ApiDocsMethodParameter[] }>();
td.deprecated {
text-decoration: line-through;
}

.type {
font-family: var(--vp-font-family-mono);
Comment thread
Shinigami92 marked this conversation as resolved.
Comment thread
Shinigami92 marked this conversation as resolved.
}

/* `:deep` reaches the popover markup injected into the type column via v-html. */
.type :deep(.shadow-type-value) {
font-family: inherit;
font-size: inherit;
color: var(--vp-c-brand-1);
text-decoration-line: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
cursor: help;
}

/* `display: none` overwrites VitePress' `.vp-doc` default of `inline`. */
.type :deep(.shadow-type-popover) {
display: none;
max-width: 20rem;
margin: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-elv);
color: var(--vp-c-text-1);
font-family: var(--vp-font-family-base);
font-size: 0.875rem;
line-height: 1.5;
box-shadow: var(--vp-shadow-3);
}

.type :deep(.shadow-type-popover:popover-open) {
display: block;
}

/* Position next to the trigger where anchor positioning is supported. */
@supports (anchor-name: --x) {
.type :deep(.shadow-type-popover) {
position: absolute;
position-area: bottom span-right;
margin-top: 4px;
}
}
</style>
2 changes: 1 addition & 1 deletion docs/.vitepress/components/api-docs/method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface ApiDocsMethod {

export interface ApiDocsMethodParameter {
readonly name: string;
readonly type: string | undefined;
readonly type: string | undefined; // HTML
readonly default: string | undefined;
readonly description: string; // HTML
}
73 changes: 72 additions & 1 deletion scripts/apidocs/output/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { FILE_PATH_API_DOCS } from '../../shared/paths';
import { toRefreshableCode } from '../../shared/refreshable-code';
import type { RawApiDocsPage } from '../processing/class';
import type { RawApiDocsMethod } from '../processing/method';
import type { RawApiDocsParameter } from '../processing/parameter';
import type { RawApiDocsType } from '../processing/type';
import { required } from '../utils/value-checks';
import { SCRIPT_COMMAND } from './constants';

Expand Down Expand Up @@ -216,7 +218,7 @@ async function toMethodData(method: RawApiDocsMethod): Promise<ApiDocsMethod> {
parameters: await Promise.all(
parameters.map(async (param) => ({
...param,
type: param.type.text,
type: renderParameterType(param, name),
description: await mdToHtml(param.description),
}))
),
Expand Down Expand Up @@ -247,3 +249,72 @@ export async function toRefreshFunction(
const exampleCode = examples.join('\n');
return await toRefreshableCode(name, exampleCode, registryHints);
}

/**
* Renders the type of a parameter to HTML. Type values that carry a description
* (shadow type values, see {@link getShadowTypeDescriptions}) become a popover
* trigger revealing that description, so the per-value documentation is kept
* without cluttering the description column.
*
* @param parameter The parameter whose type to render.
* @param methodName The name of the method, used to build unique popover ids.
*/
function renderParameterType(
parameter: RawApiDocsParameter,
methodName: string
): string {
const { type, name } = parameter;
const idPrefix = toIdSlug(`${methodName}-${name}`);

const members = type.type === 'union' ? type.types : [type];
if (members.every((member) => !member.description)) {
return escapeHtml(type.text);
}

return members
.map((member) => renderTypeMember(member, idPrefix))
.join(' | ');
}

/**
* Renders a single type (union member) to HTML, as a popover trigger when it
* has a description, or as plain escaped text otherwise.
*
* @param member The type to render.
* @param idPrefix The prefix for the popover id, unique per parameter.
*/
function renderTypeMember(member: RawApiDocsType, idPrefix: string): string {
const text = escapeHtml(member.text);
if (!member.description) {
return text;
}

const id = `${idPrefix}-${toIdSlug(member.text)}`;
return (
`<button type="button" class="shadow-type-value" popovertarget="${id}" style="anchor-name:--${id}">${text}</button>` +
`<div id="${id}" popover class="shadow-type-popover" style="position-anchor:--${id}">${escapeHtml(member.description)}</div>`
);
}

/**
* Escapes a string for safe inclusion in HTML text/attribute content.
*
* @param value The string to escape.
*/
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

/**
* Turns an arbitrary string into a slug safe for use in html ids and css idents.
*
* @param value The string to slugify.
*/
function toIdSlug(value: string): string {
return value.replaceAll(/[^a-zA-Z0-9]+/g, '-').replaceAll(/^-+|-+$/g, '');
}
Comment thread
Shinigami92 marked this conversation as resolved.
26 changes: 17 additions & 9 deletions scripts/apidocs/processing/parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import {
} from './jsdocs';
import type { RawApiDocsType } from './type';
import {
attachShadowTypeDescriptions,
getNameSuffix,
getShadowTypeDescriptions,
getTypeText,
isOptionsLikeType,
isRangeType,
Expand Down Expand Up @@ -132,7 +134,7 @@ type ParameterLikeDeclaration = Pick<
ParameterDeclaration,
'getName' | 'getType'
> &
Partial<Pick<ParameterDeclaration, 'getInitializer'>>;
Partial<Pick<ParameterDeclaration, 'getInitializer' | 'getTypeNode'>>;

function processSimpleParameter(
parameter: ParameterLikeDeclaration,
Expand All @@ -156,10 +158,13 @@ function processSimpleParameter(

return {
name: `${name}${getNameSuffix(type)}`,
type: getTypeText(type, {
abbreviate: true,
stripUndefined: true,
}),
type: attachShadowTypeDescriptions(
getTypeText(type, {
abbreviate: true,
stripUndefined: true,
}),
getShadowTypeDescriptions(parameter.getTypeNode?.())
),
default: signatureDefault ?? summaryDefault,
description: stripSummaryDefault(description),
};
Expand Down Expand Up @@ -246,10 +251,13 @@ function processComplexParameterProperty(
return [
{
name: `${name}.${parameter.getName()}${getNameSuffix(propertyType)}`,
type: getTypeText(propertyType, {
abbreviate: false,
stripUndefined: true,
}),
type: attachShadowTypeDescriptions(
getTypeText(propertyType, {
abbreviate: false,
stripUndefined: true,
}),
getShadowTypeDescriptions(declaration.getTypeNode())
),
default: getDefault(jsdocs) ?? extractSummaryDefault(description),
description:
stripSummaryDefault(description) +
Expand Down
108 changes: 106 additions & 2 deletions scripts/apidocs/processing/type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TypeFlags, type Type } from 'ts-morph';
import { Node, SyntaxKind, TypeFlags, type Type } from 'ts-morph';
import { atLeastOneAndAllRequired, required } from '../utils/value-checks';

export type RawApiDocsType =
Expand All @@ -11,6 +11,12 @@ export type RawApiDocsType =
interface RawApiDocsBaseType {
type: string;
text: string;
/**
* The raw (non-HTML) description of this type, e.g. the JSDoc of the enum
* member backing a shadow type value. To be rendered as hoverable/
* clickable popover text in the docs.
*/
description?: string;
Comment thread
Shinigami92 marked this conversation as resolved.
}

export interface RawApiDocsSimpleType extends RawApiDocsBaseType {
Expand Down Expand Up @@ -173,7 +179,7 @@ export function getTypeText(
}

if (abbreviate && isOptionsLikeType(type)) {
return newSimpleType('{ ... }');
return newSimpleType('{ }');
}

if (resolveAliases && type.isTypeParameter()) {
Expand Down Expand Up @@ -212,6 +218,104 @@ export function isRangeType(type: Type): boolean {
return symbol?.getName() === 'NumberRange';
}

/**
* Resolves the per-value descriptions backing a shadow type.
*
* A shadow type is a string-literal alias (e.g. `LengthStrategyType`) whose
* values come from an enum (e.g. `` LengthStrategyType = `${LengthStrategy}` ``).
* TypeScript resolves the template literal eagerly, so the enum member JSDoc is
* no longer reachable via the resolved `Type`. It is however still reachable via
* the syntactic type node, which is what this function walks:
*
* ```txt
* TypeReference "LengthStrategyType"
* -> TypeAliasDeclaration (following the import)
* -> `${LengthStrategy}` -> EnumDeclaration
* -> member value + JSDoc
* ```
*
* @param typeNode The syntactic type node of the parameter/property, if any.
*
* @returns A map from enum member value (e.g. `'fail'`) to its JSDoc description.
*/
export function getShadowTypeDescriptions(
typeNode: Node | undefined
): Map<string, string> {
const descriptions = new Map<string, string>();
if (!Node.isTypeReference(typeNode)) {
return descriptions;
}

const aliasSymbol = resolveSymbol(typeNode.getTypeName().getSymbol());
const aliasDeclaration = aliasSymbol?.getDeclarations()?.[0];
if (!aliasDeclaration || !Node.isTypeAliasDeclaration(aliasDeclaration)) {
return descriptions;
}

const aliasTypeNode = aliasDeclaration.getTypeNodeOrThrow();
const enumReferences = [
...(Node.isTypeReference(aliasTypeNode) ? [aliasTypeNode] : []),
...aliasTypeNode.getDescendantsOfKind(SyntaxKind.TypeReference),
];

for (const enumReference of enumReferences) {
const enumSymbol = resolveSymbol(enumReference.getTypeName().getSymbol());
const enumDeclaration = enumSymbol?.getDeclarations()?.[0];
if (!enumDeclaration || !Node.isEnumDeclaration(enumDeclaration)) {
continue;
}

for (const member of enumDeclaration.getMembers()) {
const value = member.getValue();
const description = member.getJsDocs().at(-1)?.getDescription().trim();
if (typeof value === 'string' && description) {
descriptions.set(value, description);
}
}
}

return descriptions;
}

/**
* Attaches the given per-value descriptions to the matching members of a type.
*
* @param type The type to enrich (mutated in place).
* @param descriptions The per-value descriptions, see {@link getShadowTypeDescriptions}.
*
* @returns The enriched type, for convenience.
*/
export function attachShadowTypeDescriptions(
Comment thread
Shinigami92 marked this conversation as resolved.
type: RawApiDocsType,
descriptions: Map<string, string>
): RawApiDocsType {
if (descriptions.size === 0) {
return type;
}

const members = type.type === 'union' ? type.types : [type];
for (const member of members) {
const value = member.text.replace(/^'(.*)'$/, '$1');
const description = descriptions.get(value);
if (description) {
member.description = description;
}
}

return type;
}

/**
* Follows import specifiers to the symbol of the actual declaration.
*
* @param symbol The symbol to resolve.
*/
function resolveSymbol<T extends { getAliasedSymbol(): T | undefined }>(
symbol: T | undefined
): T | undefined {
return symbol?.getAliasedSymbol() ?? symbol;
}

function newSimpleType(name: string): RawApiDocsSimpleType {
required(name, 'name');
return { type: 'simple', text: name };
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,10 @@ export {
generateMersenne32Randomizer,
generateMersenne53Randomizer,
} from './utils/mersenne';
export type { Casing, NumberOrRange, NumberRange } from './utils/types';
export { LengthStrategy } from './utils/types';
export type {
Casing,
LengthStrategyType,
NumberOrRange,
NumberRange,
} from './utils/types';
20 changes: 2 additions & 18 deletions src/modules/lorem/module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ModuleBase } from '../../internal/module-base';
import type { NumberOrRange } from '../../utils/types';
import type { LengthStrategyType, NumberOrRange } from '../../utils/types';
import { filterWordListByLength } from '../word/filter-word-list-by-length';

/**
Expand All @@ -21,14 +21,6 @@ export class LoremModule extends ModuleBase {
* @param options.length The expected length of the word.
* @param options.strategy The strategy to apply when no words with a matching length are found.
*
* Available error handling strategies:
*
* - `fail`: Throws an error if no words with the given length are found.
* - `shortest`: Returns any of the shortest words.
* - `closest`: Returns any of the words closest to the given length.
* - `longest`: Returns any of the longest words.
* - `any-length`: Returns a word with any length.
*
* Defaults to `'any-length'`.
*
* @example
Expand All @@ -52,17 +44,9 @@ export class LoremModule extends ModuleBase {
/**
* The strategy to apply when no words with a matching length are found.
*
* Available error handling strategies:
*
* - `fail`: Throws an error if no words with the given length are found.
* - `shortest`: Returns any of the shortest words.
* - `closest`: Returns any of the words closest to the given length.
* - `longest`: Returns any of the longest words.
* - `any-length`: Returns a word with any length.
*
* @default 'any-length'
*/
strategy?: 'fail' | 'closest' | 'shortest' | 'longest' | 'any-length';
strategy?: LengthStrategyType;
} = {}
): string {
if (typeof options === 'number') {
Expand Down
Loading