Skip to content

Commit 282824e

Browse files
feat(javascript): add JS runtime + render harness + Chart.js, D3.js, ECharts (Phase 1) (#8244)
Closes #8241. Adds **JavaScript** as anyplot's fourth language and ships the three framework-agnostic Phase-1 libraries: **Chart.js**, **D3.js**, **Apache ECharts**. This is the foundation of the JS rollout — the Highcharts migration (#8242) and the MUI X React path (#8243) build on it. Matches the one-pass `feat(makie)` (#7613) discipline. ## The genuinely new piece: anyplot's first browser-rendered runtime R (`Rscript`) and Julia (`julia`) are CLI runtimes that write a PNG directly. JS charting libraries render in a browser DOM — there is no headless CLI that produces a PNG. So this introduces a **Node 22 + Playwright headless-Chromium render harness**. The authored artifact is an **idiomatic snippet** (`new Chart(...)`, `d3.select(...)`, `echarts.init(...)`) drawing into `#container`; the harness wraps it in HTML, loads the pinned bundle, awaits a render-complete signal, screenshots the mount at **exact pixels**, and emits **both** the gallery PNG and the self-contained interactive HTML — the same "HTML is the deliverable, PNG is derived" model anyplot already uses for its Python interactive libraries. ## Validated end-to-end (real Playwright + Chromium) All three libraries, both themes, landscape + square: - Exact **3200×1800** / **2400×2400** (verified straight from the PNG IHDR) - First categorical series renders in brand green `#009E73` - Theme-adaptive chrome (paper `#FAF8F1` / ink `#1A1A17` backgrounds, soft grid) - Snippet errors fail the render with a non-zero exit (no silent blank PNGs) ## What changed **Registry (`core/constants.py`)** - `SUPPORTED_LANGUAGES += javascript`; `LANGUAGES_METADATA` entry (Node 22). - `SUPPORTED_LIBRARIES += chartjs, d3, echarts` (alpha-sorted); the three in `INTERACTIVE_LIBRARIES`. - **`framework`** field (`none|react|vue|svelte|angular`, default `none`) on every library — lets one `javascript` entry later cover React libs (#8243) without duplicating the registry (§6). Backed by a new `libraries.framework` column + alembic migration. - **Per-library `file_extension` override** mechanism + `language_file_extensions()`, honored by `sync_to_postgres` so a future `.tsx` React entry is a one-line change. Phase-1 libs are all `.js`. *(This is the one real schema wrinkle JS adds — called out explicitly.)* - DB seeds now project metadata dicts onto model columns, so metadata-only keys never break the bulk insert. **Runtime + harness** - `package.json` + committed `package-lock.json` (chart.js, d3, echarts + playwright, esbuild) — the JS sibling of `Project.toml` / the Posit snapshot. - `.github/actions/setup-node` composite action: `npm ci` → Playwright Chromium → render smoke test asserting exact 3200×1800. - `automation/js-render/render.mjs` — the shared harness (theme tokens, per-library bundle, `//# anyplot-orientation:` directive, render-complete wait, exact-pixel screenshot, self-contained HTML). **Workflows** — `javascript` arm in the `(LANGUAGE, EXT)` case of `impl-generate` / `impl-repair` / `impl-review` / `impl-merge`; `setup-node` step in generate + repair; Node + npm version extraction; per-library canvas hints + a `//` header-rewrite branch (preserving `//#` directives) in review; libs added to generate/bulk-generate choices + `ALL_LIBRARIES`. **Prompts** — new `prompts/library/{chartjs,d3,echarts}.md` (mount-node contract, `ANYPLOT_THEME` tokens, canvas hard rule, no-workarounds, forbidden patterns); JS render contract added to the generate + repair workflow prompts. **Frontend** — `javascript` in `LIBRARIES` / `LIB_ABBREV` / `LIB_TO_LANG` / `LANG_DISPLAY` / `LANG_EXT`; Prism `javascript` grammar registered; ext/runner ternaries extended in `PlotOfTheDay(+Terminal)`. **Tests** — constants tests updated for 14 libraries / 4 languages, the `framework` field, and the extension-override helpers. ## Test results - `tests/unit` Python: constants (43), sync + workflows (337), DB repositories with the new `framework` column (37) — all pass. *(Pre-existing GCS-font image tests fail only for lack of GCS creds in the sandbox; unrelated.)* - Frontend: `tsc` clean, ESLint clean (4 pre-existing warnings), **524 vitest tests pass**. - All edited/new YAML parses; alembic resolves to a single head (`f7a2c9d4e8b1`). ## Notes / decisions - **Plotly stays Python**, no `plotly.js` entry (most-used-variant rule, §6). Same for `plotly (R)` / `highcharter (R)`. - JS & TS are one language; snippets authored in plain JS. React/Vue/etc. are a `framework` flag, not a language. - `esbuild` is pinned and the harness's `framework=react` branch is scaffolded but **not exercised** until #8243. - Versions in metadata (`chart.js` 4.4, `d3` 7.9, `echarts` 5.5; Node 22) should be reconfirmed on the first CI run, per the issue. https://claude.ai/code/session_019awsxNrPnEZStvLfwarTzQ --- _Generated by [Claude Code](https://claude.ai/code/session_019awsxNrPnEZStvLfwarTzQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 851b2f9 commit 282824e

26 files changed

Lines changed: 2488 additions & 49 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: "Setup Node + JS render harness"
2+
description: >-
3+
Installs Node.js, the pinned JavaScript charting toolchain (Chart.js, D3,
4+
ECharts) and the headless-browser render harness (Playwright + Chromium) used
5+
by anyplot's JavaScript implementations. The toolchain is restored exactly
6+
from the committed top-level package-lock.json (`npm ci`) for reproducibility —
7+
the JS sibling of setup-r's Posit snapshot and setup-julia's Manifest.toml.
8+
inputs:
9+
node-version:
10+
description: "Node.js version to install"
11+
required: false
12+
default: "22"
13+
14+
runs:
15+
using: "composite"
16+
steps:
17+
- name: Install Node.js
18+
uses: actions/setup-node@v4
19+
with:
20+
node-version: ${{ inputs.node-version }}
21+
# Restore npm's download cache keyed on the committed lockfile so cold
22+
# `npm ci` runs stay fast.
23+
cache: "npm"
24+
cache-dependency-path: package-lock.json
25+
26+
- name: Install JS toolchain (npm ci)
27+
# `npm ci` installs the exact locked tree from package-lock.json and fails
28+
# if package.json and the lockfile disagree — the strict, reproducible
29+
# restore (vs. `npm install`, which may rewrite the lockfile).
30+
shell: bash
31+
run: npm ci
32+
33+
- name: Install Playwright Chromium
34+
# Bundled, version-pinned Chromium (no webdriver-manager drift). `--with-deps`
35+
# pulls the apt libraries headless Chromium needs on the runner image.
36+
shell: bash
37+
run: npx playwright install --with-deps chromium
38+
39+
- name: Smoke-test the render harness produces a PNG
40+
shell: bash
41+
run: |
42+
set -euo pipefail
43+
TMP="$(mktemp -d)"
44+
cat > "$TMP/chartjs.js" <<'JS'
45+
const t = window.ANYPLOT_TOKENS;
46+
const canvas = document.createElement("canvas");
47+
document.getElementById("container").appendChild(canvas);
48+
new Chart(canvas, {
49+
type: "bar",
50+
data: { labels: ["a", "b", "c"], datasets: [{ data: [3, 1, 2], backgroundColor: t.palette[0] }] },
51+
options: { responsive: true, maintainAspectRatio: false, animation: false },
52+
});
53+
JS
54+
( cd "$TMP" && ANYPLOT_THEME=light node "$GITHUB_WORKSPACE/automation/js-render/render.mjs" chartjs.js )
55+
PNG="$TMP/plot-light.png"
56+
test -f "$PNG"
57+
test -s "$PNG"
58+
# Confirm exact landscape pixels (3200×1800) straight from the PNG IHDR.
59+
node -e '
60+
const fs = require("fs");
61+
const b = fs.readFileSync(process.argv[1]);
62+
const w = b.readUInt32BE(16), h = b.readUInt32BE(20);
63+
if (w !== 3200 || h !== 1800) { console.error(`smoke-test PNG is ${w}x${h}, expected 3200x1800`); process.exit(1); }
64+
console.log(`ok: ${process.argv[1]} (${w}x${h}, ${b.length} bytes)`);
65+
' "$PNG"

.github/workflows/bulk-generate.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ on:
3030
- letsplot
3131
- ggplot2
3232
- makie
33+
- chartjs
34+
- d3
35+
- echarts
3336
dry_run:
3437
description: "List what would be generated without executing"
3538
type: boolean
@@ -54,7 +57,7 @@ on:
5457
default: '{}'
5558

5659
env:
57-
ALL_LIBRARIES: "matplotlib seaborn plotly bokeh altair plotnine pygal highcharts letsplot ggplot2 makie"
60+
ALL_LIBRARIES: "matplotlib seaborn plotly bokeh altair plotnine pygal highcharts letsplot ggplot2 makie chartjs d3 echarts"
5861

5962
# Serialise bulk-generate runs. Each run paces its own dispatches with
6063
# `pace_seconds`; letting two runs overlap would interleave their

.github/workflows/impl-generate.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ on:
3131
- letsplot
3232
- ggplot2
3333
- makie
34+
- chartjs
35+
- d3
36+
- echarts
3437
issue_number:
3538
description: "Issue number (optional, for tracking)"
3639
required: false
@@ -124,6 +127,10 @@ jobs:
124127
LANGUAGE="julia"
125128
EXT=".jl"
126129
;;
130+
chartjs|d3|echarts)
131+
LANGUAGE="javascript"
132+
EXT=".js"
133+
;;
127134
*)
128135
LANGUAGE="python"
129136
EXT=".py"
@@ -255,6 +262,10 @@ jobs:
255262
if: steps.inputs.outputs.language == 'julia'
256263
uses: ./.github/actions/setup-julia
257264

