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: 6 additions & 6 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 15 libraries in python, r, julia &amp; javascript. browse, copy, adapt — colorblind-safe by default." />
<meta data-shell-default name="description" content="the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 15 libraries in python, r, julia &amp; javascript. browse, copy, adapt — colorblind-safe by default." />
<meta name="keywords" content="python, r, julia, javascript, plotting, matplotlib, seaborn, plotly, bokeh, altair, plotnine, pygal, letsplot, ggplot2, makie, chart.js, d3, echarts, highcharts, mui x charts, data visualization, charts, graphs, ai-generated, code examples, colorblind-safe, imprint palette" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0E0E10" media="(prefers-color-scheme: dark)" />
<title>any.plot() — any library.</title>
<title data-shell-default>any.plot() — any library.</title>

<!-- Resolve theme before paint to avoid a flash-of-wrong-theme. Mirrors
useThemeMode: stored 'light'/'dark' wins, otherwise follow the OS. -->
Expand Down Expand Up @@ -62,16 +62,16 @@
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://anyplot.ai/" />
<meta property="og:title" content="any.plot() — any library." />
<meta property="og:description" content="the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 15 libraries in python, r, julia &amp; javascript." />
<meta data-shell-default property="og:title" content="any.plot() — any library." />
<meta data-shell-default property="og:description" content="the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 15 libraries in python, r, julia &amp; javascript." />
<meta property="og:image" content="https://anyplot.ai/og-image.png" />
<meta property="og:site_name" content="anyplot.ai" />
<meta property="og:locale" content="en_US" />

<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="any.plot() — any library." />
<meta name="twitter:description" content="the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 15 libraries in python, r, julia &amp; javascript." />
<meta data-shell-default name="twitter:title" content="any.plot() — any library." />
<meta data-shell-default name="twitter:description" content="the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 15 libraries in python, r, julia &amp; javascript." />
<meta name="twitter:image" content="https://anyplot.ai/og-image.png" />
<!-- Preconnect to GCS for font loading -->
<link rel="preconnect" href="https://storage.googleapis.com" crossorigin>
Expand Down
5 changes: 5 additions & 0 deletions app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import ReactDOM from 'react-dom/client';

import { reportWebVitals } from 'src/analytics/reportWebVitals';
import { App } from 'src/app';
import { watchShellDefaults } from 'src/utils/shellMetadata';

