diff --git a/blog/manifest.gen.ts b/blog/manifest.gen.ts
index 8ccb0c8ce..671d20431 100644
--- a/blog/manifest.gen.ts
+++ b/blog/manifest.gen.ts
@@ -38,7 +38,8 @@ import * as $$$$$$14 from "./sections/blocks/Quote.tsx";
import * as $$$$$$15 from "./sections/blocks/Stat.tsx";
import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx";
import * as $$$$$$17 from "./sections/blocks/Steps.tsx";
-import * as $$$$$$18 from "./sections/blocks/Video.tsx";
+import * as $$$$$$18 from "./sections/blocks/Table.tsx";
+import * as $$$$$$19 from "./sections/blocks/Video.tsx";
import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx";
import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx";
import * as $$$$$$2 from "./sections/Template.tsx";
@@ -80,7 +81,8 @@ const manifest = {
"blog/sections/blocks/Stat.tsx": $$$$$$15,
"blog/sections/blocks/StatGroup.tsx": $$$$$$16,
"blog/sections/blocks/Steps.tsx": $$$$$$17,
- "blog/sections/blocks/Video.tsx": $$$$$$18,
+ "blog/sections/blocks/Table.tsx": $$$$$$18,
+ "blog/sections/blocks/Video.tsx": $$$$$$19,
"blog/sections/Seo/SeoBlogPost.tsx": $$$$$$0,
"blog/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1,
"blog/sections/Template.tsx": $$$$$$2,
diff --git a/blog/sections/blocks/Table.tsx b/blog/sections/blocks/Table.tsx
new file mode 100644
index 000000000..b8632855c
--- /dev/null
+++ b/blog/sections/blocks/Table.tsx
@@ -0,0 +1,76 @@
+import { hardSanitize } from "../../utils/hardSanitize.ts";
+
+export interface Props {
+ /** JSON-encoded string[] — optional header row */
+ headers?: string[] | string;
+ /** JSON-encoded string[][] — body rows × cells */
+ rows?: string[][] | string;
+}
+
+function parseHeaders(value: string[] | string | undefined): string[] {
+ if (Array.isArray(value)) return value.map((c) => String(c ?? ""));
+ if (typeof value === "string") {
+ try {
+ const parsed = JSON.parse(value);
+ if (Array.isArray(parsed)) return parsed.map((c) => String(c ?? ""));
+ } catch { /* ignore */ }
+ }
+ return [];
+}
+
+function parseRows(value: string[][] | string | undefined): string[][] {
+ const toRow = (row: unknown): string[] =>
+ Array.isArray(row) ? row.map((c) => String(c ?? "")) : [];
+
+ if (Array.isArray(value)) return value.map(toRow);
+ if (typeof value === "string") {
+ try {
+ const parsed = JSON.parse(value);
+ if (Array.isArray(parsed)) return parsed.map(toRow);
+ } catch { /* ignore */ }
+ }
+ return [];
+}
+
+export default function Table({ headers, rows }: Props) {
+ const head = parseHeaders(headers);
+ const body = parseRows(rows);
+
+ if (head.length === 0 && body.length === 0) return null;
+
+ const cellClass =
+ "px-4 py-3 text-sm leading-normal align-top [&_a]:text-accent [&_a]:underline [&_strong]:font-semibold [&_strong]:text-base";
+
+ return (
+
+
+ {head.length > 0 && (
+
+
+ {head.map((cell, i) => (
+ |
+ ))}
+
+
+ )}
+
+ {body.map((row, r) => (
+
+ {row.map((cell, c) => (
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/blog/utils/blocksToSections.ts b/blog/utils/blocksToSections.ts
index 8aef79ac5..54f1c78c0 100644
--- a/blog/utils/blocksToSections.ts
+++ b/blog/utils/blocksToSections.ts
@@ -120,6 +120,12 @@ function blockToSection(
right: content.right,
});
+ case "table":
+ return toSection(`${BASE}/Table.tsx`, {
+ headers: content.headers,
+ rows: content.rows,
+ });
+
case "image":
return toSection(`${BASE}/BlockImage.tsx`, {
url: content.url,
diff --git a/blog/utils/hardSanitize.ts b/blog/utils/hardSanitize.ts
new file mode 100644
index 000000000..62e75c3f4
--- /dev/null
+++ b/blog/utils/hardSanitize.ts
@@ -0,0 +1,109 @@
+/**
+ * Stricter, dependency-free HTML sanitizer for blocks that render CMS-provided
+ * markup in a table-like context (see Table.tsx). It is intentionally separate
+ * from the shared `sanitizeHtml` so hardening this path cannot regress the many
+ * other blocks that rely on the lighter sanitizer.
+ *
+ * On top of the shared sanitizer's guarantees it also:
+ * - Removes dangerous elements together with their content (script, style,
+ * iframe, object, embed, applet, form, svg, math, template, noscript, base,
+ * link, meta, frame, frameset), plus any leftover open/close/self-closing tags
+ * - Strips inline event-handler attributes (on*), srcdoc and inline style
+ * - Neutralizes javascript:, data: and vbscript: protocols in url-bearing
+ * attributes (href, src, action, formaction, xlink:href), decoding entities
+ * and stripping whitespace/control chars first so obfuscated schemes are
+ * caught, while harmless values like "data-*" or "javascriptX" are preserved
+ *
+ * Note: this is a pragmatic denylist — not a full HTML parser. Keep the content
+ * model simple (text + basic inline/formatting markup).
+ */
+const DANGEROUS_ELEMENTS = [
+ "script",
+ "style",
+ "iframe",
+ "object",
+ "embed",
+ "applet",
+ "form",
+ "svg",
+ "math",
+ "template",
+ "noscript",
+ "base",
+ "link",
+ "meta",
+ "frame",
+ "frameset",
+];
+
+const URL_ATTRS = "href|src|action|formaction|xlink:href";
+const DANGEROUS_PROTOCOLS = "javascript|data|vbscript";
+
+// Captures a url-bearing attribute and its value (double/single-quoted or bare).
+const URL_ATTR_RE = new RegExp(
+ `\\b(${URL_ATTRS})\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`,
+ "gi",
+);
+// A dangerous scheme must be a real scheme: name immediately followed by ":".
+const DANGEROUS_SCHEME_RE = new RegExp(`^(?:${DANGEROUS_PROTOCOLS}):`, "i");
+
+function toCodePoint(n: number): string {
+ return Number.isFinite(n) && n >= 0 && n <= 0x10ffff
+ ? String.fromCodePoint(n)
+ : "";
+}
+
+/** Decode the HTML entities most commonly used to smuggle a scheme past a filter. */
+function decodeEntities(value: string): string {
+ return value
+ .replace(/([0-9a-f]+);?/gi, (_, hex) => toCodePoint(parseInt(hex, 16)))
+ .replace(/(\d+);?/g, (_, dec) => toCodePoint(parseInt(dec, 10)))
+ .replace(/:/gi, ":")
+ .replace(/&tab;/gi, "\t")
+ .replace(/&newline;/gi, "\n");
+}
+
+/**
+ * True when an attribute value resolves to a dangerous URL scheme. The value is
+ * decoded and stripped of whitespace/control chars first, since browsers ignore
+ * those within a scheme (e.g. `java\tscript:` and `javascript:`).
+ */
+function hasDangerousScheme(value: string): boolean {
+ const normalized = decodeEntities(value)
+ // Control chars are intentional: browsers strip C0 controls/whitespace from
+ // a URL scheme, so an attacker can hide one inside `javascript:`.
+ // deno-lint-ignore no-control-regex
+ .replace(/[\s\u0000-\u001f]+/g, "")
+ .toLowerCase();
+ return DANGEROUS_SCHEME_RE.test(normalized);
+}
+
+export function hardSanitize(raw: string | null | undefined): string {
+ if (!raw) return "";
+
+ let html = raw;
+
+ for (const tag of DANGEROUS_ELEMENTS) {
+ // Remove the element with its content, then any stray open/close/self-closing tag.
+ html = html
+ .replace(new RegExp(`<${tag}\\b[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "")
+ .replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, "gi"), "");
+ }
+
+ return html
+ // Inline event handlers (onclick, onerror, …)
+ .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
+ // srcdoc (smuggles an inline document into iframes) and inline styles
+ .replace(/\s+(srcdoc|style)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
+ // Neutralize dangerous protocols in url-bearing attributes. One pass handles
+ // quoted and unquoted values identically: decode/normalize, then require a
+ // real dangerous scheme followed by ":" (so harmless "data-*"/"javascriptX"
+ // values are left intact).
+ .replace(URL_ATTR_RE, (match, attr, value) => {
+ const quote = value[0] === '"' || value[0] === "'" ? value[0] : "";
+ const inner = quote ? value.slice(1, -1) : value;
+ if (!hasDangerousScheme(inner)) return match;
+ const q = quote || '"';
+ return `${attr}=${q}#${q}`;
+ });
+}
diff --git a/deno.json b/deno.json
index 42cd13bc0..302330e24 100644
--- a/deno.json
+++ b/deno.json
@@ -56,7 +56,8 @@
"exclude": [
"static",
"README.md",
- "**/README.md"
+ "**/README.md",
+ ".context"
],
"compilerOptions": {
"jsx": "react-jsx",