Skip to content
Draft
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
14 changes: 12 additions & 2 deletions .claude/skills/plugin-api-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ content, and a checker.
| `npm run plugin-api:generate` | Rewrites generated blocks in the docs, writes the request schema, reports drift |
| `npm run plugin-api:lint` | Checks the docs against the code and the conventions |
| `npm run plugin-api:check` | Both of the above in report-only mode — what CI runs |
| `npm run plugin-api:tester` | Writes the plugin API Tester's `reference-templates.json` from the request examples on the resource pages |
| `npm run plugin-api:tester:check` | Reports whether the tester's copy is stale, without writing |

`markers.mjs` and `inventory.mjs` are shared helpers, not entry points: the first defines a
well-formed generated-block marker, the second is the one way both scripts run the extractor.
`markers.mjs`, `inventory.mjs`, `coverage.mjs` and `markdown.mjs` are shared helpers, not entry
points: the first defines a well-formed generated-block marker, the second is the one way the
scripts run the extractor, the third says which page documents which resource, and the fourth
holds the fenced-code and heading-anchor rules.
The generator consults `markers.mjs` *before* rewriting a page and skips any page whose markers
it cannot prove safe; that file explains what a rewrite would otherwise destroy.

Expand All @@ -42,6 +46,12 @@ baseline; either document it or add it to the baseline deliberately.

**To see whether anything has drifted, without writing:** `npm run plugin-api:check`.

**After changing a request example on a resource page:** `npm run plugin-api:tester`, then commit
the regenerated `DataInteractiveAPITester/resources/reference-templates.json` in
`codap-data-interactives` (base `gh-pages`, which is live as soon as it merges). The tester
scripts assume that repo is cloned beside `codap`. CI cannot check this file, because it lives
in the other repo, so run `npm run plugin-api:tester:check` before a release.

## What is generated and what is not

Generated blocks are delimited in the markdown:
Expand Down
11 changes: 11 additions & 0 deletions v3/doc/plugin-api/resources/adornment.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,17 @@ can display them.
The adornment remains in the graph's store with `isVisible: false`, so a subsequent `get`
succeeds rather than reporting it missing.

**Show it again.** Because the hidden adornment is still in the store, `update` can turn it back
on:

```json
{
"action": "update",
"resource": "component[Heights].adornment",
"values": { "type": "Mean", "isVisible": true }
}
```

## Notifications

Toggling an adornment emits a component notification whose operation is specific to the
Expand Down
9 changes: 9 additions & 0 deletions v3/doc/plugin-api/resources/attribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ Start a drag from inside a plugin, so the user can drop an attribute onto a grap
}
```

Delete an attribute, and with it its value in every case:

```json
{
"action": "delete",
"resource": "dataContext[Mammals].attribute[Notes]"
}
```

## Known limitations

**`delete` sends no notification.** Other plugins are not told that the attribute is gone. v2
Expand Down
10 changes: 10 additions & 0 deletions v3/doc/plugin-api/resources/component.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,16 @@ Bring a component to the front, then rescale it:
}
```