// The shell's default title and description stay as the fallback for every state in which a
// route renders none of its own, and step aside while one does.
watchShellDefaults();

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
Expand Down
4 changes: 4 additions & 0 deletions app/src/pages/DebugPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ export function DebugPage() {
if (authRequired) {
return (
<Box sx={{ py: 4, maxWidth: 420, mx: 'auto' }}>
<title>debug | anyplot.ai</title>
<SectionHeader prompt="❯" title={<em>debug · admin auth</em>} />
<Typography
sx={{
Expand Down Expand Up @@ -495,6 +496,7 @@ export function DebugPage() {
if (loading) {
return (
<Box sx={{ py: 4, textAlign: 'center' }}>
<title>debug | anyplot.ai</title>
<Typography sx={{ fontFamily: typography.fontFamily, color: semanticColors.mutedText }}>
loading debug data...
</Typography>
Expand All @@ -505,6 +507,7 @@ export function DebugPage() {
if (error || !data) {
return (
<Box sx={{ py: 4, textAlign: 'center' }}>
<title>debug | anyplot.ai</title>
<Typography sx={{ fontFamily: typography.fontFamily, color: colors.error }}>
failed to load{error ? `: ${error}` : ''}
</Typography>
Expand Down Expand Up @@ -541,6 +544,7 @@ export function DebugPage() {

return (
<Box sx={{ pt: { xs: 2, md: 3 }, pb: 4 }}>
<title>debug | anyplot.ai</title>
{/* Header */}
<SectionHeader prompt="❯" title={<em>debug</em>} />

Expand Down
218 changes: 218 additions & 0 deletions app/src/pages/headMetadata.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { AboutPage } from 'src/pages/AboutPage';
import { DebugPage } from 'src/pages/DebugPage';
import { LandingPage } from 'src/pages/LandingPage';
import { LegalPage } from 'src/pages/LegalPage';
import { PlotsPage } from 'src/pages/PlotsPage';
import { render } from 'src/test-utils';
import { syncShellDefaults } from 'src/utils/shellMetadata';

// The six shell defaults as index.html marks them; filled in beforeEach.
let defaults: Element[] = [];

/** Run the sync main.tsx's observer runs after every head change, then count the tags. */
function headAfterSync(): { titles: string[]; descriptions: number } {
syncShellDefaults(document.head, defaults);
return {
titles: Array.from(document.head.querySelectorAll('title')).map(t => t.textContent ?? ''),
descriptions: document.head.querySelectorAll('meta[name="description"]').length,
};
}

vi.mock('src/hooks/useLayoutContext', async () => {
const actual = await vi.importActual<typeof import('src/hooks/useLayoutContext')>(
'src/hooks/useLayoutContext'
);
return {
...actual,
useTheme: () => ({ isDark: false, toggle: vi.fn() }),
useAppData: () => ({
specs: [],
specTitles: {},
filterCounts: null,
globalCounts: null,
fetchError: null,
hasData: true,
lastUpdated: '2023-01-01',
}),
useHomeState: () => ({
specsData: [],
specTitles: {},
allImages: [],
displayedImages: [],
activeFilters: [],
filterCounts: null,
globalCounts: null,
orCounts: [],
hasMore: false,
loading: false,
error: '',
setDisplayedImages: vi.fn(),
handleFilterClick: vi.fn(),
handleSortModeChange: vi.fn(),
loadMore: vi.fn(),
homeStateRef: { current: { scrollY: 0 } },
saveScrollPosition: vi.fn(),
}),
};
});

vi.mock('src/hooks/useFeaturedSpecs', () => ({
useFeaturedSpecs: () => [],
}));

vi.mock('src/hooks/usePlotOfTheDay', () => ({
usePlotOfTheDay: () => null,
}));

vi.mock('src/hooks', async () => {
const actual = await vi.importActual<typeof import('src/hooks')>('src/hooks');
return {
...actual,
useAnalytics: () => ({ trackPageview: vi.fn(), trackEvent: vi.fn() }),
useAppData: () => ({
specs: [],
specTitles: {},
filterCounts: null,
globalCounts: null,
fetchError: null,
hasData: true,
lastUpdated: '2023-01-01',
}),
useHomeState: () => ({
specsData: [],
specTitles: {},
allImages: [],
displayedImages: [],
activeFilters: [],
filterCounts: null,
globalCounts: null,
orCounts: [],
hasMore: false,
loading: false,
error: '',
setDisplayedImages: vi.fn(),
handleFilterClick: vi.fn(),
handleSortModeChange: vi.fn(),
loadMore: vi.fn(),
homeStateRef: { current: { scrollY: 0 } },
saveScrollPosition: vi.fn(),
}),
};
});

// For PlotsPage filters and infinite scroll context
vi.mock('src/hooks/useFilterState', async () => {
const actual = await vi.importActual<typeof import('src/hooks/useFilterState')>(
'src/hooks/useFilterState'
);
return {
...actual,
useFilterState: () => ({
activeFilters: [],
filterCounts: null,
globalCounts: null,
orCounts: [],
specTitles: {},
allImages: [],
displayedImages: [],
hasMore: false,
loading: false,
error: '',
setDisplayedImages: vi.fn(),
handleFilterClick: vi.fn(),
handleSortModeChange: vi.fn(),
loadMore: vi.fn(),
}),
};
});

vi.mock('src/hooks/useInfiniteScroll', () => ({
useInfiniteScroll: () => ({ loadMoreRef: { current: null } }),
}));

vi.mock('src/sections/landing/LibrariesSection', () => ({
LibrariesSection: () => <div data-testid="libraries-section" />,
}));
vi.mock('src/sections/landing/HeroSection', () => ({
HeroSection: () => <div data-testid="hero" />,
}));
vi.mock('src/sections/landing/NumbersStrip', () => ({
NumbersStrip: () => <div data-testid="numbers" />,
}));
vi.mock('src/sections/plots-gallery/FilterBar', () => ({
FilterBar: () => <div data-testid="filterbar">FilterBar</div>,
}));
vi.mock('src/sections/plots-gallery/ImagesGrid', () => ({
ImagesGrid: () => <div data-testid="images-grid">ImagesGrid</div>,
}));
vi.mock('src/layouts/Footer', () => ({
Footer: () => <div data-testid="footer">Footer</div>,
}));
vi.mock('src/utils/filters', async () => {
const actual = await vi.importActual<typeof import('src/utils/filters')>('src/utils/filters');
return { ...actual };
});

describe('head metadata deduplication', () => {
beforeEach(() => {
// Clear head
document.head.innerHTML = '';

// Insert the 6 marked shell tags
const tags = [
{ tag: 'title', attrs: {} },
{ tag: 'meta', attrs: { name: 'description', content: 'desc' } },
{ tag: 'meta', attrs: { property: 'og:title', content: 'og title' } },
{ tag: 'meta', attrs: { property: 'og:description', content: 'og desc' } },
{ tag: 'meta', attrs: { name: 'twitter:title', content: 'tw title' } },
{ tag: 'meta', attrs: { name: 'twitter:description', content: 'tw desc' } },
];

tags.forEach(({ tag, attrs }) => {
const el = document.createElement(tag);
el.setAttribute('data-shell-default', '');
Object.entries(attrs).forEach(([key, val]) => el.setAttribute(key, val));
if (tag === 'title') {
el.textContent = 'shell title';
}
document.head.appendChild(el);
});
defaults = Array.from(document.head.querySelectorAll('[data-shell-default]'));
});

afterEach(() => {
document.head.innerHTML = '';
});

it.each([
['LandingPage', () => <LandingPage />, 'any.plot() — any library.'],
['PlotsPage', () => <PlotsPage />, 'plots | anyplot.ai'],
['AboutPage', () => <AboutPage />, 'about | anyplot.ai'],
['LegalPage', () => <LegalPage />, 'legal | anyplot.ai'],
])('%s leaves exactly one title and one description, its own', (_name, page, title) => {
const { unmount } = render(page());
expect(headAfterSync()).toEqual({ titles: [title], descriptions: 1 });
expect(
document.head.querySelector('meta[name="description"]')?.hasAttribute('data-shell-default')
).toBe(false);
unmount();
});

it('DebugPage shows its own title and keeps the shell description it has none of', () => {
const { unmount } = render(<DebugPage />);
expect(headAfterSync()).toEqual({ titles: ['debug | anyplot.ai'], descriptions: 1 });
expect(
document.head.querySelector('meta[name="description"]')?.hasAttribute('data-shell-default')
).toBe(true);
unmount();
});

it('brings the shell title back when the page that replaced it is gone', () => {
const { unmount } = render(<AboutPage />);
expect(headAfterSync().titles).toEqual(['about | anyplot.ai']);
unmount();
expect(headAfterSync()).toEqual({ titles: ['shell title'], descriptions: 1 });
});
});
82 changes: 82 additions & 0 deletions app/src/utils/shellMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it } from 'vitest';

import { syncShellDefaults, watchShellDefaults } from 'src/utils/shellMetadata';

function shellTag(tag: 'title' | 'meta', attrs: Record<string, string>, text = ''): Element {
const el = document.createElement(tag);
el.setAttribute('data-shell-default', '');
Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value));
el.textContent = text;
document.head.appendChild(el);
return el;
}

function pageTag(tag: 'title' | 'meta', attrs: Record<string, string>, text = ''): Element {
const el = document.createElement(tag);
Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value));
el.textContent = text;
document.head.appendChild(el);
return el;
}

describe('syncShellDefaults', () => {
afterEach(() => {
document.head.innerHTML = '';
});

it('detaches a shell default only while a page tag of the same kind exists', () => {
const title = shellTag('title', {}, 'shell title');
const description = shellTag('meta', { name: 'description', content: 'shell' });
const ogTitle = shellTag('meta', { property: 'og:title', content: 'shell' });
const keywords = pageTag('meta', { name: 'keywords', content: 'kept' });
const pageTitle = pageTag('title', {}, 'page title');
pageTag('meta', { property: 'og:title', content: 'page' });

expect(syncShellDefaults(document.head, [title, description, ogTitle])).toBe(2);

expect(Array.from(document.head.querySelectorAll('title')).map(t => t.textContent)).toEqual([
'page title',
]);
expect(document.head.querySelectorAll('meta[property="og:title"]')).toHaveLength(1);
// Nothing replaces the description, so the shell's stays.
expect(description.isConnected).toBe(true);
expect(keywords.isConnected).toBe(true);

// The page's title goes away (an error state, a lazy chunk loading): the shell's comes back.
pageTitle.remove();
expect(syncShellDefaults(document.head, [title, description, ogTitle])).toBe(1);
expect(document.title).toBe('shell title');
});

it('is idempotent', () => {
const title = shellTag('title', {}, 'shell title');
pageTag('title', {}, 'page title');
syncShellDefaults(document.head, [title]);
const before = document.head.innerHTML;
expect(syncShellDefaults(document.head, [title])).toBe(1);
expect(document.head.innerHTML).toBe(before);
});
});

describe('watchShellDefaults', () => {
afterEach(() => {
document.head.innerHTML = '';
});

it('follows page tags as they come and go', async () => {
const title = shellTag('title', {}, 'shell title');
const stop = watchShellDefaults(document.head);
expect(title.isConnected).toBe(true);

const pageTitle = pageTag('title', {}, 'page title');
await Promise.resolve();
expect(title.isConnected).toBe(false);
expect(document.title).toBe('page title');

pageTitle.remove();
await Promise.resolve();
expect(title.isConnected).toBe(true);
expect(document.title).toBe('shell title');
stop();
});
});
Loading
Loading