Fix Chart and Signature evaluating JS strings (#3226)

This commit is contained in:
Bartosz-Do authored and GitHub committed 2026-09-28 12:12:25 +02:00
1 parent 01857d234a
commit 6c1d546583
8 files changed
+157 -133

No files matched your search

+5 -2
View File
@@ -77,9 +77,12 @@ Two known snags:
- **Astro wraps a `define:vars` script in an IIFE.** Anything the markup calls (`onclick="setPageListItems(this)"`) must be assigned to `window` explicitly — see `shared/components/demo/AdvancedTable.astro`. If several instances define the same global, make it stateless and resolve the instance from the clicked element.
- The type checker does not see `define:vars` bindings used inside template literals. `shared/components/marketing/hero/Side.astro` carries a `// @ts-nocheck` for exactly that; copy the comment with its explanation rather than restructuring the script.
## 5. When the script has to be a string
## 5. No caller-supplied JS
If caller-supplied JS must land *inside* a function body, `define:vars` cannot do it — it injects variables, not code. Then build the script as a template string in the frontmatter and render it with `<Fragment set:html={script} />` inside `<CaptureScript>` (see `shared/ui/Signature.astro`). Serialise every interpolated value with `JSON.stringify` and keep raw-JS props documented as an escape hatch.
Never accept JavaScript as a string — no raw-JS props, no expressions in data files turned into code with `new Function` or `set:html`. They are `eval` sinks and block a strict CSP. Instead:
- Page-specific behaviour goes in the page's own `<CaptureScript>` and reaches the plugin instance through its `window.tabler_*` registry (see `preview/pages/signatures.astro`).
- Behaviour picked from data uses a whitelist of names mapped to real functions inside the script (see the chart `x-formatter` / `y-formatter` in `shared/components/demo/Chart.astro`).
## 6. Instance registries
+6
View File
@@ -0,0 +1,6 @@
---
"@tabler/preview": patch
"@tabler/docs": patch
---
Removed JS string evaluation from `Chart` axis formatters in `charts.json` and the `extraJs` prop from `Signature`.
+48 -47
View File
@@ -1,5 +1,4 @@
---
// Raw JS passed as Signature `extraJs` (appended inside the SignaturePad init).
import CardSubtitle from '@ui/CardSubtitle.astro'
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import Signature from '@ui/Signature.astro'
@@ -11,51 +10,10 @@ import CardTitle from '@ui/CardTitle.astro'
import FormGroup from '@ui/FormGroup.astro'
import CaptureModal from '@shared/components/CaptureModal.astro'
import DocsLink from '@ui/DocsLink.astro'
import CaptureScript from '@shared/components/CaptureScript.astro'
const simpleJs = `signaturePad.fromDataURL("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA1OTIgMjAwIj4KICA8ZyBjbGlwLXBhdGg9InVybCgjYSkiPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZD0iTTEzMi4wOTIgNzkuMDY1YzAgLjMwNC0uMzU1IDEuNDAzLTEuMDYzIDMuMjk4LS42OTQgMS44OTQtMS4xNDIgMy4wMjItMS4zNDUgMy4zODQtMi4zNDIgMy44NDctNi43NiA3LjE4LTEzLjI1MiAxMC4wMDEtNS4zNjUgMi4yMjctMTMuNDU1IDUuNTktMjQuMjcgMTAuMDg4YTE0OC4xOSAxNDguMTkgMCAwIDAtMTAuMzQ3IDMuMTI0Yy0yLjY5Ljg4Mi02LjU4NiAyLjk1LTExLjY5IDYuMjA0YTE4Ny42NiAxODcuNjYgMCAwIDAtMS4wNjMgMTAuMTk2IDQwNy40OSA0MDcuNDkgMCAwIDEtMS40MzIgMTUuMzM4Yy0uODgyIDEuMTg2LTEuOTg4IDEuNzc5LTMuMzE4IDEuNzc5LjExNSAwIC4xNzMtLjAxNS4xNzMtLjA0NC0uMjAyLS4xMDEtLjg5Ni0uMjgyLTIuMDgyLS41NDJ2LTIuODg1YzAtMi43MDUuMjMxLTUuNDYuNjk0LTguMjY2IDAtLjA1Ny4yNTMtLjc3My43Ni0yLjE0Ny41MDUtMS4zMzEuNzU4LTIuMTQxLjc1OC0yLjQzIDAtMS42MDUtLjI5Ni0zLjI0LS44ODktNC45MDMtLjM5IDEuMTI4LTEuMTEzIDEuODU5LTIuMTY5IDIuMTkxLTEuMjQ0LS43MjMtMS44NjUtMS45ODgtMS44NjUtMy43OTYgMC0uMjAzIDEuMDQ4LTEuNzI5IDMuMTQ1LTQuNTc4IDIuMTQtMi45NjUgMy4zODMtNS4wMDQgMy43My02LjExNy4yNzUtLjgxLjc2Ny0zLjQ1IDEuNDc1LTcuOTE5IDEuMDQxLTYuNjEgMS45Ni0xMS40NCAyLjc1NS0xNC40OTEgMS42OTItNi42MzkgMy42OTQtMTAuODYyIDYuMDA4LTEyLjY3bDIuNDMgMS44MjN2MS44NjVjLTEuNTA1IDEuMTU3LTIuNTY3IDMuMjYyLTMuMTkgNi4zMTMtLjE4Ny45NC0uNjM2IDMuMzQxLTEuMzQ0IDcuMjAzLS4xNDUuNzA4LS43MjMgMi41NDUtMS43MzUgNS41MS0uOTExIDIuNjAzLTEuMzY3IDQuMTg3LTEuMzY3IDQuNzUxdjUuNDI0Yy4yNi4yNDUuNS4zNjguNzE2LjM2OCAxLjIxNSAwIDMuMTA5LS41MDYgNS42ODMtMS41MTggMy4wOC0xLjE1NyA1LjAxNy0xLjg1MSA1LjgxMy0yLjA4MyAxMy4wNzEtMy4zODQgMjMuMTUtNy4yMzggMzAuMjM1LTExLjU2MyA2LjIxOC0zLjc0NiA5Ljk5Mi02LjI5OCAxMS4zMjItNy42NTggMS41NzYtMS41NjIgMi4zNjQtMy42NjYgMi4zNjQtNi4zMTMgMC00LjY3MS0yLjEwNC04LjU2OS02LjMxMi0xMS42OTMtMy44Ni0yLjkwNy04LjE5MS00LjM2LTEyLjk5Mi00LjM2LTMuNzg4IDAtOC4yNzEuNDYyLTEzLjQ0NyAxLjM4OC02LjY2NiAxLjIxNS0xMi45MiAyLjkwNy0xOC43NjIgNS4wNzYtOC44NjQgMy4yODQtMTMuNzM2IDYuODM0LTE0LjYxOCAxMC42NTJsLTEuMDg1LjgwM0M1OC44MzkgNzguNDIyIDU4IDc3LjA2MiA1OCA3NS43OWMwLTEuODA4IDEuODgtNC4wMTQgNS42NC02LjYxNyAzLjA4LTIuMTQgNS44Mi0zLjU2NSA4LjIyLTQuMjc0IDE1Ljk0OS00LjkxNyAyNy44NjQtNy4zNzYgMzUuNzQ0LTcuMzc2IDcuNDYxIDAgMTMuNDEyIDEuOTUzIDE3Ljg1MSA1Ljg1NyA0LjQyNCAzLjg2MiA2LjYzNyA5LjA5IDYuNjM3IDE1LjY4NVptMzIuMjk1IDU1LjQwN3YxLjkwOWMtLjU5Mi44MjQtMS4yNDMgMS4yMzYtMS45NTIgMS4yMzYtLjQ0OCAwLTEuOTczLTEuMzU5LTQuNTc2LTQuMDc4LTIuNjAzLTIuNzMzLTQuMzM4LTQuMS01LjIwNi00LjEgMCAuMDU4LTMuMDggMS4yNDQtOS4yMzkgMy41NTgtNi4zMDUgMi4zNDMtMTAuMzE3IDMuNTE0LTEyLjAzOCAzLjUxNC0xLjQxNyAwLTIuOTg2LS42OTQtNC43MDctMi4wODMtMS43Mi0xLjM3NC0yLjU4MS0yLjY2OC0yLjU4MS0zLjg4MyAwLTMuMTM4IDMuNDM0LTYuNzk3IDEwLjMwMy0xMC45NzcgNi40MzQtMy45NjMgMTEuMzQzLTUuOTQ0IDE0LjcyNy01Ljk0NCAxLjQxNyAwIDIuNjkuODEgMy44MTcgMi40M3YxLjgyMmMtLjU2NC44MS0xLjIxNCAxLjIxNS0xLjk1MiAxLjIxNS0uMTMgMC0uNjE0LS4xODEtMS40NTMtLjU0My0uODM5LS4zNzYtMS4zMzctLjU2NC0xLjQ5Ni0uNTY0LTEuMTI4IDAtMy43MTcgMS4xNjUtNy43NjUgMy40OTMtMy44MzIgMi4xNjktNi4zOTkgMy43ODktNy43IDQuODYtMS4zMDIgMS4xMjgtMi4xMzMgMi40MTUtMi40OTQgMy44NjEuOTgzLjkyNiAyLjE1NCAxLjM4OCAzLjUxMyAxLjM4OC43MDkgMCA0LjEwNy0xLjE3MSAxMC4xOTQtMy41MTQgNi4wNzMtMi4zNzIgOS43NDYtMy41NTggMTEuMDE5LTMuNTU4IDEuODY1IDAgMy4yMS40NyA0LjAzNCAxLjQxLjE4OC4yMTcuOTA0IDEuNDkgMi4xNDcgMy44MThhMjEuOTAyIDIxLjkwMiAwIDAgMCAzLjQwNSA0LjczWm02OS44ODQtMTkuNzQydjEuOTUzYy0yLjk5MyAyLjQ1OC01Ljg5OSA1LjIxMy04LjcxOSA4LjI2NS04Ljc2MiA5LjQ1OS0xNC42NjIgMTQuMTg4LTE3LjY5OSAxNC4xODgtMi40NTggMC01LjE5OC0yLjIyLTguMjItNi42NmwtNi41MjgtOS42NTRjLTEuMjQ0LjIwMy0zLjQ5MiAyLjQ2Ni02Ljc0NiA2Ljc5LTMuMDUxIDQuMTIyLTUuODQyIDYuMTgzLTguMzcyIDYuMTgzbC0xLjE5My0uNjI5Yy0uODgyLTEuNjItMi41MzgtNS4xNy00Ljk2Ny0xMC42NTItMi42NDYtNS45MjktMy45NjktOS4zMDYtMy45NjktMTAuMTMxIDAtMS40MzIuODQ2LTIuNjE4IDIuNTM4LTMuNTU4IDEuNjkxIDIuMDQgMy4zNTQgNC44NjcgNC45ODggOC40ODMgMi40NTggNS4zMzYgMy44NzUgOC4zMyA0LjI1MSA4Ljk4MWgyLjA2MWMuNTY0LTEuMDQxIDEuODk0LTIuNjU0IDMuOTkxLTQuODM4LjczNy0xLjEyOCAxLjgyMi0yLjY5IDMuMjUzLTQuNjg2IDEuNjc3LTIuMzcyIDMuMDk1LTMuNTU3IDQuMjUxLTMuNTU3IDEuMDcgMCAxLjk3NC41OTIgMi43MTEgMS43NzggMS4wNzEgMS42OTMgMi45ODYgNC43MyA1Ljc0OCA5LjExMiAzLjA1MSA0Ljc0NCA1LjMgNy4xMTYgNi43NDYgNy4xMTYgMS4wNyAwIDUuMDgyLTMuMDgxIDEyLjAzNy05LjI0MiA2Ljk3LTYuMTYxIDEwLjc5NS05LjI0MiAxMS40NzQtOS4yNDJoMi4zNjRabTQ0LjU3MyAxOS41MjVjLTEuMDQyIDEuMjQ0LTIuNDAxIDEuODY2LTQuMDc4IDEuODY2aC0xNC45ODhjLTMuNDcgMC03LjMzOC0uNTIxLTExLjYwNC0xLjU2Mi01LjU5NS0xLjM2LTguNzYyLTMuMDY3LTkuNS01LjEyLS44MDktMi4zMTQtMS4yMTQtNC41MzQtMS4yMTQtNi42NiAwLTQuMjY3IDEuODM2LTcuNTQzIDUuNTA5LTkuODI4IDMuMDk0LTEuOTgxIDYuODc2LTIuOTcyIDExLjM0NC0yLjk3MiAzLjg0NiAwIDYuODk3LjgzMiA5LjE1MyAyLjQ5NSAyLjI1NSAxLjY0OSAzLjM4MyAzLjg5OCAzLjM4MyA2Ljc0NyAwIDEuNTkxLTIuNzExIDMuMzI2LTguMTMzIDUuMjA2LTQuNDgzIDEuNjItNy43NDQgMi40My05Ljc4MiAyLjQzYTQuNDkgNC40OSAwIDAgMS0uNjczLS4wNDNjMCAuMzktLjMxMS44NjgtLjkzMiAxLjQzMi4zMzIuNTY0IDEuOTgxIDEuMjE0IDQuOTQ1IDEuOTUyIDIuNjYuNjggNC41NTUgMS4wMiA1LjY4MiAxLjAyaDEzLjQ3YzAgLjAyOSAxLjY5OS4yODIgNS4wOTcuNzU5IDEuMDcuNjggMS44NDMgMS40MzkgMi4zMjEgMi4yNzhabS0xNy40NjEtMTYuOWMtMS41NzYtMS42MDUtNC4xMTMtMi40MDgtNy42MTMtMi40MDgtMi40NTggMC00LjczNS40NzctNi44MzIgMS40MzItMi43MDQgMS4yNDQtNC4wNTYgMi45MjktNC4wNTYgNS4wNTUgMCAuNzUyLjI1MyAxLjI4Ny43NTkgMS42MDVoMLine truncated
const advancedJs = `function download(dataURL, filename) {
const blob = dataURLToBlob(dataURL);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style = "display: none";
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}
function dataURLToBlob(dataURL) {
const parts = dataURL.split(';base64,');
const contentType = parts[0].split(":")[1];
const raw = window.atob(parts[1]);
const rawLength = raw.length;
const uInt8Array = new Uint8Array(rawLength);
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i);
}
return new Blob([uInt8Array], { type: contentType });
}
document.querySelector("#signature-advanced-color").addEventListener("input", function (e) {
signaturePad.penColor = e.target.value;
});
document.querySelector("#signature-advanced-svg").addEventListener("click", function () {
const dataURL = signaturePad.toDataURL('image/svg+xml');
download(dataURL, "signature.svg");
});
document.querySelector("#signature-advanced-png").addEventListener("click", function () {
const dataURL = signaturePad.toDataURL();
download(dataURL, "signature.png");
});`
const signatureImage =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA1OTIgMjAwIj4KICA8ZyBjbGlwLXBhdGg9InVybCgjYSkiPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZD0iTTEzMi4wOTIgNzkuMDY1YzAgLjMwNC0uMzU1IDEuNDAzLTEuMDYzIDMuMjk4LS42OTQgMS44OTQtMS4xNDIgMy4wMjItMS4zNDUgMy4zODQtMi4zNDIgMy44NDctNi43NiA3LjE4LTEzLjI1MiAxMC4wMDEtNS4zNjUgMi4yMjctMTMuNDU1IDUuNTktMjQuMjcgMTAuMDg4YTE0OC4xOSAxNDguMTkgMCAwIDAtMTAuMzQ3IDMuMTI0Yy0yLjY5Ljg4Mi02LjU4NiAyLjk1LTExLjY5IDYuMjA0YTE4Ny42NiAxODcuNjYgMCAwIDAtMS4wNjMgMTAuMTk2IDQwNy40OSA0MDcuNDkgMCAwIDEtMS40MzIgMTUuMzM4Yy0uODgyIDEuMTg2LTEuOTg4IDEuNzc5LTMuMzE4IDEuNzc5LjExNSAwIC4xNzMtLjAxNS4xNzMtLjA0NC0uMjAyLS4xMDEtLjg5Ni0uMjgyLTIuMDgyLS41NDJ2LTIuODg1YzAtMi43MDUuMjMxLTUuNDYuNjk0LTguMjY2IDAtLjA1Ny4yNTMtLjc3My43Ni0yLjE0Ny41MDUtMS4zMzEuNzU4LTIuMTQxLjc1OC0yLjQzIDAtMS42MDUtLjI5Ni0zLjI0LS44ODktNC45MDMtLjM5IDEuMTI4LTEuMTEzIDEuODU5LTIuMTY5IDIuMTkxLTEuMjQ0LS43MjMtMS44NjUtMS45ODgtMS44NjUtMy43OTYgMC0uMjAzIDEuMDQ4LTEuNzI5IDMuMTQ1LTQuNTc4IDIuMTQtMi45NjUgMy4zODMtNS4wMDQgMy43My02LjExNy4yNzUtLjgxLjc2Ny0zLjQ1IDEuNDc1LTcuOTE5IDEuMDQxLTYuNjEgMS45Ni0xMS40NCAyLjc1NS0xNC40OTEgMS42OTItNi42MzkgMy42OTQtMTAuODYyIDYuMDA4LTEyLjY3bDIuNDMgMS44MjN2MS44NjVjLTEuNTA1IDEuMTU3LTIuNTY3IDMuMjYyLTMuMTkgNi4zMTMtLjE4Ny45NC0uNjM2IDMuMzQxLTEuMzQ0IDcuMjAzLS4xNDUuNzA4LS43MjMgMi41NDUtMS43MzUgNS41MS0uOTExIDIuNjAzLTEuMzY3IDQuMTg3LTEuMzY3IDQuNzUxdjUuNDI0Yy4yNi4yNDUuNS4zNjguNzE2LjM2OCAxLjIxNSAwIDMuMTA5LS41MDYgNS42ODMtMS41MTggMy4wOC0xLjE1NyA1LjAxNy0xLjg1MSA1LjgxMy0yLjA4MyAxMy4wNzEtMy4zODQgMjMuMTUtNy4yMzggMzAuMjM1LTExLjU2MyA2LjIxOC0zLjc0NiA5Ljk5Mi02LjI5OCAxMS4zMjItNy42NTggMS41NzYtMS41NjIgMi4zNjQtMy42NjYgMi4zNjQtNi4zMTMgMC00LjY3MS0yLjEwNC04LjU2OS02LjMxMi0xMS42OTMtMy44Ni0yLjkwNy04LjE5MS00LjM2LTEyLjk5Mi00LjM2LTMuNzg4IDAtOC4yNzEuNDYyLTEzLjQ0NyAxLjM4OC02LjY2NiAxLjIxNS0xMi45MiAyLjkwNy0xOC43NjIgNS4wNzYtOC44NjQgMy4yODQtMTMuNzM2IDYuODM0LTE0LjYxOCAxMC42NTJsLTEuMDg1LjgwM0M1OC44MzkgNzguNDIyIDU4IDc3LjA2MiA1OCA3NS43OWMwLTEuODA4IDEuODgtNC4wMTQgNS42NC02LjYxNyAzLjA4LTIuMTQgNS44Mi0zLjU2NSA4LjIyLTQuMjc0IDE1Ljk0OS00LjkxNyAyNy44NjQtNy4zNzYgMzUuNzQ0LTcuMzc2IDcuNDYxIDAgMTMuNDEyIDEuOTUzIDE3Ljg1MSA1Ljg1NyA0LjQyNCAzLjg2MiA2LjYzNyA5LjA5IDYuNjM3IDE1LjY4NVptMzIuMjk1IDU1LjQwN3YxLjkwOWMtLjU5Mi44MjQtMS4yNDMgMS4yMzYtMS45NTIgMS4yMzYtLjQ0OCAwLTEuOTczLTEuMzU5LTQuNTc2LTQuMDc4LTIuNjAzLTIuNzMzLTQuMzM4LTQuMS01LjIwNi00LjEgMCAuMDU4LTMuMDggMS4yNDQtOS4yMzkgMy41NTgtNi4zMDUgMi4zNDMtMTAuMzE3IDMuNTE0LTEyLjAzOCAzLjUxNC0xLjQxNyAwLTIuOTg2LS42OTQtNC43MDctMi4wODMtMS43Mi0xLjM3NC0yLjU4MS0yLjY2OC0yLjU4MS0zLjg4MyAwLTMuMTM4IDMuNDM0LTYuNzk3IDEwLjMwMy0xMC45NzcgNi40MzQtMy45NjMgMTEuMzQzLTUuOTQ0IDE0LjcyNy01Ljk0NCAxLjQxNyAwIDIuNjkuODEgMy44MTcgMi40M3YxLjgyMmMtLjU2NC44MS0xLjIxNCAxLjIxNS0xLjk1MiAxLjIxNS0uMTMgMC0uNjE0LS4xODEtMS40NTMtLjU0My0uODM5LS4zNzYtMS4zMzctLjU2NC0xLjQ5Ni0uNTY0LTEuMTI4IDAtMy43MTcgMS4xNjUtNy43NjUgMy40OTMtMy44MzIgMi4xNjktNi4zOTkgMy43ODktNy43IDQuODYtMS4zMDIgMS4xMjgtMi4xMzMgMi40MTUtMi40OTQgMy44NjEuOTgzLjkyNiAyLjE1NCAxLjM4OCAzLjUxMyAxLjM4OC43MDkgMCA0LjEwNy0xLjE3MSAxMC4xOTQtMy41MTQgNi4wNzMtMi4zNzIgOS43NDYtMy41NTggMTEuMDE5LTMuNTU4IDEuODY1IDAgMy4yMS40NyA0LjAzNCAxLjQxLjE4OC4yMTcuOTA0IDEuNDkgMi4xNDcgMy44MThhMjEuOTAyIDIxLjkwMiAwIDAgMCAzLjQwNSA0LjczWm02OS44ODQtMTkuNzQydjEuOTUzYy0yLjk5MyAyLjQ1OC01Ljg5OSA1LjIxMy04LjcxOSA4LjI2NS04Ljc2MiA5LjQ1OS0xNC42NjIgMTQuMTg4LTE3LjY5OSAxNC4xODgtMi40NTggMC01LjE5OC0yLjIyLTguMjItNi42NmwtNi41MjgtOS42NTRjLTEuMjQ0LjIwMy0zLjQ5MiAyLjQ2Ni02Ljc0NiA2Ljc5LTMuMDUxIDQuMTIyLTUuODQyIDYuMTgzLTguMzcyIDYuMTgzbC0xLjE5My0uNjI5Yy0uODgyLTEuNjItMi41MzgtNS4xNy00Ljk2Ny0xMC42NTItMi42NDYtNS45MjktMy45NjktOS4zMDYtMy45NjktMTAuMTMxIDAtMS40MzIuODQ2LTIuNjE4IDIuNTM4LTMuNTU4IDEuNjkxIDIuMDQgMy4zNTQgNC44NjcgNC45ODggOC40ODMgMi40NTggNS4zMzYgMy44NzUgOC4zMyA0LjI1MSA4Ljk4MWgyLjA2MWMuNTY0LTEuMDQxIDEuODk0LTIuNjU0IDMuOTkxLTQuODM4LjczNy0xLjEyOCAxLjgyMi0yLjY5IDMuMjUzLTQuNjg2IDEuNjc3LTIuMzcyIDMuMDk1LTMuNTU3IDQuMjUxLTMuNTU3IDEuMDcgMCAxLjk3NC41OTIgMi43MTEgMS43NzggMS4wNzEgMS42OTMgMi45ODYgNC43MyA1Ljc0OCA5LjExMiAzLjA1MSA0Ljc0NCA1LjMgNy4xMTYgNi43NDYgNy4xMTYgMS4wNyAwIDUuMDgyLTMuMDgxIDEyLjAzNy05LjI0MiA2Ljk3LTYuMTYxIDEwLjc5NS05LjI0MiAxMS40NzQtOS4yNDJoMi4zNjRabTQ0LjU3MyAxOS41MjVjLTEuMDQyIDEuMjQ0LTIuNDAxIDEuODY2LTQuMDc4IDEuODY2aC0xNC45ODhjLTMuNDcgMC03LjMzOC0uNTIxLTExLjYwNC0xLjU2Mi01LjU5NS0xLjM2LTguNzYyLTMuMDY3LTkuNS01LjEyLS44MDktMi4zMTQtMS4yMTQtNC41MzQtMS4yMTQtNi42NiAwLTQuMjY3IDEuODM2LTcuNTQzIDUuNTA5LTkuODI4IDMuMDk0LTEuOTgxIDYuODc2LTIuOTcyIDExLjM0NC0yLjk3MiAzLjg0NiAwIDYuODk3LjgzMiA5LjE1MyAyLjQ5NSAyLjI1NSAxLjY0OSAzLjM4MyAzLjg5OCAzLjM4MyA2Ljc0NyAwIDEuNTkxLTIuNzExIDMuMzI2LTguMTMzIDUuMjA2LTQuNDgzIDEuNjItNy43NDQgMi40My05Ljc4MiAyLjQzYTQuNDkgNC40OSAwIDAgMS0uNjczLS4wNDNjMCAuMzktLjMxMS44NjgtLjkzMiAxLjQzMi4zMzIuNTY0IDEuOTgxIDEuMjE0IDQuOTQ1IDEuOTUyIDIuNjYuNjggNC41NTUgMS4wMiA1LjY4MiAxLjAyaDEzLjQ3YzAgLjAyOSAxLjY5OS4yODIgNS4wOTcuNzU5IDEuMDcuNjggMS44NDMgMS40MzkgMi4zMjEgMi4yNzhabS0xNy40NjEtMTYuOWMtMS41NzYtMS42MDUtNC4xMTMtMi40MDgtNy42MTMtMi40MDgtMi40NTggMC00LjczNS40NzctNi44MzIgMS40MzItMi43MDQgMS4yNDQtNC4wNTYgMi45MjktNC4wNTYgNS4wNTUgMCAuNzUyLjI1MyAxLjI4Ny43NTkgMS42MDVoMi43MTFjLjk1NSAwIDMuNTUtLjgwMyA3Ljc4Ny0yLjLine truncated
---
<DefaultLayout title="Signatures" pageMenu="extra.signatures" pageLibs={['signature_pad']}>
@@ -74,7 +32,7 @@ document.querySelector("#signature-advanced-png").addEventListener("click", func
<input type="text" class="form-control" id="signature-last-name" name="last_name" />
</FormGroup>
<FormGroup label="Signature" required associate={false}>
<Signature id="default" clear extraJs={simpleJs} />
<Signature id="default" clear />
</FormGroup>
</form>
<div class="text-secondary fs-5">I agree that the signature and initials will be the electronic representation of my signature and initials for all purposes when I (or my agent) use them on documents, including legally binding contracts - just the same as a pen-and-paper signature or initial.</div>
@@ -96,7 +54,7 @@ document.querySelector("#signature-advanced-png").addEventListener("click", func
<CardBody>
<CardTitle>Advanced demo</CardTitle>
<Signature id="advanced" clear extraJs={advancedJs} />
<Signature id="advanced" clear />
<div class="mt-4">
<div class="row">
@@ -126,6 +84,49 @@ document.querySelector("#signature-advanced-png").addEventListener("click", func
</div>
</div>
<CaptureScript>
<!-- BEGIN SIGNATURE DEMO -->
<script is:inline define:vars={{ signatureImage }}>
function download(dataURL, filename) {
const a = document.createElement('a')
a.style.display = 'none'
a.href = dataURL
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
}
function initSignatureDemo() {
const signatures = window.tabler_signature ?? {}
signatures.default?.fromDataURL(signatureImage)
const advanced = signatures.advanced
if (!advanced) {
return
}
document.getElementById('signature-advanced-color')?.addEventListener('input', function (e) {
advanced.penColor = e.target.value
})
document.getElementById('signature-advanced-svg')?.addEventListener('click', function () {
download(advanced.toDataURL('image/svg+xml'), 'signature.svg')
})
document.getElementById('signature-advanced-png')?.addEventListener('click', function () {
download(advanced.toDataURL(), 'signature.png')
})
}
document.readyState !== 'loading' ? initSignatureDemo() : document.addEventListener('DOMContentLoaded', initSignatureDemo, { once: true })
</script>
<!-- END SIGNATURE DEMO -->
</CaptureScript>
<CaptureModal>
<div class="modal" tabindex="-1" id="modal-signature" role="dialog" aria-modal="true" aria-labelledby="modal-signature-save-title">
<div class="modal-dialog">
+11 -6
View File
@@ -42,7 +42,7 @@ if (size === 'sm') {
// The :root custom-property block stays a plain string rendered via set:html — it's
// inert CSS text (no injection surface), not executable code.
const style = data ? chartStyle({ id, data }) : '';
const { config, xFormatterExpr, yFormatterExpr } = data ? chartConfig({ id, data, height }) : { config: undefined, xFormatterExpr: undefined, yFormatterExpr: undefined };
const { config, xFormatter, yFormatter } = data ? chartConfig({ id, data, height }) : { config: undefined, xFormatter: undefined, yFormatter: undefined };
const chartKey = `chart-${id}`;
const elementId = `chart-${id}`;
---
@@ -54,9 +54,14 @@ const elementId = `chart-${id}`;
<Fragment set:html={style} />
<CaptureScript>
<!-- BEGIN CHART -->
<script is:inline define:vars={{ chartKey, elementId, config, xFormatterExpr, yFormatterExpr }}>
<script is:inline define:vars={{ chartKey, elementId, config, xFormatter, yFormatter }}>
window.tabler_chart ??= {};
const formatters = {
thousands: (val) => val.toLocaleString('en-US'),
'suffix-k': (val) => val + 'K',
};
// Heatmap and treemap shade each cell by computing off the literal base color in JS
// (light -> full color-mix by value), unlike other chart types where the
// SVG `fill` attribute just holds `var(--chart-...)` and the browser paints
@@ -85,12 +90,12 @@ const elementId = `chart-${id}`;
}
function initChart() {
if (xFormatterExpr && config.xaxis?.labels) {
config.xaxis.labels.formatter = new Function('val', `return (${xFormatterExpr})`);
if (Object.hasOwn(formatters, xFormatter) && config.xaxis?.labels) {
config.xaxis.labels.formatter = formatters[xFormatter];
}
if (yFormatterExpr && config.yaxis?.labels) {
config.yaxis.labels.formatter = new Function('val', `return (${yFormatterExpr})`);
if (Object.hasOwn(formatters, yFormatter) && config.yaxis?.labels) {
config.yaxis.labels.formatter = formatters[yFormatter];
}
if ((config.chart?.type === 'heatmap' || config.chart?.type === 'treemap') && Array.isArray(config.colors)) {
+4 -4
View File
@@ -35,7 +35,7 @@
"height": 18,
"datetime": true,
"stroke-curve": "straight",
"y-formatter": "val.toLocaleString(\"en-US\")",
"y-formatter": "thousands",
"series": [
{
"name": "Mobile",
@@ -72,7 +72,7 @@
"horizontal": true,
"stacked": true,
"categories": [2019, 2020, 2021, 2022, 2023, 2024, 2025],
"x-formatter": "val + \"K\"",
"x-formatter": "suffix-k",
"series": [
{
"name": "North America",
@@ -122,7 +122,7 @@
"type": "line",
"datetime": true,
"stroke-curve": "straight",
"y-formatter": "val.toLocaleString(\"en-US\")",
"y-formatter": "thousands",
"series": [
{
"name": "Total visits",
@@ -302,7 +302,7 @@
"stacked": true,
"legend": true,
"name": "Semantic colors",
"y-formatter": "val.toLocaleString(\"en-US\")",
"y-formatter": "thousands",
"categories": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"series": [
{ "name": "Hit", "color": "success", "data": [18000, 19200, 21000, 20300, 22000, 16600, 15000] },
+15 -9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { chartStyle, chartConfig, type ChartData } from './chart-script'
import charts from '../data/charts.json'
import { chartStyle, chartConfig, chartFormatters, type ChartData } from './chart-script'
describe('chartStyle', () => {
it('emits an id-scoped color custom property per serie', () => {
@@ -131,16 +132,21 @@ describe('chartConfig', () => {
})
})
it('returns the x-formatter as a separate expression instead of embedding it', () => {
const { config, xFormatterExpr } = chartConfig({ id: 'demo', data: { 'series': [], 'x-formatter': 'val + "K"' }, height: 10 })
expect(xFormatterExpr).toBe('val + "K"')
expect(JSON.stringify(config)).not.toContain('val + "K"')
it('returns the formatter names separately instead of embedding them', () => {
const { config, xFormatter, yFormatter } = chartConfig({ id: 'demo', data: { 'series': [], 'x-formatter': 'suffix-k', 'y-formatter': 'thousands' }, height: 10 })
expect(xFormatter).toBe('suffix-k')
expect(yFormatter).toBe('thousands')
expect(JSON.stringify(config)).not.toMatch(/suffix-k|thousands/)
})
it('returns the y-formatter as a separate expression instead of embedding it', () => {
const { config, yFormatterExpr } = chartConfig({ id: 'demo', data: { 'series': [], 'y-formatter': 'val.toLocaleString("en-US")' }, height: 10 })
expect(yFormatterExpr).toBe('val.toLocaleString("en-US")')
expect(JSON.stringify(config)).not.toContain('toLocaleString')
it('only uses known formatter names in charts.json', () => {
const used = Object.values(charts as Record<string, Record<string, unknown>>)
.flatMap((chart) => [chart['x-formatter'], chart['y-formatter']])
.filter((name) => name !== undefined)
expect(used.length).toBeGreaterThan(0)
for (const name of used) {
expect(chartFormatters).toContain(name)
}
})
it('uses the ApexCharts 7 legend marker options', () => {
+11 -11
View File
@@ -2,6 +2,10 @@
// inert, not executable). chartConfig() returns a JSON-serializable config object,
// rendered by Chart.astro via <script define:vars>.
/** Named axis label formatters, implemented in Chart.astro. */
export const chartFormatters = ['thousands', 'suffix-k'] as const
export type ChartFormatter = (typeof chartFormatters)[number]
type Serie = {
'name'?: string
/** overrides the chart type for this one serie — how a combination chart is built */
@@ -69,8 +73,8 @@ export type ChartData = {
'stroke-curve'?: string
'hide-grid'?: boolean
'show-x'?: boolean
'x-formatter'?: string
'y-formatter'?: string
'x-formatter'?: ChartFormatter
'y-formatter'?: ChartFormatter
'y-max'?: number
'y-title'?: string
'y-tooltip'?: boolean
@@ -173,15 +177,11 @@ export function chartStyle(opts: { id: string; data: ChartData }): string {
/**
* The ApexCharts config, as a real JSON-serializable object (rendered via
* <script define:vars> — no string-built script). The one exception is
* `x-formatter` / `y-formatter`: a handful of charts.json entries carry a raw JS
* expression for an axis label formatter (e.g. `val + "K"`), which can't be represented
* as data. It comes back as `xFormatterExpr` / `yFormatterExpr` instead of being embedded in `config`,
* for the caller to turn into a real function with `new Function` — see
* Chart.astro. This is data-driven (from our own charts.json, not user input),
* same trust level as everything else here.
* <script define:vars> — no string-built script). Axis label formatters can't be
* represented as data, so `x-formatter` / `y-formatter` name one of `chartFormatters`
* and come back as `xFormatter` / `yFormatter`, for Chart.astro to map to a real function.
*/
export function chartConfig(opts: { id: string; data: ChartData; height: number }): { config: Record<string, unknown>; xFormatterExpr?: string | undefined; yFormatterExpr?: string | undefined } {
export function chartConfig(opts: { id: string; data: ChartData; height: number }): { config: Record<string, unknown>; xFormatter?: ChartFormatter | undefined; yFormatter?: ChartFormatter | undefined } {
const { id, data, height } = opts
const type = data.type ?? 'bar'
const series = data.series ?? []
@@ -468,5 +468,5 @@ export function chartConfig(opts: { id: string; data: ChartData; height: number
config.markers = { size: 2 }
}
return { config, xFormatterExpr: data['x-formatter'], yFormatterExpr: data['y-formatter'] }
return { config, xFormatter: data['x-formatter'], yFormatter: data['y-formatter'] }
}
+57 -54
View File
@@ -9,75 +9,78 @@ interface Props {
width?: number
height?: number
event?: string
/** raw JS appended inside the init callback */
extraJs?: string
}
const { id, class: className, clear, width = 400, height = 400, event = 'DOMContentLoaded', extraJs } = Astro.props
const { id, class: className, clear = false, width = 400, height = 400, event = 'DOMContentLoaded' } = Astro.props
const signatureClass = ['signature position-relative', className]
const clearSnippet = clear
? ` document.querySelector(${JSON.stringify(`#signature-${id}-clear`)}).addEventListener("click", function () {
signaturePad.clear();
});
`
: ''
// extraJs is a documented escape hatch for raw JS (see Props) — not user data, so it
// stays interpolated verbatim rather than JSON-serialized.
const extraSnippet = extraJs
? `${extraJs}
`
: ''
// set:html (below) is genuinely required here: extraJs is a documented raw-JS escape
// hatch that must land *inside* the init callback body (it references canvas /
// signaturePad), so the whole script has to be assembled as text — <script define:vars>
// can only inject variables, not splice caller-supplied code into the function body.
const script = `<!-- BEGIN SIGNATURE PAD -->
<script>
document.addEventListener(${JSON.stringify(event)}, function () {
const canvas = document.getElementById(${JSON.stringify(`signature-${id}`)});
if (canvas) {
const signaturePad = new SignaturePad(canvas, {
backgroundColor: "transparent",
penColor: getComputedStyle(canvas).color
});
${clearSnippet}
function resizeCanvas() {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext("2d").scale(ratio, ratio);
signaturePad.fromData(signaturePad.toData());
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
${extraSnippet}
}
});
</script>
<!-- END SIGNATURE PAD -->`
const signatureKey = id
const canvasId = `signature-${id}`
const clearId = `signature-${id}-clear`
---
<div class:list={signatureClass}>
{
clear && (
<div class="position-absolute top-0 end-0 p-2">
<button type="button" class="btn btn-icon" id={`signature-${id}-clear`} title="Clear signature" aria-label="Clear signature" data-bs-toggle="tooltip">
<button type="button" class="btn btn-icon" id={clearId} title="Clear signature" aria-label="Clear signature" data-bs-toggle="tooltip">
<Icon name="trash" />{' '}
</button>
</div>
)
}
<canvas id={`signature-${id}`} width={width} height={height} class="signature-canvas"></canvas>
<canvas id={canvasId} width={width} height={height} class="signature-canvas"></canvas>
</div>
<CaptureScript>
<Fragment set:html={script} />
<!-- BEGIN SIGNATURE PAD -->
<script is:inline define:vars={{ signatureKey, canvasId, clearId, clear, event }}>
window.tabler_signature ??= {}
function resizeCanvas(canvas, signaturePad) {
const ratio = Math.max(window.devicePixelRatio || 1, 1)
canvas.width = canvas.offsetWidth * ratio
canvas.height = canvas.offsetHeight * ratio
canvas.getContext('2d').scale(ratio, ratio)
signaturePad.fromData(signaturePad.toData())
}
function initSignature() {
const canvas = document.getElementById(canvasId)
if (!canvas) {
return
}
let signaturePad = window.tabler_signature[signatureKey]
if (!signaturePad) {
signaturePad = new SignaturePad(canvas, {
backgroundColor: 'transparent',
penColor: getComputedStyle(canvas).color,
})
window.tabler_signature[signatureKey] = signaturePad
if (clear) {
document.getElementById(clearId)?.addEventListener('click', function () {
signaturePad.clear()
})
}
window.addEventListener('resize', function () {
resizeCanvas(canvas, signaturePad)
})
}
resizeCanvas(canvas, signaturePad)
}
if (event === 'DOMContentLoaded') {
document.readyState !== 'loading' ? initSignature() : document.addEventListener('DOMContentLoaded', initSignature, { once: true })
} else {
document.addEventListener(event, initSignature)
}
</script>
<!-- END SIGNATURE PAD -->
</CaptureScript>