Skip to content
Merged
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
12 changes: 11 additions & 1 deletion apps/staged/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,17 @@
--ui-danger-bg: rgba(248, 81, 73, 0.1);
--ui-selection: rgba(255, 255, 255, 0.08);

--diagram-canvas-bg: #ffffff;
--diagram-canvas-bg: #f2edf8;
--pikchr-ink: #241a2f;
--pikchr-surface: #fffaff;
--pikchr-muted: #74677f;
--pikchr-red: #ec91a0;
--pikchr-green: #80cd99;
--pikchr-blue: #8bbaed;
--pikchr-yellow: #ecd285;
--pikchr-orange: #ebae7f;
--pikchr-purple: #c4a6f4;
--pikchr-cyan: #80d5e1;

--scrollbar-thumb: #47424d;
--scrollbar-thumb-hover: #5d5962;
Expand Down
2 changes: 1 addition & 1 deletion apps/staged/src/lib/shared/markdown/diagramRendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function renderMarkdownDiagramCodeBlock(
if (!renderedPikchr || renderedPikchr.kind !== 'svg') {
return { html: renderedDiagramSource, trustedHtml: false };
}
const renderedSvg = sanitizePikchrSvg(renderedPikchr.svg);
const renderedSvg = sanitizePikchrSvg(renderedPikchr.svg, { source: token.text });
if (!renderedSvg) {
return { html: renderedDiagramSource, trustedHtml: false };
}
Expand Down
56 changes: 53 additions & 3 deletions apps/staged/src/lib/shared/markdown/pikchrRendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { loadPikchrRenderer, sanitizePikchrSvg } from './pikchrRendering';

describe('sanitizePikchrSvg', () => {
it('keeps static Pikchr geometry and path styles', () => {
it('keeps static Pikchr geometry and applies the themed default ink', () => {
const svg = sanitizePikchrSvg(
[
'<svg xmlns="http://www.w3.org/2000/svg" class="markdown-pikchr-svg" viewBox="0 0 58 34" data-pikchr-date="20260403102956">',
Expand All @@ -16,9 +16,9 @@ describe('sanitizePikchrSvg', () => {
expect(svg).toContain('<svg');
expect(svg).toContain('viewBox="0 0 58 34"');
expect(svg).toContain('<path');
expect(svg).toContain('style="fill:none;stroke-width:2.16;stroke:rgb(0,0,0)"');
expect(svg).toContain('style="fill:none;stroke-width:2.16;stroke:var(--pikchr-ink)"');
expect(svg).toContain('<text');
expect(svg).toContain('fill="rgb(0,0,0)"');
expect(svg).toContain('fill="var(--pikchr-ink)"');
expect(svg).not.toContain('data-pikchr-date');
});

Expand All @@ -40,6 +40,55 @@ describe('sanitizePikchrSvg', () => {
expect(svg).not.toContain('url(');
});

it('maps common Pikchr colors onto the themed palette', () => {
const svg = sanitizePikchrSvg(
[
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40">',
'<path d="M1,1L119,1" style="stroke:rgb(255,0,0);fill:white" />',
'<rect x="1" y="1" width="10" height="10" fill="yellow" stroke="green" />',
'<text x="20" y="20" fill="blue">Label</text>',
'</svg>',
].join('')
);

expect(svg).toContain('stroke:var(--pikchr-red)');
expect(svg).toContain('fill:var(--pikchr-surface)');
expect(svg).toContain('fill="var(--pikchr-yellow)"');
expect(svg).toContain('stroke="var(--pikchr-green)"');
expect(svg).toContain('fill="var(--pikchr-blue)"');
});

it('preserves numeric Pikchr colors from the source', () => {
const svg = sanitizePikchrSvg(
[
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40">',
'<path d="M1,1L119,1" style="stroke:rgb(255,0,0);fill:rgb(255,255,255)" />',
'<text x="20" y="20" fill="rgb(0,0,0)">Label</text>',
'</svg>',
].join(''),
{
source: 'box "Numeric colors" color 0xff0000 fill 0xffffff',
}
);

expect(svg).toContain('stroke:rgb(255,0,0)');
expect(svg).toContain('fill:rgb(255,255,255)');
expect(svg).toContain('fill="var(--pikchr-ink)"');
});

it('keeps fill none as no paint', () => {
const svg = sanitizePikchrSvg(
[
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">',
'<path d="M1,1L19,19" fill="none" stroke="rgb(0,0,0)" />',
'</svg>',
].join('')
);

expect(svg).toContain('fill="none"');
expect(svg).toContain('stroke="var(--pikchr-ink)"');
});

it('adds breathing room to side-anchored Pikchr text labels', () => {
const svg = sanitizePikchrSvg(
[
Expand Down Expand Up @@ -106,6 +155,7 @@ describe('loadPikchrRenderer', () => {
expect(rendered.svg).toContain('class="markdown-pikchr-svg"');
expect(rendered.svg).toContain('<path');
expect(rendered.svg).toContain('Start');
expect(rendered.svg).toContain('var(--pikchr-ink)');
expect(rendered.svg).not.toContain('<script');
expect(rendered.svg).not.toContain('data-pikchr-date');
});
Expand Down
149 changes: 144 additions & 5 deletions apps/staged/src/lib/shared/markdown/pikchrRendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,23 @@ const PIKCHR_SIDE_LABEL_GAP = '0.35em';

const CSS_NUMBER = String.raw`[-+]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[-+]?\d+)?`;
const CSS_LENGTH = new RegExp(`^(?:${CSS_NUMBER})(?:px|pt|pc|mm|cm|in|em|rem|%)?$`);
const CSS_COLOR =
/^(?:none|transparent|currentColor|[a-zA-Z]+|#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/;
const PIKCHR_THEME_COLOR_VAR_NAMES = [
'--pikchr-ink',
'--pikchr-surface',
'--pikchr-muted',
'--pikchr-red',
'--pikchr-green',
'--pikchr-blue',
'--pikchr-yellow',
'--pikchr-orange',
'--pikchr-purple',
'--pikchr-cyan',
] as const;
const PIKCHR_THEME_COLOR_VAR_PATTERN = String.raw`var\((?:${PIKCHR_THEME_COLOR_VAR_NAMES.join('|')})\)`;
const CSS_COLOR = new RegExp(
String.raw`^(?:none|transparent|currentColor|[a-zA-Z]+|#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)|${PIKCHR_THEME_COLOR_VAR_PATTERN})$`,
'i'
);
const CSS_DASH_ARRAY = new RegExp(`^(?:${CSS_NUMBER})(?:\\s*,\\s*(?:${CSS_NUMBER}))*$`);
const DIRECT_COLOR_ATTRIBUTES = ['fill', 'stroke'] as const;
const DIRECT_COLOR_ATTRIBUTE_TAGS = new Set([
Expand All @@ -23,6 +38,60 @@ const DIRECT_COLOR_ATTRIBUTE_TAGS = new Set([
'polyline',
'polygon',
]);
const RGB_COLOR = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
const PIKCHR_NUMERIC_COLOR_LITERAL = /\b0x([0-9a-fA-F]{6})\b/g;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve all numeric Pikchr color literals

When a diagram uses Pikchr's decimal or shorter C-style numeric color literals (for example color 0, color 255, or fill 16777215), this regex does not add the rendered #000000/#0000ff/#ffffff key to preservedColors, so themePikchrColor later rewrites those explicit author colors to theme variables. Pikchr color expressions are 24-bit numeric values, not only six-digit 0xRRGGBB constants, so this breaks the intended preservation for supported numeric color syntax whenever the chosen value is one of the mapped colors.

Useful? React with 👍 / 👎.

const THEMED_PIKCHR_COLORS = new Map([
['#000000', 'var(--pikchr-ink)'],
['#ffffff', 'var(--pikchr-surface)'],
['#808080', 'var(--pikchr-muted)'],
['#a9a9a9', 'var(--pikchr-muted)'],
['#c0c0c0', 'var(--pikchr-muted)'],
['#696969', 'var(--pikchr-muted)'],
['#ff0000', 'var(--pikchr-red)'],
['#dc143c', 'var(--pikchr-red)'],
['#008000', 'var(--pikchr-green)'],
['#00ff00', 'var(--pikchr-green)'],
['#0000ff', 'var(--pikchr-blue)'],
['#ffff00', 'var(--pikchr-yellow)'],
['#ffd700', 'var(--pikchr-yellow)'],
['#ffa500', 'var(--pikchr-orange)'],
['#800080', 'var(--pikchr-purple)'],
['#ff00ff', 'var(--pikchr-purple)'],
['#ee82ee', 'var(--pikchr-purple)'],
['#00ffff', 'var(--pikchr-cyan)'],
['#008080', 'var(--pikchr-cyan)'],
]);
const NAMED_PIKCHR_COLOR_KEYS = new Map([
['black', '#000000'],
['white', '#ffffff'],
['gray', '#808080'],
['grey', '#808080'],
['darkgray', '#a9a9a9'],
['darkgrey', '#a9a9a9'],
['silver', '#c0c0c0'],
['dimgray', '#696969'],
['dimgrey', '#696969'],
['red', '#ff0000'],
['crimson', '#dc143c'],
['green', '#008000'],
['lime', '#00ff00'],
['blue', '#0000ff'],
['yellow', '#ffff00'],
['gold', '#ffd700'],
['orange', '#ffa500'],
['purple', '#800080'],
['fuchsia', '#ff00ff'],
['magenta', '#ff00ff'],
['violet', '#ee82ee'],
['cyan', '#00ffff'],
['aqua', '#00ffff'],
['teal', '#008080'],
]);

interface SanitizePikchrSvgOptions {
source?: string;
}

export type RenderedPikchrDiagram =
| {
Expand Down Expand Up @@ -64,7 +133,7 @@ export function renderPikchrSource(pikchr: Pikchr, source: string): RenderedPikc
return { kind: 'error', message: 'Pikchr could not render this diagram.' };
}

const svg = sanitizePikchrSvg(rendered.svg);
const svg = sanitizePikchrSvg(rendered.svg, { source });
if (!svg) {
return { kind: 'error', message: 'Pikchr rendered unsafe SVG.' };
}
Expand All @@ -80,7 +149,10 @@ export function renderPikchrSource(pikchr: Pikchr, source: string): RenderedPikc
}
}

export function sanitizePikchrSvg(svg: string): string | null {
export function sanitizePikchrSvg(
svg: string,
options: SanitizePikchrSvgOptions = {}
): string | null {
if (svg.length > MAX_PIKCHR_SVG_LENGTH || !PIKCHR_SVG_ROOT.test(svg)) {
return null;
}
Expand Down Expand Up @@ -138,7 +210,7 @@ export function sanitizePikchrSvg(svg: string): string | null {

const normalized = sanitized.replace(/\sviewbox=/g, ' viewBox=');
if (!PIKCHR_SVG_ROOT.test(normalized)) return null;
return normalized;
return applyThemedPikchrPalette(normalized, options.source);
}

function normalizePikchrSvgAttributes(tagName: string, attribs: Record<string, string>) {
Expand Down Expand Up @@ -183,3 +255,70 @@ function stripUnsafeDirectColorAttributes(tagName: string, attribs: Record<strin

return { tagName, attribs: nextAttribs };
}

function applyThemedPikchrPalette(svg: string, source: string | undefined): string {
const preservedColors = collectPreservedPikchrColorKeys(source);
return svg
.replace(/\b(fill|stroke)="([^"]*)"/gi, (_match, attribute: string, value: string) => {
return `${attribute}="${themePikchrColor(value, preservedColors)}"`;
})
.replace(/\bstyle="([^"]*)"/gi, (_match, style: string) => {
return `style="${themePikchrStyle(style, preservedColors)}"`;
});
}

function themePikchrStyle(style: string, preservedColors: Set<string>): string {
return style.replace(
/(^|;)\s*(fill|stroke)\s*:\s*([^;]+)/gi,
(_match, prefix: string, property: string, value: string) => {
return `${prefix}${property}:${themePikchrColor(value, preservedColors)}`;
}
);
}

function themePikchrColor(value: string, preservedColors: Set<string>): string {
const trimmedValue = value.trim();
const colorKey = canonicalColorKey(trimmedValue);
if (!colorKey || preservedColors.has(colorKey)) return trimmedValue;

return THEMED_PIKCHR_COLORS.get(colorKey) ?? trimmedValue;
}

function collectPreservedPikchrColorKeys(source: string | undefined): Set<string> {
const preservedColors = new Set<string>();
if (!source) return preservedColors;

const sourceWithoutLabels = source.replace(/"[^"]*(?:"|$)/g, ' ');
for (const match of sourceWithoutLabels.matchAll(PIKCHR_NUMERIC_COLOR_LITERAL)) {
preservedColors.add(`#${match[1].toLowerCase()}`);
}

return preservedColors;
}

function canonicalColorKey(value: string): string | null {
const normalizedValue = value.toLowerCase();
const namedColor = NAMED_PIKCHR_COLOR_KEYS.get(normalizedValue);
if (namedColor) return namedColor;

const rgb = RGB_COLOR.exec(normalizedValue);
if (rgb) return colorKeyFromRgb(Number(rgb[1]), Number(rgb[2]), Number(rgb[3]));

const hex = HEX_COLOR.exec(normalizedValue);
if (!hex) return null;

const digits = hex[1];
if (digits.length === 3) {
return `#${digits
.split('')
.map((digit) => `${digit}${digit}`)
.join('')}`;
}
return `#${digits}`;
}

function colorKeyFromRgb(r: number, g: number, b: number): string | null {
if ([r, g, b].some((component) => component < 0 || component > 255)) return null;

return `#${[r, g, b].map((component) => component.toString(16).padStart(2, '0')).join('')}`;
}
23 changes: 22 additions & 1 deletion apps/staged/src/lib/shared/markdown/renderMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,23 @@ describe('renderMarkdown', () => {
expect(html).toContain('<svg');
expect(html).toContain('class="markdown-pikchr-svg"');
expect(html).toContain('<path');
expect(html).toContain('stroke:rgb(0,0,0)');
expect(html).toContain('stroke:var(--pikchr-ink)');
expect(html).not.toContain('markdown-diagram-source-wrap');
expect(html).not.toContain('markdown-diagram-source-pikchr');
expect(html).not.toContain('box "Start" fit');
expect(html).not.toContain('box &quot;Start&quot; fit');
expect(html).not.toContain('STAGED_MARKDOWN_TRUSTED_DIAGRAM_');
});

it('preserves numeric Pikchr colors through the Markdown rendering path', () => {
const html = renderMarkdown('```pikchr\nbox "Exact" color 0xff0000 fill 0xffffff\n```', {
pikchrRenderer: numericColorPikchrRenderer,
});

expect(html).toContain('stroke:rgb(255,0,0)');
expect(html).toContain('fill:rgb(255,255,255)');
});

it('does not allow renderer SVG through the generic Markdown sanitizer', () => {
const html = renderMarkdown('<svg><script>alert(1)</script></svg>');

Expand Down Expand Up @@ -101,6 +110,18 @@ const safePikchrRenderer: PikchrRenderer = () => ({
].join(''),
});

const numericColorPikchrRenderer: PikchrRenderer = () => ({
kind: 'svg',
width: 58,
height: 34,
svg: [
'<svg xmlns="http://www.w3.org/2000/svg" class="markdown-pikchr-svg" viewBox="0 0 58 34">',
'<path d="M2,32L56,32L56,2L2,2Z" style="fill:rgb(255,255,255);stroke-width:2.16;stroke:rgb(255,0,0);" />',
'<text x="29" y="17" text-anchor="middle" fill="rgb(255,0,0)" dominant-baseline="central">Exact</text>',
'</svg>',
].join(''),
});

const unsafePikchrRenderer: PikchrRenderer = () => ({
kind: 'error',
message: 'Pikchr rendered unsafe SVG.',
Expand Down
43 changes: 43 additions & 0 deletions apps/staged/src/lib/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';

import { createAdaptiveTheme, themeToVarMap } from './theme';

describe('createAdaptiveTheme', () => {
it('exposes a soft themed Pikchr palette for dark chrome', () => {
const vars = themeToVarMap(
createAdaptiveTheme('#27212e', '#ffffff', '#91889b', {
added: '#3fb950',
deleted: '#f85149',
modified: '#d29922',
})
);

expect(vars['--diagram-canvas-bg']).toBe('#f2edf8');
expect(vars['--pikchr-ink']).toBe('#241a2f');
expect(vars['--pikchr-surface']).toBe('#fffaff');
expect(vars['--pikchr-muted']).toBe('#74677f');
expect(vars['--pikchr-red']).toBe('#ec91a0');
expect(vars['--pikchr-green']).toBe('#80cd99');
expect(vars['--pikchr-blue']).toBe('#8bbaed');
expect(vars['--pikchr-yellow']).toBe('#ecd285');
expect(vars['--pikchr-orange']).toBe('#ebae7f');
expect(vars['--pikchr-yellow']).not.toBe(vars['--pikchr-orange']);
});

it('uses a quiet light Pikchr palette for light chrome', () => {
const vars = themeToVarMap(
createAdaptiveTheme('#ffffff', '#24292e', '#6e7781', {
added: '#28a745',
deleted: '#d73a49',
modified: '#2188ff',
})
);

expect(vars['--diagram-canvas-bg']).toBe('#fbf8ff');
expect(vars['--pikchr-surface']).toBe('#ffffff');
expect(vars['--pikchr-ink']).toBe('#24292e');
expect(vars['--pikchr-blue']).toBe('#81abdd');
expect(vars['--pikchr-orange']).toBe('#da9a6f');
expect(vars['--pikchr-yellow']).not.toBe(vars['--pikchr-orange']);
});
});
Loading