Skip to content

Commit 8065e59

Browse files
authored
Script to keep README in sync with JSDoc (#1034)
* Add script to inject JSDoc into README.md * yarn readme:update * emit declaration files to types/ * jsdoc-to-readme - allow specifiying the title prefix * use full urls for readme links * JSDoc for scales * jsdoc legend * fix link * jsdoc-to-readme - indicate in title when a param uses dot-dot-dot notation * jsdoc marks * fix link * jsdoc area * jsdoc areaX * jsdoc areaY * jsdoc arrow * jsdoc barX * jsdoc barY * jsdoc boxX, boxY * jsdoc cell, cellX, cellY * jsdoc delaunayLink, delaunayMesh * jsdoc hull, voronoi, voronoiMesh * jsdoc density * jsdoc dot, dotX, dotY * jsdoc circle, hexagon * jsdoc hexgrid * jsdoc image * jsdoc linearRegressionX, linearRegressionY * jsdoc line, lineX, lineY * jsdoc link * jsdoc rect, rectX, rectY * jsdoc ruleX, ruleY * jsdoc text, textX, textY * jsdoc tickX, tickY * jsdoc vector, vectorX, vectorY * jsdoc frame * jsdoc sort, shuffle, reverse, filter * jsdoc bin, binX, binY * jsdoc group, groupX, groupY, groupZ * jsdoc map, mapX, mapY * jsdoc normalize, normalizeX, normalizeY * jsdoc select* * jsdoc stack* * jsdoc tree, cluster, treeNode, treeLink * jsdoc dodgeX, dodgeY * jsdoc hexbin * jsdoc initializer * jsdoc format* * fix mapX, mapY * revert d.ts related changes for another PR * Hard-wrap JSDoc comments * run readme:check as part of yarn test * jsdoc scale - move content from JSDoc to the README * valueof arrayType->type * use map argument name in map functions * use original README as source of truth for argument names throughout * jsdoc-to-readme - make links to other parts of README relative * fix README * fix console.log * yarn readme:update
1 parent 549f078 commit 8065e59

40 files changed

+3061
-396
lines changed

.github/workflows/node.js.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ jobs:
2727
echo ::add-matcher::.github/eslint.json
2828
yarn run eslint . --format=compact
2929
- run: yarn test
30+
- run: yarn readme:check

README.md

Lines changed: 1076 additions & 199 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
"src/**/*.js"
2727
],
2828
"scripts": {
29-
"test": "yarn test:typecheck && yarn test:lint && yarn test:mocha",
29+
"test": "yarn test:typecheck && yarn test:lint && yarn readme:check && yarn test:mocha",
3030
"test:mocha": "mkdir -p test/output && mocha --conditions=mocha 'test/**/*-test.*' 'test/plot.js'",
3131
"test:lint": "eslint src test",
3232
"test:typecheck": "tsc --noEmit",
33+
"readme:check": "tsx scripts/jsdoc-to-readme.ts --check",
34+
"readme:update": "tsx scripts/jsdoc-to-readme.ts",
3335
"prepublishOnly": "rm -rf dist && rollup -c && tsc",
3436
"postpublish": "git push && git push --tags",
3537
"dev": "vite"
@@ -63,7 +65,9 @@
6365
"prettier": "^2.7.1",
6466
"rollup": "2",
6567
"rollup-plugin-terser": "7",
68+
"ts-morph": "^15.1.0",
6669
"tslib": "^2.4.0",
70+
"tsx": "^3.8.0",
6771
"typescript": "^4.6.4",
6872
"typescript-module-alias": "^1.0.2",
6973
"vite": "3"

scripts/jsdoc-to-readme.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {readFileSync, writeFileSync} from "fs";
2+
import type {ExportedDeclarations, FunctionDeclaration} from "ts-morph";
3+
import {Project} from "ts-morph";
4+
5+
/**
6+
* This script will find html comments in the README of the below shape and
7+
* inject the corresponding JSDoc from that exported symbol.
8+
*
9+
* <!-- jsdoc column -->
10+
* <!-- jsdocEnd -->
11+
*/
12+
13+
const readmePath = "README.md";
14+
let indexPath = "src/index.js";
15+
const project = new Project({tsConfigFilePath: "tsconfig.json"});
16+
17+
let index = project.getSourceFile(indexPath);
18+
if (!index) {
19+
indexPath = "src/index.ts";
20+
index = project.getSourceFile(indexPath);
21+
if (!index) throw new Error(`index file not found in src/`);
22+
}
23+
24+
const exported = index.getExportedDeclarations();
25+
function getByApiName(name: string) {
26+
for (const [exportedName, declarations] of exported) {
27+
if (name === exportedName) {
28+
return declarations[0];
29+
}
30+
}
31+
}
32+
33+
function injectJsDoc(readme: string) {
34+
const lines = readme.split("\n");
35+
const output: string[] = [];
36+
let insideReplacement = false;
37+
for (let i = 0; i < lines.length; i++) {
38+
const line = lines[i];
39+
let replacement = "";
40+
let isReplacementDelimiter = false;
41+
if (line.startsWith("<!-- jsdocEnd")) {
42+
if (!insideReplacement) throw new Error(`Unexpected jsdocEnd on line ${i}.`);
43+
isReplacementDelimiter = true;
44+
insideReplacement = false;
45+
} else if (line.startsWith("<!-- jsdoc ")) {
46+
isReplacementDelimiter = true;
47+
insideReplacement = true;
48+
const parts = [""];
49+
const match = line.match(/jsdoc\s+(#+)?\s?(.+)\s/);
50+
if (!match || match.length < 2) throw new Error(`Malformed jsdoc comment in README.md on line ${i}.`);
51+
const [, prefix, name] = match;
52+
const declaration = getByApiName(name);
53+
if (!declaration) throw new Error(`${name} is not exported by src/index`);
54+
parts.push(getJsDocs(name, declaration, prefix));
55+
parts.push("");
56+
replacement = parts.join("\n");
57+
}
58+
if (!insideReplacement || isReplacementDelimiter) output.push(line);
59+
if (replacement) output.push(replacement);
60+
}
61+
return output.join("\n");
62+
}
63+
64+
function getJsDocs(name: string, declaration: ExportedDeclarations, prefix = "####") {
65+
if ("getParameters" in declaration) {
66+
return getJsDocsForFunction(name, declaration, prefix);
67+
}
68+
if ("getJsDocs" in declaration) {
69+
return `${prefix} Plot.${name}\n${declaration
70+
.getJsDocs()
71+
.map((doc) => makeRelativeUrls(doc.getDescription()))
72+
.join("\n\n")}`;
73+
}
74+
return `JSDoc extraction for ${declaration.getKindName()} not yet implemented.`;
75+
}
76+
77+
function getJsDocsForFunction(name: string, declaration: FunctionDeclaration, prefix = "####") {
78+
const parameters = declaration.getParameters();
79+
const title = `${prefix} Plot.${name}(${parameters
80+
.map((param) => `${param.getDotDotDotToken() ? "..." : ""}*${param.getName()}*`)
81+
.join(", ")})`;
82+
const parts = [title];
83+
const docs = declaration.getJsDocs();
84+
if (docs.length) {
85+
parts.push(docs.map((doc) => makeRelativeUrls(doc.getDescription())).join("\n\n"));
86+
return parts.join("\n");
87+
}
88+
// If we didn't find docs on the implementation, it's probably on one of the
89+
// overloads.
90+
const overloads = declaration.getOverloads();
91+
for (const overload of overloads) {
92+
const docs = overload.getJsDocs();
93+
if (!docs.length) continue;
94+
parts.push(docs.map((doc) => makeRelativeUrls(doc.getDescription())).join("\n\n"));
95+
return parts.join("\n");
96+
}
97+
98+
return "No JSDocs found.";
99+
}
100+
101+
function makeRelativeUrls(description: string) {
102+
return description.replace(new RegExp("https://github.com/observablehq/plot/blob/main/README.md#", "g"), "#");
103+
}
104+
105+
const check = process.argv[process.argv.length - 1] === "--check";
106+
const original = readFileSync(readmePath, {encoding: "utf-8"});
107+
const output = injectJsDoc(original);
108+
109+
if (original !== output) {
110+
if (check) {
111+
console.log("README.md is out of sync. Please run `yarn readme:update`");
112+
process.exit(1);
113+
} else {
114+
writeFileSync(readmePath, output);
115+
console.log("README.md has been updated based on the JSDoc annotations.");
116+
}
117+
} else {
118+
console.log("README.md requires no changes.");
119+
}

src/format.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,54 @@ export function formatNumber(locale = "en-US"): (value: any) => string | undefin
2020
return (i: any) => (i != null && !isNaN(i) ? format.format(i) : undefined);
2121
}
2222

23+
/**
24+
* ```js
25+
* Plot.formatMonth("es-MX", "long")(0) // "enero"
26+
* ```
27+
*
28+
* Returns a function that formats a given month number (from 0 = January to 11
29+
* = December) according to the specified *locale* and *format*. The *locale* is
30+
* a [BCP 47 language tag](https://tools.ietf.org/html/bcp47) and defaults to
31+
* U.S. English. The *format* is a [month
32+
* format](https://tc39.es/ecma402/#datetimeformat-objects): either *2-digit*,
33+
* *numeric*, *narrow*, *short*, *long*; if not specified, it defaults to
34+
* *short*.
35+
*/
2336
export function formatMonth(
2437
locale = "en-US",
25-
month: "numeric" | "2-digit" | "long" | "short" | "narrow" | undefined = "short"
38+
format: "numeric" | "2-digit" | "long" | "short" | "narrow" | undefined = "short"
2639
) {
27-
const format = monthFormat(locale, month);
40+
const fmt = monthFormat(locale, format);
2841
return (i: Date | number | null | undefined) =>
29-
i != null && !isNaN((i = +new Date(Date.UTC(2000, +i)))) ? format.format(i) : undefined;
42+
i != null && !isNaN((i = +new Date(Date.UTC(2000, +i)))) ? fmt.format(i) : undefined;
3043
}
3144

32-
export function formatWeekday(locale = "en-US", weekday: "long" | "short" | "narrow" | undefined = "short") {
33-
const format = weekdayFormat(locale, weekday);
45+
/**
46+
* ```js
47+
* Plot.formatWeekday("es-MX", "long")(0) // "domingo"
48+
* ```
49+
*
50+
* Returns a function that formats a given week day number (from 0 = Sunday to 6
51+
* = Saturday) according to the specified *locale* and *format*. The *locale* is
52+
* a [BCP 47 language tag](https://tools.ietf.org/html/bcp47) and defaults to
53+
* U.S. English. The *format* is a [weekday
54+
* format](https://tc39.es/ecma402/#datetimeformat-objects): either *narrow*,
55+
* *short*, or *long*; if not specified, it defaults to *short*.
56+
*/
57+
export function formatWeekday(locale = "en-US", format: "long" | "short" | "narrow" | undefined = "short") {
58+
const fmt = weekdayFormat(locale, format);
3459
return (i: Date | number | null | undefined) =>
35-
i != null && !isNaN((i = +new Date(Date.UTC(2001, 0, +i)))) ? format.format(i) : undefined;
60+
i != null && !isNaN((i = +new Date(Date.UTC(2001, 0, +i)))) ? fmt.format(i) : undefined;
3661
}
3762

63+
/**
64+
* ```js
65+
* Plot.formatIsoDate(new Date("2020-01-01T00:00.000Z")) // "2020-01-01"
66+
* ```
67+
*
68+
* Given a *date*, returns the shortest equivalent ISO 8601 UTC string. If the
69+
* given *date* is not valid, returns `"Invalid Date"`.
70+
*/
3871
export function formatIsoDate(date: Date): string {
3972
return isoFormat(date, "Invalid Date");
4073
}

src/legends.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,30 @@ const legendRegistry = new Map([
1111
["opacity", legendOpacity]
1212
]);
1313

14+
/**
15+
* Returns a standalone legend for the scale defined by the given *options*
16+
* object. The *options* object must define at least one scale; see [Scale
17+
* options](https://github.com/observablehq/plot/blob/main/README.md#scale-options)
18+
* for how to define a scale. For example, here is a ramp legend of a linear
19+
* color scale with the default domain of [0, 1] and default scheme *turbo*:
20+
*
21+
* ```js
22+
* Plot.legend({color: {type: "linear"}})
23+
* ```
24+
*
25+
* The *options* object may also include any additional legend options described
26+
* in the previous section. For example, to make the above legend slightly
27+
* wider:
28+
*
29+
* ```js
30+
* Plot.legend({
31+
* width: 320,
32+
* color: {
33+
* type: "linear"
34+
* }
35+
* })
36+
* ```
37+
*/
1438
export function legend(options = {}) {
1539
for (const [key, value] of legendRegistry) {
1640
const scale = options[key];

src/marks/area.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,93 @@ export class Area extends Mark {
7171
}
7272
}
7373

74+
/**
75+
* ```js
76+
* Plot.area(aapl, {x1: "Date", y1: 0, y2: "Close"})
77+
* ```
78+
*
79+
* Returns a new area with the given *data* and *options*. Plot.area is rarely
80+
* used directly; it is only needed when the baseline and topline have neither
81+
* common *x* nor *y* values.
82+
* [Plot.areaY](https://github.com/observablehq/plot/blob/main/README.md#plotareaydata-options)
83+
* is used in the common horizontal orientation where the baseline and topline
84+
* share *x* values, while
85+
* [Plot.areaX](https://github.com/observablehq/plot/blob/main/README.md#plotareaxdata-options)
86+
* is used in the vertical orientation where the baseline and topline share *y*
87+
* values.
88+
*/
7489
export function area(data, options) {
7590
if (options === undefined) return areaY(data, {x: first, y: second});
7691
return new Area(data, options);
7792
}
7893

94+
/**
95+
* ```js
96+
* Plot.areaX(aapl, {y: "Date", x: "Close"})
97+
* ```
98+
*
99+
* Returns a new area with the given *data* and *options*. This constructor is
100+
* used when the baseline and topline share *y* values, as in a time-series area
101+
* chart where time goes up↑. If neither the **x1** nor **x2** option is
102+
* specified, the **x** option may be specified as shorthand to apply an
103+
* implicit [stackX
104+
* transform](https://github.com/observablehq/plot/blob/main/README.md#plotstackxstack-options);
105+
* this is the typical configuration for an area chart with a baseline at *x* =
106+
* 0. If the **x** option is not specified, it defaults to the identity
107+
* function. The **y** option specifies the **y1** channel; and the **y1** and
108+
* **y2** options are ignored.
109+
*
110+
* If the **interval** option is specified, the [binY
111+
* transform](https://github.com/observablehq/plot/blob/main/README.md#bin) is
112+
* implicitly applied to the specified *options*. The reducer of the output *x*
113+
* channel may be specified via the **reduce** option, which defaults to
114+
* *first*. To default to zero instead of showing gaps in data, as when the
115+
* observed value represents a quantity, use the *sum* reducer.
116+
*
117+
* ```js
118+
* Plot.areaX(observations, {y: "date", x: "temperature", interval: d3.utcDay})
119+
* ```
120+
*
121+
* The **interval** option is recommended to “regularize” sampled data; for
122+
* example, if your data represents timestamped temperature measurements and you
123+
* expect one sample per day, use d3.utcDay as the interval.
124+
*/
79125
export function areaX(data, options) {
80126
const {y = indexOf, ...rest} = maybeDenseIntervalY(options);
81127
return new Area(data, maybeStackX(maybeIdentityX({...rest, y1: y, y2: undefined})));
82128
}
83129

130+
/**
131+
* ```js
132+
* Plot.areaY(aapl, {x: "Date", y: "Close"})
133+
* ```
134+
*
135+
* Returns a new area with the given *data* and *options*. This constructor is
136+
* used when the baseline and topline share *x* values, as in a time-series area
137+
* chart where time goes right→. If neither the **y1** nor **y2** option is
138+
* specified, the **y** option may be specified as shorthand to apply an
139+
* implicit [stackY
140+
* transform](https://github.com/observablehq/plot/blob/main/README.md#plotstackystack-options);
141+
* this is the typical configuration for an area chart with a baseline at *y* =
142+
* 0. If the **y** option is not specified, it defaults to the identity
143+
* function. The **x** option specifies the **x1** channel; and the **x1** and
144+
* **x2** options are ignored.
145+
*
146+
* If the **interval** option is specified, the [binX
147+
* transform](https://github.com/observablehq/plot/blob/main/README.md#bin) is
148+
* implicitly applied to the specified *options*. The reducer of the output *y*
149+
* channel may be specified via the **reduce** option, which defaults to
150+
* *first*. To default to zero instead of showing gaps in data, as when the
151+
* observed value represents a quantity, use the *sum* reducer.
152+
*
153+
* ```js
154+
* Plot.areaY(observations, {x: "date", y: "temperature", interval: d3.utcDay)
155+
* ```
156+
*
157+
* The **interval** option is recommended to “regularize” sampled data; for
158+
* example, if your data represents timestamped temperature measurements and you
159+
* expect one sample per day, use d3.utcDay as the interval.
160+
*/
84161
export function areaY(data, options) {
85162
const {x = indexOf, ...rest} = maybeDenseIntervalX(options);
86163
return new Area(data, maybeStackY(maybeIdentityY({...rest, x1: x, x2: undefined})));

src/marks/arrow.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,16 @@ function circleCircleIntersect([ax, ay, ar], [bx, by, br], sign) {
176176
return [ax + (dx * x + dy * y) / d, ay + (dy * x - dx * y) / d];
177177
}
178178

179-
export function arrow(data, {x, x1, x2, y, y1, y2, ...options} = {}) {
179+
/**
180+
* ```js
181+
* Plot.arrow(inequality, {x1: "POP_1980", y1: "R90_10_1980", x2: "POP_2015", y2: "R90_10_2015", bend: true})
182+
* ```
183+
*
184+
* Returns a new arrow with the given *data* and *options*.
185+
*/
186+
export function arrow(data, options = {}) {
187+
let {x, x1, x2, y, y1, y2, ...remainingOptions} = options;
180188
[x1, x2] = maybeSameValue(x, x1, x2);
181189
[y1, y2] = maybeSameValue(y, y1, y2);
182-
return new Arrow(data, {...options, x1, x2, y1, y2});
190+
return new Arrow(data, {...remainingOptions, x1, x2, y1, y2});
183191
}

0 commit comments

Comments
 (0)