265+
- name: Setup Node + browser render harness
266+
if: steps.inputs.outputs.language == 'javascript'
267+
uses: ./.github/actions/setup-node
268+
258269
# ========================================================================
259270
# Generate: Create implementation branch and code
260271
# ========================================================================
@@ -526,6 +537,10 @@ jobs:
526537
# if julia exits non-zero after partial stdout.
527538
LANGUAGE_VERSION=$(julia -e 'print(VERSION)' 2>/dev/null | tail -n1)
528539
is_version "$LANGUAGE_VERSION" || LANGUAGE_VERSION="unknown"
540+
elif [ "$LANGUAGE" = "javascript" ]; then
541+
# `node --version` prints `v22.x.y`; strip the leading `v`.
542+
LANGUAGE_VERSION=$(node --version 2>/dev/null | sed 's/^v//' | tail -n1)
543+
is_version "$LANGUAGE_VERSION" || LANGUAGE_VERSION="unknown"
529544
else
530545
LANGUAGE_VERSION="$PYTHON_VERSION"
531546
fi
@@ -558,6 +573,14 @@ jobs:
558573
" 2>/dev/null | tail -n1)
559574
is_version "$v" && printf '%s' "$v"
560575
}
576+
get_npm_version() {
577+
# Read the installed version straight out of the package's own
578+
# package.json (restored by `npm ci` from the committed lockfile).
579+
# `require('<pkg>/package.json')` resolves the subpath reliably.
580+
local v
581+
v=$(node -p "require('$1/package.json').version" 2>/dev/null | tail -n1)
582+
is_version "$v" && printf '%s' "$v"
583+
}
561584
562585
if [ "$LANGUAGE" = "r" ]; then
563586
LIBRARY_VERSION=$(get_r_version "$LIBRARY")
@@ -572,6 +595,17 @@ jobs:
572595
LIBRARY_VERSION=$(get_julia_version "$LIBRARY")
573596
;;
574597
esac
598+
elif [ "$LANGUAGE" = "javascript" ]; then
599+
# Catalogue id `chartjs` maps to the npm package `chart.js`; `d3` and
600+
# `echarts` match their npm names directly.
601+
case "$LIBRARY" in
602+
chartjs)
603+
LIBRARY_VERSION=$(get_npm_version "chart.js")
604+
;;
605+
*)
606+
LIBRARY_VERSION=$(get_npm_version "$LIBRARY")
607+
;;
608+
esac
575609
else
576610
case "$LIBRARY" in
577611
letsplot)

