Skip to content

Commit e99bde5

Browse files
aka-sacci-ccrdecobotclaude
authored
feat(blog): add Table block (#1634)
* feat(blog): add ProductShelf and Table blocks Add two native blogpost content blocks so editors no longer need manual HTML: - ProductShelf: renders a resolved product list (title + Product[] from a productList loader ref) as a responsive grid of cards with image, name, price and link. - Table: renders header/body rows (tolerant to native arrays or JSON strings), sanitizing cell HTML for inline formatting. Wire the Table into the Spire path (blocksToSections.ts) and register both blocks in the generated manifest. ProductShelf is loader-backed, so it is composed exclusively via the studio editor. Highlight (DECO-5397) is dropped: already covered by the existing Callout block. Refs DECO-5395, DECO-5396 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(blog): map product-shelf in blocksToSections Add a product-shelf case to the Spire block converter so a vitrine block in the content vocabulary is mapped to ProductShelf.tsx instead of being silently dropped by the default branch. Mirrors the other cases: it forwards title and products (a productList loader ref resolved by deco, or an already resolved Product[]). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(blog): drop ProductShelf block, keep Table Remove the ProductShelf block (component, Spire converter case and manifest entry). Scope narrowed to the Table block only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(blog): harden sanitizeHtml denylist against more XSS vectors The regex sanitizer used by every blog block (including the new Table block) only stripped script/style/on*/javascript:/data:. Extend the shared, dependency-free sanitizer to also drop iframe, object, embed, applet, form, svg, math and other dangerous elements with their content, strip srcdoc and inline style attributes, and neutralize javascript:/data:/vbscript: protocols in more url-bearing attributes (incl. unquoted values). Kept as a hardened denylist rather than a parser-based sanitizer to preserve the util's no-dependency, SSR+browser contract and stay consistent across all blocks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(blog): normalize URL values before scheme checks in sanitizeHtml The url-attribute regexes matched raw protocol text, which both overmatched harmless quoted values (e.g. href="data-sheet.pdf", src="javascript-x.png") because no ":" was required, and missed obfuscated schemes (whitespace/control chars or HTML entities inside "javascript:"). Replace the three protocol regexes with a single pass over url-bearing attributes that decodes entities, strips whitespace/control chars, lowercases, then requires a real dangerous scheme followed by ":". Quoted and unquoted values now go through identical logic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(blog): isolate strict sanitizer for Table, revert shared sanitizeHtml Revert blog/utils/sanitizeHtml.ts to its original form so the many blocks that import it are unaffected. Move the hardened denylist logic into a dedicated blog/utils/sanitizeHtmlStrict.ts and use it only in Table.tsx. sanitizeHtmlStrict removes dangerous elements with their content (iframe, object, embed, svg, form, …), strips on*/srcdoc/style attributes, and neutralizes javascript:/data:/vbscript: protocols in url-bearing attributes — handling quoted and unquoted values identically and decoding/normalizing the value first to catch obfuscated schemes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(blog): rename strict sanitizer to hardSanitize Rename blog/utils/sanitizeHtmlStrict.ts -> hardSanitize.ts and its export sanitizeHtmlStrict -> hardSanitize, updating the Table block import. No behavior change; sanitizeHtml stays untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(blog): regenerate manifest via deco bundler The manifest was hand-edited when adding the Table block, so its import identifiers/ordering did not match the deco generator output and CI's "no uncommitted changes" check failed after Bundle Apps. Regenerated with `deno run -A jsr:@deco/deco/scripts/bundle` so Table/Video indices match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: exclude .context from deno fmt/lint/check Conductor's gitignored .context scratch dir (attachments, plans) was tripping `deno task check` and the pre-commit githook locally. It is not part of the repo and not present in CI, so excluding it keeps local checks green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: decobot <capy@deco.cx> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2e69c15 commit e99bde5

5 files changed

Lines changed: 197 additions & 3 deletions

File tree

blog/manifest.gen.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import * as $$$$$$14 from "./sections/blocks/Quote.tsx";
3838
import * as $$$$$$15 from "./sections/blocks/Stat.tsx";
3939
import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx";
4040
import * as $$$$$$17 from "./sections/blocks/Steps.tsx";
41-
import * as $$$$$$18 from "./sections/blocks/Video.tsx";
41+
import * as $$$$$$18 from "./sections/blocks/Table.tsx";
42+
import * as $$$$$$19 from "./sections/blocks/Video.tsx";
4243
import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx";
4344
import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx";
4445
import * as $$$$$$2 from "./sections/Template.tsx";
@@ -80,7 +81,8 @@ const manifest = {
8081
"blog/sections/blocks/Stat.tsx": $$$$$$15,
8182
"blog/sections/blocks/StatGroup.tsx": $$$$$$16,
8283
"blog/sections/blocks/Steps.tsx": $$$$$$17,
83-
"blog/sections/blocks/Video.tsx": $$$$$$18,
84+
"blog/sections/blocks/Table.tsx": $$$$$$18,
85+
"blog/sections/blocks/Video.tsx": $$$$$$19,
8486
"blog/sections/Seo/SeoBlogPost.tsx": $$$$$$0,
8587
"blog/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1,
8688
"blog/sections/Template.tsx": $$$$$$2,

blog/sections/blocks/Table.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { hardSanitize } from "../../utils/hardSanitize.ts";
2+
3+
export interface Props {
4+
/** JSON-encoded string[] — optional header row */
5+
headers?: string[] | string;
6+
/** JSON-encoded string[][] — body rows × cells */
7+
rows?: string[][] | string;
8+
}
9+
10+
function parseHeaders(value: string[] | string | undefined): string[] {
11+
if (Array.isArray(value)) return value.map((c) => String(c ?? ""));
12+
if (typeof value === "string") {
13+
try {
14+
const parsed = JSON.parse(value);
15+
if (Array.isArray(parsed)) return parsed.map((c) => String(c ?? ""));
16+
} catch { /* ignore */ }
17+
}
18+
return [];
19+
}
20+
21+
function parseRows(value: string[][] | string | undefined): string[][] {
22+
const toRow = (row: unknown): string[] =>
23+
Array.isArray(row) ? row.map((c) => String(c ?? "")) : [];
24+
25+
if (Array.isArray(value)) return value.map(toRow);
26+
if (typeof value === "string") {
27+
try {
28+
const parsed = JSON.parse(value);
29+
if (Array.isArray(parsed)) return parsed.map(toRow);
30+
} catch { /* ignore */ }
31+
}
32+
return [];
33+
}
34+
35+
export default function Table({ headers, rows }: Props) {
36+
const head = parseHeaders(headers);
37+
const body = parseRows(rows);
38+
39+
if (head.length === 0 && body.length === 0) return null;
40+
41+
const cellClass =
42+
"px-4 py-3 text-sm leading-normal align-top [&_a]:text-accent [&_a]:underline [&_strong]:font-semibold [&_strong]:text-base";
43+
44+
return (
45+
<div class="my-8 border border-line rounded-brand overflow-x-auto">
46+
<table class="w-full border-collapse text-left">
47+
{head.length > 0 && (
48+
<thead>
49+
<tr class="bg-alt">
50+
{head.map((cell, i) => (
51+
<th
52+
key={i}
53+
class="px-4 py-3 text-xs font-semibold tracking-caps uppercase border-b-2 border-line"
54+
dangerouslySetInnerHTML={{ __html: hardSanitize(cell) }}
55+
/>
56+
))}
57+
</tr>
58+
</thead>
59+
)}
60+
<tbody>
61+
{body.map((row, r) => (
62+
<tr key={r} class="border-b border-line-subtle last:border-b-0">
63+
{row.map((cell, c) => (
64+
<td
65+
key={c}
66+
class={cellClass}
67+
dangerouslySetInnerHTML={{ __html: hardSanitize(cell) }}
68+
/>
69+
))}
70+
</tr>
71+
))}
72+
</tbody>
73+
</table>
74+
</div>
75+
);
76+
}

blog/utils/blocksToSections.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ function blockToSection(
120120
right: content.right,
121121
});
122122

123+
case "table":
124+
return toSection(`${BASE}/Table.tsx`, {
125+
headers: content.headers,
126+
rows: content.rows,
127+
});
128+
123129
case "image":
124130
return toSection(`${BASE}/BlockImage.tsx`, {
125131
url: content.url,

blog/utils/hardSanitize.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Stricter, dependency-free HTML sanitizer for blocks that render CMS-provided
3+
* markup in a table-like context (see Table.tsx). It is intentionally separate
4+
* from the shared `sanitizeHtml` so hardening this path cannot regress the many
5+
* other blocks that rely on the lighter sanitizer.
6+
*
7+
* On top of the shared sanitizer's guarantees it also:
8+
* - Removes dangerous elements together with their content (script, style,
9+
* iframe, object, embed, applet, form, svg, math, template, noscript, base,
10+
* link, meta, frame, frameset), plus any leftover open/close/self-closing tags
11+
* - Strips inline event-handler attributes (on*), srcdoc and inline style
12+
* - Neutralizes javascript:, data: and vbscript: protocols in url-bearing
13+
* attributes (href, src, action, formaction, xlink:href), decoding entities
14+
* and stripping whitespace/control chars first so obfuscated schemes are
15+
* caught, while harmless values like "data-*" or "javascriptX" are preserved
16+
*
17+
* Note: this is a pragmatic denylist — not a full HTML parser. Keep the content
18+
* model simple (text + basic inline/formatting markup).
19+
*/
20+
const DANGEROUS_ELEMENTS = [
21+
"script",
22+
"style",
23+
"iframe",
24+
"object",
25+
"embed",
26+
"applet",
27+
"form",
28+
"svg",
29+
"math",
30+
"template",
31+
"noscript",
32+
"base",
33+
"link",
34+
"meta",
35+
"frame",
36+
"frameset",
37+
];
38+
39+
const URL_ATTRS = "href|src|action|formaction|xlink:href";
40+
const DANGEROUS_PROTOCOLS = "javascript|data|vbscript";
41+
42+
// Captures a url-bearing attribute and its value (double/single-quoted or bare).
43+
const URL_ATTR_RE = new RegExp(
44+
`\\b(${URL_ATTRS})\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`,
45+
"gi",
46+
);
47+
// A dangerous scheme must be a real scheme: name immediately followed by ":".
48+
const DANGEROUS_SCHEME_RE = new RegExp(`^(?:${DANGEROUS_PROTOCOLS}):`, "i");
49+
50+
function toCodePoint(n: number): string {
51+
return Number.isFinite(n) && n >= 0 && n <= 0x10ffff
52+
? String.fromCodePoint(n)
53+
: "";
54+
}
55+
56+
/** Decode the HTML entities most commonly used to smuggle a scheme past a filter. */
57+
function decodeEntities(value: string): string {
58+
return value
59+
.replace(/&#x([0-9a-f]+);?/gi, (_, hex) => toCodePoint(parseInt(hex, 16)))
60+
.replace(/&#(\d+);?/g, (_, dec) => toCodePoint(parseInt(dec, 10)))
61+
.replace(/&colon;/gi, ":")
62+
.replace(/&tab;/gi, "\t")
63+
.replace(/&newline;/gi, "\n");
64+
}
65+
66+
/**
67+
* True when an attribute value resolves to a dangerous URL scheme. The value is
68+
* decoded and stripped of whitespace/control chars first, since browsers ignore
69+
* those within a scheme (e.g. `java\tscript:` and `&#106;avascript:`).
70+
*/
71+
function hasDangerousScheme(value: string): boolean {
72+
const normalized = decodeEntities(value)
73+
// Control chars are intentional: browsers strip C0 controls/whitespace from
74+
// a URL scheme, so an attacker can hide one inside `javascript:`.
75+
// deno-lint-ignore no-control-regex
76+
.replace(/[\s\u0000-\u001f]+/g, "")
77+
.toLowerCase();
78+
return DANGEROUS_SCHEME_RE.test(normalized);
79+
}
80+
81+
export function hardSanitize(raw: string | null | undefined): string {
82+
if (!raw) return "";
83+
84+
let html = raw;
85+
86+
for (const tag of DANGEROUS_ELEMENTS) {
87+
// Remove the element with its content, then any stray open/close/self-closing tag.
88+
html = html
89+
.replace(new RegExp(`<${tag}\\b[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "")
90+
.replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, "gi"), "");
91+
}
92+
93+
return html
94+
// Inline event handlers (onclick, onerror, …)
95+
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
96+
// srcdoc (smuggles an inline document into iframes) and inline styles
97+
.replace(/\s+(srcdoc|style)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
98+
// Neutralize dangerous protocols in url-bearing attributes. One pass handles
99+
// quoted and unquoted values identically: decode/normalize, then require a
100+
// real dangerous scheme followed by ":" (so harmless "data-*"/"javascriptX"
101+
// values are left intact).
102+
.replace(URL_ATTR_RE, (match, attr, value) => {
103+
const quote = value[0] === '"' || value[0] === "'" ? value[0] : "";
104+
const inner = quote ? value.slice(1, -1) : value;
105+
if (!hasDangerousScheme(inner)) return match;
106+
const q = quote || '"';
107+
return `${attr}=${q}#${q}`;
108+
});
109+
}

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"exclude": [
5757
"static",
5858
"README.md",
59-
"**/README.md"
59+
"**/README.md",
60+
".context"
6061
],
6162
"compilerOptions": {
6263
"jsx": "react-jsx",

0 commit comments

Comments
 (0)