Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/callback-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-simplikit': minor
---

Add `useCallbackOnce`, which runs a callback only once until its `deps` change and has the same signature and behavior as `useCallbackOncePerRender`. `useCallbackOncePerRender` is now deprecated in favor of `useCallbackOnce` and still works as an alias of it. The second example now lists `userId` in the effect's dependencies, because the returned function keeps the same reference and an effect keyed only on it does not run again when `deps` change.
38 changes: 38 additions & 0 deletions .scripts/commands/generateDocs/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,4 +677,42 @@ export function useNoReturnValue() {}`

expect(document).toContain('\nThis function does not return anything.');
});

it('refuses an export whose JSDoc is @deprecated, because it has no page', async () => {
await expect(
render(
'useOld',
`/**
* @description
* \`useOld\` is the name \`useNew\` had before.
*
* @deprecated Use \`useNew\` instead.
*/
export const useOld = useNew;`
)
).rejects.toThrow('useOld is @deprecated, and a deprecated export has no documentation page');
});

it('still renders a page when only an earlier comment, such as an options type, is @deprecated', async () => {
const document = await render(
'useCurrent',
`type Options = {
/** @deprecated Use \`delay\` instead. */
wait?: number;
};

/**
* @description
* \`useCurrent\` does something.
*
* @returns {void}
*
* @example
* useCurrent();
*/
export function useCurrent(options: Options) {}`
);

expect(document.startsWith('# useCurrent\n\n`useCurrent` does something.\n')).toBe(true);
});
});
115 changes: 58 additions & 57 deletions .scripts/commands/generateDocs/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { parse, Spec as OriginSpec, tokenizers } from 'comment-parser';
import { parse, Spec as OriginSpec } from 'comment-parser';
import glob from 'fast-glob';
import * as fs from 'fs/promises';
import { Listr } from 'listr2';
import path from 'path';
import * as prettier from 'prettier';

import { getRootPath } from '../../utils/getRootPath.ts';
import { parseOptions, readDeprecation, trimBlock } from '../../utils/jsdoc.ts';
import { generateSkill } from '../generateSkill/index.ts';

type Spec = Pick<OriginSpec, 'type' | 'name' | 'description' | 'optional' | 'default'>;
Expand All @@ -23,10 +24,17 @@ const prettierConfig: prettier.Options = {
*
* `verifyDocs.ts` compares a committed page against this, so the formatting applied here has to be
* the same formatting the page is written with — hence prettier runs inside, not at the call site.
*
* Throws for an export whose JSDoc is `@deprecated`: it has no page, and the page of the export
* that replaces it says what it was renamed from.
*/
export async function renderEnglishDoc(name: string, sourceFilePath: string): Promise<string> {
const documentPath = `${path.dirname(sourceFilePath)}/${name}.md`;
const docSource = await jsdocToMd(name, parseJSDoc(await fs.readFile(sourceFilePath, 'utf-8')));
const source = await fs.readFile(sourceFilePath, 'utf-8');

assertHasPage(name, source);

const docSource = await jsdocToMd(name, parseJSDoc(source));

return prettier.format(docSource, {
...(await prettier.resolveConfig(documentPath)),
Expand All @@ -36,59 +44,64 @@ export async function renderEnglishDoc(name: string, sourceFilePath: string): Pr

export async function generateDocs(names: string[]) {
const tasks = new Listr([], { concurrent: 10 });
const targets = names.map(name => [name, glob.sync(`**/${name}.ts*`, { cwd: getRootPath() })[0]]);

// Checked before any task runs: the tasks below collect their errors instead of exiting, and a
// deprecated name has to fail the command without writing anything.
for (const [name, sourceFilePath] of targets) {
if (sourceFilePath !== undefined) {
assertHasPage(name, await fs.readFile(sourceFilePath, 'utf-8'));
}
}

names
.map(name => [name, glob.sync(`**/${name}.ts*`, { cwd: getRootPath() })[0]])
.forEach(([name, sourceFilePath]) => {
const subCtx: { document?: string } = {};
tasks.add([
{
title: `Generate documents: ${sourceFilePath}`,
task: async (_, task) =>
task.newListr<{ document?: string }>(
[
{
title: `Convert JSDoc to markdown`,
task: async ctx => {
ctx.document = await renderEnglishDoc(name, sourceFilePath);
},
targets.forEach(([name, sourceFilePath]) => {
const subCtx: { document?: string } = {};
tasks.add([
{
title: `Generate documents: ${sourceFilePath}`,
task: async (_, task) =>
task.newListr<{ document?: string }>(
[
{
title: `Convert JSDoc to markdown`,
task: async ctx => {
ctx.document = await renderEnglishDoc(name, sourceFilePath);
},
{
title: `Write English document`,
task: async ctx => {
const { document } = ctx;

if (document != null) {
// Written already formatted: `.prettierignore`'s `src/hooks/**/*.md` is anchored to the
// repo root and never reaches packages/, so prettier (yarn fix, autofix.ci) reformats
// these pages later. `generateSkill()` below copies them, and an unformatted copy
// would drift from the page the moment prettier runs.
await fs.writeFile(`${path.dirname(sourceFilePath)}/${name}.md`, document);
}
},
},
{
title: `Write English document`,
task: async ctx => {
const { document } = ctx;

if (document != null) {
// Written already formatted: `.prettierignore`'s `src/hooks/**/*.md` is anchored to the
// repo root and never reaches packages/, so prettier (yarn fix, autofix.ci) reformats
// these pages later. `generateSkill()` below copies them, and an unformatted copy
// would drift from the page the moment prettier runs.
await fs.writeFile(`${path.dirname(sourceFilePath)}/${name}.md`, document);
}
},
],
{ concurrent: false, ctx: subCtx, exitOnError: false }
),
},
]);
});
},
],
{ concurrent: false, ctx: subCtx, exitOnError: false }
),
},
]);
});

await tasks.run();

// The skill catalog is rendered from the English pages written above, so it is refreshed in the same run.
await generateSkill();
}

// These tags carry no name, but the stock name tokenizer still takes the description's first word
// as one ("An object…" became "object…"). Skipping it for them keeps the sentence whole.
const NAMELESS_TAGS = new Set(['returns', 'remarks', 'description']);
const skipNameForNamelessTags = (spec: OriginSpec) => (NAMELESS_TAGS.has(spec.tag) ? spec : tokenizers.name()(spec));

const parseOptions = (spacing: 'compact' | 'preserve') => ({
spacing,
tokenizers: [tokenizers.tag(), tokenizers.type(spacing), skipNameForNamelessTags, tokenizers.description(spacing)],
});
function assertHasPage(name: string, source: string) {
if (readDeprecation(source) != null) {
throw new Error(
`${name} is @deprecated, and a deprecated export has no documentation page — document the rename on its replacement's page instead`
);
}
}