.github/workflows/impl-merge.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ jobs:
113113
LANGUAGE="julia"
114114
EXT=".jl"
115115
;;
116+
chartjs|d3|echarts)
117+
LANGUAGE="javascript"
118+
EXT=".js"
119+
;;
116120
*)
117121
LANGUAGE="python"
118122
EXT=".py"

.github/workflows/impl-repair.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ jobs:
7979
LANGUAGE="julia"
8080
EXT=".jl"
8181
;;
82+
chartjs|d3|echarts)
83+
LANGUAGE="javascript"
84+
EXT=".js"
85+
;;
8286
*)
8387
LANGUAGE="python"
8488
EXT=".py"
@@ -127,6 +131,10 @@ jobs:
127131
if: steps.lang.outputs.language == 'julia'
128132
uses: ./.github/actions/setup-julia
129133

134+
- name: Setup Node + browser render harness
135+
if: steps.lang.outputs.language == 'javascript'
136+
uses: ./.github/actions/setup-node
137+
130138
- name: Extract AI feedback from PR
131139
id: feedback
132140
env:

.github/workflows/impl-review.yml

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ jobs:
109109
LANGUAGE="julia"
110110
EXT=".jl"
111111
;;
112+
chartjs|d3|echarts)
113+
LANGUAGE="javascript"
114+
EXT=".js"
115+
;;
112116
*)
113117
LANGUAGE="python"
114118
EXT=".py"
@@ -268,6 +272,9 @@ jobs:
268272
"letsplot": "Check `ggsize(W, H)` and `ggsave(..., scale=4)` pair — only the two canonical pairs land on target.",
269273
"ggplot2": "Check `ggsave(width=…, height=…, units='in', dpi=400)` with `ragg::agg_png`.",
270274
"makie": "Check `Figure(resolution=(1600, 900))` + `save(..., px_per_unit=2)` (landscape) or `resolution=(1200, 1200)` + `px_per_unit=2` (square) — those are the only two canonical pairs.",
275+
"chartjs": "The harness owns exact pixels (Playwright deviceScaleFactor 2 over a fixed mount). Drift means the snippet forced its own canvas size: set `responsive: true` + `maintainAspectRatio: false` and let the canvas fill `#container` — do NOT set canvas width/height. Use `//# anyplot-orientation: square` only for square specs.",
276+
"d3": "The harness owns exact pixels (Playwright deviceScaleFactor 2 over a fixed mount). Size the `<svg>` to `window.ANYPLOT_SIZE` (1600×900 landscape / 1200×1200 square) — never hard-code other dimensions. Use `//# anyplot-orientation: square` only for square specs.",
277+
"echarts": "The harness owns exact pixels (Playwright deviceScaleFactor 2 over a fixed mount). Call `echarts.init(document.getElementById('container'))` with no explicit width/height so it fills the mount; do not pass a `devicePixelRatio` (the page already runs at 2×). Use `//# anyplot-orientation: square` only for square specs.",
271278
}
272279
cause = causes.get(LIBRARY, f"Review `prompts/library/{LIBRARY}.md` 'Canvas — hard rule' section.")
273280
@@ -645,7 +652,9 @@ jobs:
645652
646653
# Human-readable runtime label for the header. Extend this map when a new
647654
# language joins the catalog.
648-
RUNTIME_LABEL = {"python": "Python", "r": "R", "julia": "Julia"}.get(language, language.capitalize())
655+
RUNTIME_LABEL = {"python": "Python", "r": "R", "julia": "Julia", "javascript": "JavaScript"}.get(
656+
language, language.capitalize()
657+
)
649658
650659
with open(impl_file, "r") as f:
651660
content = f.read()
@@ -694,6 +703,29 @@ jobs:
694703
new_content = re.sub(pattern, new_header + "\n", content, count=1)
695704
else:
696705
new_content = new_header + "\n" + content
706+
elif language == "javascript":
707+
# JavaScript has no docstring syntax; anyplot uses a leading `//`
708+
# line-comment block, mirroring the Julia/R conventions.
709+
title_safe = title.replace("\n", " ")
710+
new_header = (
711+
"// anyplot.ai\n"
712+
f"// {spec_id}: {title_safe}\n"
713+
f"// Library: {library} {lib_version} | {RUNTIME_LABEL} {lang_version}\n"
714+
f"// Quality: {score}/100 | {date_info}"
715+
)
716+
# Match a leading run of `//`-comment lines (the existing header).
717+
# The negative lookahead `(?!#)` excludes `//#`-prefixed lines —
718+
# those are anyplot directives (e.g. `//# anyplot-orientation:
719+
# square`) the harness reads, NOT header lines, so they must
720+
# survive the rewrite. The run therefore stops at the first
721+
# directive; if a directive is the first line of the file the
722+
# regex falls through and the canonical header is prepended above
723+
# it. (#6947 prepend-fallback.)
724+
pattern = r"\A(?://(?!#)[^\n]*\n)+"
725+
if re.match(pattern, content):
726+
new_content = re.sub(pattern, new_header + "\n", content, count=1)
727+
else:
728+
new_content = new_header + "\n" + content
697729
else:
698730
# Python: triple-quoted module docstring at the top of the file.
699731
title_safe = title.replace('"""', '\\"\\"\\"')
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""add_framework_to_libraries
2+
3+
Adds the `framework` column to the libraries table. It models the UI-framework
4+
runtime constraint a charting library imposes (none | react | vue | svelte |
5+
angular) so a single `javascript` language entry can cover both
6+
framework-agnostic libraries (Chart.js, D3, ECharts) and React-only libraries
7+
without duplicating the registry — see docs/concepts/library-expansion.md §6.
8+
All existing libraries are framework-agnostic, so the column defaults to "none".
9+
10+
Revision ID: f7a2c9d4e8b1
11+
Revises: 7efe9fc8bde1
12+
Create Date: 2026-06-02 20:00:00.000000
13+
14+
"""
15+
16+
from typing import Sequence
17+
18+
import sqlalchemy as sa
19+
20+
from alembic import op
21+
22+
23+
# revision identifiers, used by Alembic.
24+
revision: str = "f7a2c9d4e8b1"
25+
down_revision: str | None = "7efe9fc8bde1"
26+
branch_labels: str | Sequence[str] | None = None
27+
depends_on: str | Sequence[str] | None = None
28+
29+
30+
def upgrade() -> None:
31+
op.add_column("libraries", sa.Column("framework", sa.String(length=20), nullable=False, server_default="none"))
32+
33+
34+
def downgrade() -> None:
35+
op.drop_column("libraries", "framework")

app/src/components/CodeHighlighter.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@ import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light';
22
import python from 'react-syntax-highlighter/dist/esm/languages/prism/python';
33
import r from 'react-syntax-highlighter/dist/esm/languages/prism/r';
44
import julia from 'react-syntax-highlighter/dist/esm/languages/prism/julia';
5+
import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
56
import { typography } from '../theme';
67

78
SyntaxHighlighter.registerLanguage('python', python);
89
SyntaxHighlighter.registerLanguage('r', r);
910
SyntaxHighlighter.registerLanguage('julia', julia);
11+
SyntaxHighlighter.registerLanguage('javascript', javascript);
1012

1113
// Map anyplot language IDs → Prism grammar names. Anything we don't know about
1214
// falls back to plain text so the block still renders, just unhighlighted.
1315
const PRISM_LANGUAGE: Record<string, string> = {
1416
python: 'python',
1517
r: 'r',
1618
julia: 'julia',
19+
javascript: 'javascript',
1720
};
1821

1922
// Theme-aware imprint syntax theme. All colors come from CSS variables in

app/src/components/PlotOfTheDay.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,11 @@ export function PlotOfTheDay() {
103103
<Typography sx={{ fontFamily: mono, fontSize: fontSize.xs, color: colors.primary, fontWeight: 600 }}>$</Typography>
104104
{(() => {
105105
// Per-language file extension + runner command. Anyplot ships Python
106-
// for nine libraries, R for ggplot2, and Julia for makie; the chip
107-
// mimics what a user would actually type into a shell, so the
108-
// runner label flips too.
109-
const ext = data.language === 'r' ? '.R' : data.language === 'julia' ? '.jl' : '.py';
110-
const runner = data.language === 'r' ? 'Rscript' : data.language === 'julia' ? 'julia --project=.' : 'python';
106+
// for nine libraries, R for ggplot2, Julia for makie, and JavaScript
107+
// for chartjs/d3/echarts; the chip mimics what a user would actually
108+
// type into a shell, so the runner label flips too.
109+
const ext = data.language === 'r' ? '.R' : data.language === 'julia' ? '.jl' : data.language === 'javascript' ? '.js' : '.py';
110+
const runner = data.language === 'r' ? 'Rscript' : data.language === 'julia' ? 'julia --project=.' : data.language === 'javascript' ? 'node' : 'python';
111111
return (
112112
<Typography
113113
component="a"
@@ -265,7 +265,7 @@ export function PlotOfTheDay() {
265265
266266
</Typography>
267267
<Typography sx={{ fontFamily: mono, fontSize: fontSize.xxs, color: semanticColors.mutedText, whiteSpace: 'nowrap' }}>
268-
{data.library_name}{data.library_version && data.library_version !== 'unknown' ? ` ${data.library_version}` : ''} · {data.language === 'r' ? 'R' : data.language === 'julia' ? 'Julia' : 'Python'} {data.language_version || data.python_version || (data.language === 'r' ? '4.4' : data.language === 'julia' ? '1.11' : '3.13')}
268+
{data.library_name}{data.library_version && data.library_version !== 'unknown' ? ` ${data.library_version}` : ''} · {data.language === 'r' ? 'R' : data.language === 'julia' ? 'Julia' : data.language === 'javascript' ? 'JavaScript' : 'Python'} {data.language_version || data.python_version || (data.language === 'r' ? '4.4' : data.language === 'julia' ? '1.11' : data.language === 'javascript' ? '22' : '3.13')}
269269
</Typography>
270270
</Box>
271271
</Box>

app/src/components/PlotOfTheDayTerminal.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ export function PlotOfTheDayTerminal({
4545
// File extension + runner command follow the implementation language:
4646
// - ggplot2 (R) ships as .R + Rscript
4747
// - makie (Julia) ships as .jl + `julia --project=.`
48+
// - chartjs/d3/echarts (JavaScript) ship as .js + node (browser render harness)
4849
// - every Python library as .py + python
49-
const ext = potd.language === 'r' ? '.R' : potd.language === 'julia' ? '.jl' : '.py';
50-
const runner = potd.language === 'r' ? 'Rscript' : potd.language === 'julia' ? 'julia --project=.' : 'python';
50+
const ext = potd.language === 'r' ? '.R' : potd.language === 'julia' ? '.jl' : potd.language === 'javascript' ? '.js' : '.py';
51+
const runner = potd.language === 'r' ? 'Rscript' : potd.language === 'julia' ? 'julia --project=.' : potd.language === 'javascript' ? 'node' : 'python';
5152
const displayFilename = `plots/${potd.spec_id}/${potd.library_id}${ext}`;
5253
const implPath = specPath(potd.spec_id, potd.language, potd.library_id);
5354
const githubFileUrl = `${GITHUB_URL}/blob/main/plots/${potd.spec_id}/implementations/${potd.language}/${potd.library_id}${ext}`;

0 commit comments

Comments
 (0)