Close a component. A case table, case card, calculator or guide is hidden rather than removed —
see [Known limitations](#known-limitations):

```json
{
"action": "delete",
"resource": "component[Height by Weight]"
}
```

## Known limitations

**Creating or closing a component cannot be undone.** v2 added `create` and `delete` on
Expand Down
91 changes: 91 additions & 0 deletions v3/doc/plugin-api/resources/data-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,88 @@ Create a data context with one collection and two attributes:
}
```

Read it back, after one case has been added. The response describes the structure — collections
and attributes — and has no cases:

```json
{
"action": "get",
"resource": "dataContext[Mammals]"
}
```

```json
{
"success": true,
"values": {
"type": "DG.DataContext",
"document": 1,
"guid": 41,
"id": 41,
"name": "Mammals",
"title": "Mammal measurements",
"collections": [
{
"attrs": [
{
"name": "Species",
"type": "categorical",
"title": "Species",
"cid": "ATTR43",
"_categoryMap": {
"lion": "#ff6800",
"__order": [ "lion" ],
"attribute-color": "#ff6800",
"low-attribute-color": "#f5e9e0",
"high-attribute-color": "#ff6800"
},
"editable": true,
"hidden": false,
"renameable": true,
"deleteable": true,
"guid": 43,
"id": 43
},
{
"name": "Mass",
"type": "numeric",
"title": "Mass",
"cid": "ATTR44",
"_categoryMap": {
"190": "#ff6800",
"__order": [ "190" ],
"attribute-color": "#803e75",
"low-attribute-color": "#eee7ed",
"high-attribute-color": "#803e75"
},
"editable": true,
"hidden": false,
"renameable": true,
"deleteable": true,
"guid": 44,
"id": 44,
"precision": 2
}
],
"guid": 42,
"id": 42,
"name": "Cases",
"title": "Cases",
"type": "DG.Collection"
}
],
"flexibleGroupingChangeFlag": false,
"preventReorg": false,
"setAsideItems": [],
"contextStorage": { "_links_": { "selectedCases": [] } }
}
}
```

Keys whose value is `undefined`, such as each attribute's `formula` and the collection's `parent`,
are left out above; a plugin receives them, as described under
[What `get` returns](#what-get-returns).

Sort a data context by one attribute, descending:

```json
Expand Down Expand Up @@ -159,6 +241,15 @@ Set two cases aside, then restore everything that is set aside:
}
```

Delete a data context, with all of its cases:

```json
{
"action": "delete",
"resource": "dataContext[Mammals]"
}
```

## Known limitations

**`title` is `undefined` when the data context has no title of its own.** v2 reported the title
Expand Down
11 changes: 11 additions & 0 deletions v3/doc/plugin-api/resources/document.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,17 @@ sendRequest({ action: 'get', resource: 'document' })
sendRequest({ action: 'update', resource: 'document', values: savedState })
```

**Replace the document.** `values` is the whole v2 document, such as one saved from an earlier
`newDocumentState` notification:

```json
{
"action": "update",
"resource": "document",
"values": { "...": "a complete v2 document, as delivered in values.state" }
}
```

## Notifications

| Resource | Operation | When |
Expand Down
2 changes: 2 additions & 0 deletions v3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"plugin-api:generate": "node scripts/plugin-api/generate-plugin-api-docs.mjs",
"plugin-api:check": "node scripts/plugin-api/generate-plugin-api-docs.mjs --check; g=$?; node scripts/plugin-api/lint-plugin-api-docs.mjs; l=$?; exit $((g>l?g:l))",
"plugin-api:lint": "node scripts/plugin-api/lint-plugin-api-docs.mjs",
"plugin-api:tester": "node scripts/plugin-api/generate-tester-templates.mjs --out ../../codap-data-interactives/DataInteractiveAPITester/resources/reference-templates.json",
"plugin-api:tester:check": "node scripts/plugin-api/generate-tester-templates.mjs --check --out ../../codap-data-interactives/DataInteractiveAPITester/resources/reference-templates.json",
"lint:build": "eslint --cache --cache-strategy content --cache-location \".cache/eslint/build\" -c \"eslint.build.config.mjs\" \"./src/**/*.{js,jsx,mjs,ts,tsx}\" \"./cypress/**/*.{js,jsx,mjs,ts,tsx}\"",
"lint:fix": "eslint --cache --fix \"./src/**/*.{js,jsx,ts,tsx}\" \"./cypress/**/*.{js,jsx,ts,tsx}\"",
"lint:style": "eslint -c \".eslintrc.style.cjs\" \"./src/**/*.{js,jsx,ts,tsx}\" \"./cypress/**/*.{js,jsx,ts,tsx}\"",
Expand Down
176 changes: 176 additions & 0 deletions v3/scripts/plugin-api/generate-tester-templates.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env node
//
// generate-tester-templates.mjs
//
// Writes the request templates for the plugin API Tester (DataInteractiveAPITester in the
// codap-data-interactives repo) from the request examples in v3/doc/plugin-api/resources/.
//
// Every ```json block on a resource page whose `action` is one the inventory lists for its
// resource type becomes a template. That test is what separates a request a plugin sends from a
// notification CODAP sends, which the pages also show as json: `logMessageNotice` and
// `documentChangeNotice` are not resources, and `notify` on `global` is not an action a plugin
// can send. An array block becomes one template per element.
//
// Each template is named from the prose that introduces its example — the bold lead-in, or the
// first sentence of the paragraph before the block — and carries the URL of the section it came
// from. A lead that only makes sense beside its neighbors ("Read it.") gets the resource
// appended. An example that appears on more than one page is kept once, from its own resource's
// page where there is one.
//
// Templates with no counterpart in the reference live in the tester's own tests.json; this
// script does not touch that file.
//
// Usage: npm run plugin-api:tester (assumes codap-data-interactives is cloned beside codap)
// npm run plugin-api:tester:check (exit 1 if the tester's copy is stale)
// node generate-tester-templates.mjs --out <file> [--check]
//
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, join } from "node:path"
import { buildPageFor, listPages } from "./coverage.mjs"
import { readInventory } from "./inventory.mjs"
import { headingsOf, maskFences } from "./markdown.mjs"