function parseJSDoc(source: string) {
const parsedComments = parse(source, parseOptions('compact'));
Expand Down Expand Up @@ -284,18 +297,6 @@ ${remarks.length === 0 ? '' : `\n## Notes\n\n${remarks}\n`}`;

const LIST_ITEM = /^[-*]\s/;

/**
* `@description` and `@remarks` are Markdown and go to the page as written: a line break inside a
* paragraph renders as a space, and fences, numbered and nested lists keep their meaning.
*/
function trimBlock(text: string) {
return text
.split('\n')
.map(line => line.trimEnd())
.join('\n')
.trim();
}

/**
* The `@returns` intro lands in an HTML attribute where a line break becomes `<br />`, so the
* source's wrapping is joined back into one line; a blank line still separates paragraphs.
Expand Down
78 changes: 76 additions & 2 deletions .scripts/commands/generateSkill/catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { describe, it } from 'vitest';

import { extractDescription, getCategory, renderSkill } from './catalog.ts';
import { extractDescription, getCategory, readCatalogNames, readDeprecatedNames, renderSkill } from './catalog.ts';

describe('getCategory', () => {
it('derives the category from the export source path', () => {
Expand Down Expand Up @@ -60,6 +60,7 @@ describe('renderSkill', () => {
{ name: 'isIOS', category: 'utils', description: 'Detects iOS.' },
{ name: 'useToggle', category: 'hooks', description: 'Toggles a | boolean.' },
],
deprecatedEntries: [],
});

assert.equal(
Expand All @@ -80,12 +81,85 @@ describe('renderSkill', () => {
| --- | --- |
| [\`isIOS\`](references/isIOS.md) | Detects iOS. |

## Learn more
`
);
});

it('lists a deprecated entry under Deprecated as an unlinked row with its notice on one line', () => {
const rendered = renderSkill({
template: '# Skill\n\n## Catalog\n\n<!-- CATALOG -->\n\n## Learn more\n',
entries: [{ name: 'useNew', category: 'hooks', description: 'Does it.' }],
deprecatedEntries: [{ name: 'useOld', notice: 'Use `useNew` | not this.\nIt goes away\nlater.' }],
});

assert.equal(
rendered,
`# Skill

## Catalog

### hooks

| Name | Description |
| --- | --- |
| [\`useNew\`](references/useNew.md) | Does it. |

### Deprecated

These still work but are kept only for backward compatibility. Do not use them in new code; each row names the replacement.

| Name | Notice |
| --- | --- |
| \`useOld\` | Use \`useNew\` \\| not this. It goes away later. |

## Learn more
`
);
});

it('rejects a template without the placeholder', () => {
assert.throws(() => renderSkill({ template: '# Skill\n', entries: [] }), /<!-- CATALOG -->/);
assert.throws(() => renderSkill({ template: '# Skill\n', entries: [], deprecatedEntries: [] }), /<!-- CATALOG -->/);
});
});

describe('readCatalogNames', () => {
it('reads the linked rows only, so a deprecated export is not counted as documented', () => {
const skill = renderSkill({
template: '# Skill\n\n| Need | Use |\n| --- | --- |\n| Toggle | `useToggle` |\n\n<!-- CATALOG -->\n',
entries: [
{ name: 'isIOS', category: 'utils', description: 'Detects iOS.' },
{ name: 'useNew', category: 'hooks', description: 'Does it.' },
],
deprecatedEntries: [{ name: 'useOld', notice: 'Use `useNew` instead.' }],
});

assert.deepEqual(readCatalogNames(skill), ['useNew', 'isIOS']);
});
});

describe('readDeprecatedNames', () => {
it('reads only the names in the Deprecated table of the rendered catalog', () => {
const skill = renderSkill({
template:
'# Skill\n\n| Need | Use |\n| --- | --- |\n| `useBefore` | Toggle |\n\n<!-- CATALOG -->\n\n## Learn more\n\n| Name | Link |\n| --- | --- |\n| `useAfter` | Docs |\n',
entries: [
{ name: 'isIOS', category: 'utils', description: 'Detects iOS.' },
{ name: 'useNew', category: 'hooks', description: 'Does it.' },
],
deprecatedEntries: [{ name: 'useOld', notice: 'Use `useNew` instead.' }],
});

assert.deepEqual(readDeprecatedNames(skill), ['useOld']);
});

it('reports no deprecated names when the catalog has no Deprecated table', () => {
const skill = renderSkill({
template: '<!-- CATALOG -->\n',
entries: [{ name: 'useNew', category: 'hooks', description: 'Does it.' }],
deprecatedEntries: [],
});

assert.deepEqual(readDeprecatedNames(skill), []);
});
});
Loading
Loading