Merge PR #20: enh nav

This commit was merged in pull request #20.
This commit is contained in:
2026-07-14 13:58:11 +00:00
10 changed files with 649 additions and 18 deletions
@@ -20,3 +20,14 @@ describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () =>
expect(html).toContain('© 2026 MySite. All rights reserved.');
});
});
describe('Footer (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Footer as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
+10
View File
@@ -6,6 +6,11 @@ import { escapeHtml } from '../../utils/escape';
interface FooterProps {
text?: string;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
export const Footer: UserComponent<FooterProps> = ({
@@ -92,6 +97,11 @@ Footer.craft = {
fontSize: '14px',
padding: '24px 20px',
},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
},
rules: {
canDrag: () => true,
@@ -43,6 +43,29 @@ describe('Logo.toHtml image src/alt sanitization (type="image")', () => {
});
});
describe('Logo.toHtml download attribute (F3: link-to-file toggle)', () => {
test('download:true emits the download attribute', () => {
const { html } = toHtml({ href: '/resume.pdf', download: true, text: 'Resume' }, '');
expect(html).toMatch(/<a href="\/resume\.pdf" download/);
});
test('no download prop -> no download attribute emitted', () => {
const { html } = toHtml({ href: '/', text: 'MySite' }, '');
expect(html).not.toContain('download');
});
});
describe('Logo (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Logo as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
describe('Logo.toHtml text-logo styling sanitization', () => {
test('a quote-breakout color does not escape the span style attribute', () => {
const malicious = 'red" onmouseover="alert(1)';
+17 -1
View File
@@ -12,11 +12,18 @@ interface LogoProps {
imageSrc?: string;
imageWidth?: string;
href?: string;
/** Adds the `download` attribute to the exported anchor (F3: link to a file). */
download?: boolean;
fontFamily?: string;
fontSize?: string;
fontWeight?: string;
color?: string;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Component ---------- */
@@ -27,6 +34,7 @@ export const Logo: UserComponent<LogoProps> = ({
imageSrc = '',
imageWidth = '120px',
href = '/',
download = false,
fontFamily = 'Inter, sans-serif',
fontSize = '20px',
fontWeight = '700',
@@ -44,6 +52,7 @@ export const Logo: UserComponent<LogoProps> = ({
<a
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
href={href}
download={download || undefined}
onClick={(e) => e.preventDefault()}
style={{
textDecoration: 'none',
@@ -87,7 +96,13 @@ Logo.craft = {
fontSize: '20px',
fontWeight: '700',
color: undefined,
download: false,
style: {},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as LogoProps,
rules: {
canDrag: () => true,
@@ -122,8 +137,9 @@ Logo.craft = {
flexShrink: '0',
...props.style,
});
const downloadAttr = props.download ? ' download' : '';
return {
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
html: `<a href="${escapeAttr(safeUrl(href))}"${downloadAttr}${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
};
};
@@ -31,6 +31,29 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.
});
});
describe('Menu.toHtml download attribute (F3: link-to-file toggle)', () => {
test('a link with download:true emits the download attribute', () => {
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
});
test('a link without download does not emit the attribute', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
expect(html).not.toContain(' download');
});
});
describe('Menu (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Menu as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
test('a linkHoverColor value containing </style><script> is neutralized', () => {
const malicious = '#fff}</style><script>alert(1)</script><style>{';
+15 -1
View File
@@ -10,6 +10,8 @@ interface MenuLink {
href: string;
isExternal?: boolean;
isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
}
interface MenuProps {
@@ -23,6 +25,11 @@ interface MenuProps {
orientation?: 'horizontal' | 'vertical';
fontSize?: string;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Defaults ---------- */
@@ -75,6 +82,7 @@ export const Menu: UserComponent<MenuProps> = ({
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
download={link.download || undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
@@ -114,6 +122,11 @@ Menu.craft = {
orientation: 'horizontal',
fontSize: '14px',
style: {},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as MenuProps,
rules: {
canDrag: () => true,
@@ -162,6 +175,7 @@ Menu.craft = {
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const downloadAttr = link.download ? ' download' : '';
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
const linkStyle = cssPropsToString({
textDecoration: 'none',
@@ -173,7 +187,7 @@ Menu.craft = {
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n ');
const hoverCss = `<style>
@@ -83,6 +83,29 @@ describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not
});
});
describe('Navbar.toHtml download attribute (F3: link-to-file toggle)', () => {
test('a link with download:true emits the download attribute', () => {
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
});
test('a link without download does not emit the attribute', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
expect(html).not.toContain(' download');
});
});
describe('Navbar (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Navbar as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
const malicious = '#fff}</style><script>alert(1)</script><style>{';
+15 -1
View File
@@ -11,6 +11,8 @@ interface NavLink {
href: string;
isExternal?: boolean;
isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
}
interface NavbarProps {
@@ -33,6 +35,11 @@ interface NavbarProps {
isSticky?: boolean;
showMobileMenu?: boolean;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Defaults ---------- */
@@ -149,6 +156,7 @@ export const Navbar: UserComponent<NavbarProps> = ({
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
download={link.download || undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
@@ -200,6 +208,11 @@ Navbar.craft = {
style: {
borderBottom: '1px solid #e4e4e7',
},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as NavbarProps,
rules: {
canDrag: () => true,
@@ -308,6 +321,7 @@ Navbar.craft = {
// Add CSS class to each link for hover
const linksHtmlWithClass = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const downloadAttr = link.download ? ' download' : '';
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
const linkStyle = cssPropsToString({
textDecoration: 'none',
@@ -319,7 +333,7 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n ');
return {