const here = dirname(fileURLToPath(import.meta.url))
const resourcesDir = join(here, "..", "..", "doc", "plugin-api", "resources")
const sourceBaseUrl =
"https://github.com/concord-consortium/codap/blob/main/v3/doc/plugin-api/resources/"

const usage = "usage: generate-tester-templates.mjs --out <file> [--check]"
const args = process.argv.slice(2)
const outIndex = args.indexOf("--out")
const outPath = outIndex >= 0 ? args[outIndex + 1] : undefined
const check = args.includes("--check")
if (!outPath || outPath.startsWith("--")) {
console.error(usage)
process.exit(1)
}

const inventory = readInventory()
const actionsByType = new Map(inventory.resources.map(r => [r.name, new Set(r.actions)]))
const pageFor = buildPageFor(resourcesDir, inventory.resources)

// `dataContext[Mammals].collection[Cases].attribute[Height]` -> `attribute`
const resourceTypeOf = resource => resource.replace(/\[[^\]]*]/g, "").replace(/.*\./, "")

const kMaxNameLength = 80

// The text a template is named from: the bold lead-in of the paragraph, or its first sentence.
// Returns undefined for a paragraph that is not prose — a heading, a list, a table, code.
function leadOf(paragraph) {
if (/^(#|- |\* |\d+\. |\||<!--|```)/.test(paragraph)) return undefined
const text = paragraph.replace(/\s+/g, " ").trim()
const bold = text.match(/^\*\*(.+?)\*\*/)
let lead = bold ? bold[1] : text.match(/^(.+?)(?:[.:](?: |$)|$)/)[1]
lead = lead.replace(/`/g, "").replace(/\[([^\]]*)]\([^)]*\)/g, "$1").replace(/[.:]$/, "").trim()
if (lead.length > kMaxNameLength) {
lead = lead.slice(0, kMaxNameLength - 1).replace(/\s+\S*$/, "") + "…"
}
return lead || undefined
}

// A lead with no content of its own: "Send".
const isBareLead = lead => !lead.includes(" ")

// A short lead that leans on the examples around it: "Read it", "Change one of its values".
function needsResource(lead) {
const words = lead.split(" ")
return words.length <= 5 && words.slice(0, 4).some(w => /^(it|its|them)$/i.test(w))
}

function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1) }

function templatesFromPage(file) {
const raw = readFileSync(join(resourcesDir, file), "utf8")
// Paragraphs are found in the masked text, so a blank line inside a code block cannot split it.
const masked = maskFences(raw)
const headings = headingsOf(raw).filter(h => h.level >= 2)
const found = []
for (const block of raw.matchAll(/```json\n([\s\S]*?)```/g)) {
let parsed
try { parsed = JSON.parse(block[1]) } catch { continue } // the lint reports these
const requests = (Array.isArray(parsed) ? parsed : [parsed]).filter(request => {
if (typeof request?.action !== "string" || typeof request.resource !== "string") return false
return actionsByType.get(resourceTypeOf(request.resource))?.has(request.action) ?? false
})
if (!requests.length) continue

// Walk back over any code blocks just before this one, so every block in a run that one
// sentence introduces ("Set two cases aside, then restore everything...") shares its lead.
const paragraphs = masked.slice(0, block.index).trimEnd().split(/\n{2,}/)
let p = paragraphs.length - 1
while (p >= 0 && paragraphs[p].trimStart().startsWith("```")) p--
let lead = p >= 0 ? leadOf(paragraphs[p]) : undefined
let hasBoldLead = p >= 0 && paragraphs[p].startsWith("**")
// "**Get a PNG of a graph.**" then "Send:" — the bold lead-in above names the example.
if (lead && isBareLead(lead) && p > 0 && paragraphs[p - 1].startsWith("**")) {
lead = leadOf(paragraphs[p - 1])
hasBoldLead = true
}
const heading = headings.filter(h => h.offset < block.index).pop()

for (const request of requests) {
found.push({
lead,
// a section heading names an example with no prose of its own, unless it is just "Examples"
heading: heading && heading.text !== "Examples" ? heading.text.replace(/`/g, "") : undefined,
group: `${file}:${p}`,
request,
source: `${sourceBaseUrl}${file}${heading ? `#${heading.anchor}` : ""}`,
ownPage: pageFor.get(resourceTypeOf(request.resource)) === file,
hasBoldLead
})
}
}
return found
}

const all = listPages(resourcesDir).flatMap(templatesFromPage)

// Keep one copy of a request that appears on several pages: its own page's, then one with a bold
// lead, then the first.
const rank = t => (t.ownPage ? 0 : 2) + (t.hasBoldLead ? 0 : 1)
const byMessage = new Map()
for (const t of all) {
const key = JSON.stringify(t.request)
const kept = byMessage.get(key)
if (!kept || rank(t) < rank(kept)) byMessage.set(key, t)
}
const kept = all.filter(t => byMessage.get(JSON.stringify(t.request)) === t)

// Name each template, numbering those that still share a lead after deduplication:
// "(1 of 2)", "(2 of 2)".
const groups = new Map()
for (const t of kept) {
if (!groups.has(t.group)) groups.set(t.group, [])
groups.get(t.group).push(t)
}
for (const group of groups.values()) {
group.forEach((t, i) => {
const { action, resource } = t.request
let name = t.lead ?? t.heading ?? `${capitalize(action)} ${resourceTypeOf(resource)}`
if (t.lead && (isBareLead(t.lead) || needsResource(t.lead))) name = `${name} — ${resource}`
if (group.length > 1) name = `${name} (${i + 1} of ${group.length})`
t.name = name
})
}
const templates = kept.map(({ name, request, source }) => ({ name, message: request, source }))

const output = JSON.stringify({
$comment: "Generated by v3/scripts/plugin-api/generate-tester-templates.mjs in the codap " +
"repo, from the request examples in v3/doc/plugin-api/resources/. Do not edit by " +
"hand: change the reference and regenerate. Hand-made templates go in tests.json.",
templates
}, null, 2) + "\n"

if (check) {
const current = existsSync(outPath) ? readFileSync(outPath, "utf8") : ""
if (current !== output) {
console.error(`${outPath} is out of date with the reference; regenerate it with --out`)
process.exit(1)
}
console.log(`${outPath} is up to date (${templates.length} templates)`)
} else {
writeFileSync(outPath, output)
console.log(`wrote ${templates.length} templates to ${outPath}`)
}
Loading
Loading