Add syntax highlighting (#178)

This commit is contained in:
Matt Rubens 2025-07-01 15:19:35 -04:00 committed by GitHub
parent 9a94ad70bf
commit 9edcee5c53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 581 additions and 15 deletions

View file

@ -64,6 +64,7 @@
"react-use": "^17.6.0",
"recharts": "^2.15.3",
"require-in-the-middle": "^7.5.2",
"shiki": "^3.7.0",
"sonner": "^2.0.3",
"stripe": "^18.2.0",
"tailwind-merge": "^3.3.0",

View file

@ -1,30 +1,24 @@
'use client';
import { MermaidDiagram } from './MermaidDiagram';
import type { Element } from 'hast';
import { SyntaxHighlighter } from './SyntaxHighlighter';
interface CodeBlockProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode;
className?: string;
inline?: boolean;
node?: Element;
}
export const CodeBlock = ({
children,
className,
inline,
...props
}: CodeBlockProps) => {
// Extract language from className (format: "language-xxx")
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
// Convert children to string
const code = String(children).replace(/\n$/, '');
// If it's inline code or not mermaid, render as regular code
if (inline || language !== 'mermaid') {
// No language = inline code (from `backticks`)
if (!match) {
return (
<code className={className} {...props}>
{children}
@ -32,15 +26,13 @@ export const CodeBlock = ({
);
}
// If it's a mermaid code block, render with MermaidDiagram
// Mermaid diagrams
if (language === 'mermaid') {
return <MermaidDiagram chart={code} className="my-4" />;
return <MermaidDiagram chart={code} className="my-2" />;
}
// Fallback to regular code block
// Code blocks with syntax highlighting
return (
<code className={className} {...props}>
{children}
</code>
<SyntaxHighlighter code={code} language={language || ''} className="my-2" />
);
};

View file

@ -0,0 +1,170 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useTheme } from 'next-themes';
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki';
interface SyntaxHighlighterProps {
code: string;
language: string;
className?: string;
}
// Cache for the highlighter instance
let highlighterCache: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
null;
let highlighterPromise: Promise<
HighlighterGeneric<BundledLanguage, BundledTheme>
> | null = null;
// Common languages to preload for better performance
const COMMON_LANGUAGES: BundledLanguage[] = [
'javascript',
'typescript',
'jsx',
'tsx',
'python',
'java',
'go',
'rust',
'cpp',
'c',
'csharp',
'php',
'ruby',
'swift',
'kotlin',
'scala',
'html',
'css',
'scss',
'json',
'yaml',
'xml',
'markdown',
'bash',
'shell',
'sql',
'dockerfile',
];
const getHighlighter = async () => {
if (highlighterCache) {
return highlighterCache;
}
if (highlighterPromise) {
return highlighterPromise;
}
highlighterPromise = (async () => {
const { createHighlighter } = await import('shiki');
const highlighter = await createHighlighter({
themes: ['github-light', 'github-dark'],
langs: COMMON_LANGUAGES,
});
highlighterCache = highlighter;
return highlighter;
})();
return highlighterPromise;
};
export const SyntaxHighlighter = ({
code,
language,
className,
}: SyntaxHighlighterProps) => {
const [highlightedCode, setHighlightedCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { theme, systemTheme } = useTheme();
const currentTheme = theme === 'system' ? systemTheme : theme;
const shikiTheme = currentTheme === 'dark' ? 'github-dark' : 'github-light';
const highlightCode = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const highlighter = await getHighlighter();
// Check if the language is supported, fallback to 'text' if not
const supportedLanguages = highlighter.getLoadedLanguages();
const langToUse = supportedLanguages.includes(language as BundledLanguage)
? (language as BundledLanguage)
: 'text';
// If the language isn't loaded yet, try to load it
if (!supportedLanguages.includes(langToUse) && langToUse !== 'text') {
try {
await highlighter.loadLanguage(langToUse);
} catch {
// If loading fails, fall back to text
}
}
const html = highlighter.codeToHtml(code, {
lang: langToUse,
theme: shikiTheme,
transformers: [
{
pre(node) {
// Remove default background and padding since we'll handle it with CSS
if (node.properties.style) {
node.properties.style = (node.properties.style as string)
.replace(/background-color:[^;]+;?/g, '')
.replace(/padding:[^;]+;?/g, '');
}
},
},
],
});
setHighlightedCode(html);
} catch (err) {
console.error('Failed to highlight code:', err);
setError('Failed to highlight code');
} finally {
setIsLoading(false);
}
}, [code, language, shikiTheme]);
useEffect(() => {
highlightCode();
}, [highlightCode]);
if (error) {
// Fallback to plain code if highlighting fails
return (
<pre
className={`bg-muted p-3 rounded overflow-x-auto ${className || ''}`}
>
<code className="bg-transparent p-0 font-mono text-sm">{code}</code>
</pre>
);
}
if (isLoading) {
// Show loading state with plain code
return (
<pre
className={`bg-muted p-3 rounded overflow-x-auto ${className || ''}`}
>
<code className="bg-transparent p-0 font-mono text-sm opacity-70">
{code}
</code>
</pre>
);
}
return (
<div
className={`bg-muted p-3 rounded overflow-x-auto [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0 ${className || ''}`}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
);
};

View file

@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'next-themes';
import { CodeBlock } from '../CodeBlock';
// Mock the SyntaxHighlighter component
vi.mock('../SyntaxHighlighter', () => ({
SyntaxHighlighter: ({
code,
language,
className,
}: {
code: string;
language: string;
className?: string;
}) => (
<div
data-testid="syntax-highlighter"
data-language={language}
className={className}
>
{code || ''}
</div>
),
}));
// Mock the MermaidDiagram component
vi.mock('../MermaidDiagram', () => ({
MermaidDiagram: ({
chart,
className,
}: {
chart: string;
className?: string;
}) => (
<div data-testid="mermaid-diagram" className={className}>
{chart}
</div>
),
}));
const renderWithTheme = (component: React.ReactElement) => {
return render(
<ThemeProvider attribute="class" defaultTheme="light">
{component}
</ThemeProvider>,
);
};
describe('CodeBlock', () => {
it('renders inline code as regular code element', () => {
renderWithTheme(<CodeBlock>const x = 1;</CodeBlock>);
const codeElement = screen.getByText(/const/).closest('code');
expect(codeElement?.tagName).toBe('CODE');
});
it('renders mermaid code blocks using MermaidDiagram component', () => {
const mermaidCode = `graph TD
A[Start] --> B{Is it working?}`;
renderWithTheme(
<CodeBlock className="language-mermaid">{mermaidCode}</CodeBlock>,
);
expect(screen.getByTestId('mermaid-diagram')).toBeInTheDocument();
});
it('renders code blocks with language using SyntaxHighlighter', () => {
renderWithTheme(
<CodeBlock className="language-javascript">
console.log(&apos;test&apos;);
</CodeBlock>,
);
const syntaxHighlighter = screen.getByTestId('syntax-highlighter');
expect(syntaxHighlighter).toBeInTheDocument();
expect(syntaxHighlighter).toHaveAttribute('data-language', 'javascript');
});
it('renders text language using SyntaxHighlighter', () => {
renderWithTheme(
<CodeBlock className="language-text">This is plain text</CodeBlock>,
);
const syntaxHighlighter = screen.getByTestId('syntax-highlighter');
expect(syntaxHighlighter).toBeInTheDocument();
expect(syntaxHighlighter).toHaveAttribute('data-language', 'text');
});
it('renders inline code when no language is specified', () => {
renderWithTheme(<CodeBlock>Some code without language</CodeBlock>);
const codeElement = screen
.getByText(/Some code without language/)
.closest('code');
expect(codeElement).toBeInTheDocument();
expect(codeElement?.tagName).toBe('CODE');
});
});

View file

@ -0,0 +1,163 @@
import { render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider } from 'next-themes';
import { SyntaxHighlighter } from '../SyntaxHighlighter';
// Mock shiki
vi.mock('shiki', () => ({
createHighlighter: vi.fn().mockResolvedValue({
getLoadedLanguages: vi
.fn()
.mockReturnValue(['javascript', 'typescript', 'python']),
loadLanguage: vi.fn().mockResolvedValue(undefined),
codeToHtml: vi.fn().mockImplementation((code, options) => {
return `<pre><code class="language-${options.lang}">${code}</code></pre>`;
}),
}),
}));
const renderWithTheme = (component: React.ReactElement, theme = 'light') => {
return render(
<ThemeProvider attribute="class" defaultTheme={theme}>
{component}
</ThemeProvider>,
);
};
describe('SyntaxHighlighter', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state initially', () => {
renderWithTheme(
<SyntaxHighlighter code="console.log('test');" language="javascript" />,
);
// Should show loading state with plain code
const codeElement = screen.getByText("console.log('test');");
expect(codeElement).toBeInTheDocument();
expect(codeElement).toHaveClass('opacity-70'); // Loading state styling
});
it('renders highlighted code after loading', async () => {
renderWithTheme(
<SyntaxHighlighter code="console.log('test');" language="javascript" />,
);
// Wait for the highlighting to complete
await waitFor(() => {
const highlightedElement = screen.getByText("console.log('test');");
expect(highlightedElement).toBeInTheDocument();
});
});
it('handles unsupported languages gracefully', async () => {
renderWithTheme(
<SyntaxHighlighter code="some code" language="unsupported-lang" />,
);
await waitFor(() => {
const codeElement = screen.getByText('some code');
expect(codeElement).toBeInTheDocument();
});
});
it('applies correct theme based on theme provider', async () => {
renderWithTheme(
<SyntaxHighlighter code="const x = 1;" language="javascript" />,
'dark',
);
await waitFor(() => {
const codeElement = screen.getByText('const x = 1;');
expect(codeElement).toBeInTheDocument();
});
});
it('handles empty code', async () => {
renderWithTheme(<SyntaxHighlighter code="" language="javascript" />);
await waitFor(() => {
// Should render without errors - check for the container div
const container = document.querySelector('.bg-muted');
expect(container).toBeInTheDocument();
});
});
it('applies custom className', async () => {
renderWithTheme(
<SyntaxHighlighter
code="test"
language="javascript"
className="custom-class"
/>,
);
await waitFor(() => {
const container = document.querySelector('.custom-class');
expect(container).toBeInTheDocument();
expect(container).toHaveClass('custom-class');
});
});
it('falls back to plain code on error', async () => {
// Mock an error in the highlighter
const mockCreateHighlighter = vi
.fn()
.mockRejectedValue(new Error('Highlighting failed'));
vi.doMock('shiki', () => ({
createHighlighter: mockCreateHighlighter,
}));
renderWithTheme(
<SyntaxHighlighter code="error code" language="javascript" />,
);
await waitFor(() => {
const codeElement = screen.getByText('error code');
expect(codeElement).toBeInTheDocument();
// Should be in a pre/code fallback structure
expect(codeElement.closest('pre')).toBeInTheDocument();
});
});
it('handles different programming languages', async () => {
const languages = ['python', 'typescript', 'java'];
for (const lang of languages) {
const { unmount } = renderWithTheme(
<SyntaxHighlighter code={`// ${lang} code`} language={lang} />,
);
await waitFor(() => {
const codeElement = screen.getByText(`// ${lang} code`);
expect(codeElement).toBeInTheDocument();
});
unmount();
}
});
it('caches highlighter instance for performance', async () => {
// Render multiple instances
const { unmount: unmount1 } = renderWithTheme(
<SyntaxHighlighter code="code1" language="javascript" />,
);
const { unmount: unmount2 } = renderWithTheme(
<SyntaxHighlighter code="code2" language="python" />,
);
await waitFor(() => {
expect(screen.getByText('code1')).toBeInTheDocument();
expect(screen.getByText('code2')).toBeInTheDocument();
});
// Both components should render successfully
expect(screen.getByText('code1')).toBeInTheDocument();
expect(screen.getByText('code2')).toBeInTheDocument();
unmount1();
unmount2();
});
});

View file

@ -12,4 +12,19 @@ global.IntersectionObserver = vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
}));
// Mock matchMedia for next-themes
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
beforeEach(() => vi.clearAllMocks());

126
pnpm-lock.yaml generated
View file

@ -302,6 +302,9 @@ importers:
require-in-the-middle:
specifier: ^7.5.2
version: 7.5.2
shiki:
specifier: ^3.7.0
version: 3.7.0
sonner:
specifier: ^2.0.3
version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -2451,6 +2454,27 @@ packages:
peerDependencies:
webpack: '>=4.40.0'
'@shikijs/core@3.7.0':
resolution: {integrity: sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==}
'@shikijs/engine-javascript@3.7.0':
resolution: {integrity: sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA==}
'@shikijs/engine-oniguruma@3.7.0':
resolution: {integrity: sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==}
'@shikijs/langs@3.7.0':
resolution: {integrity: sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==}
'@shikijs/themes@3.7.0':
resolution: {integrity: sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==}
'@shikijs/types@3.7.0':
resolution: {integrity: sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
@ -4345,6 +4369,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
@ -4371,6 +4398,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
@ -5359,6 +5389,12 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
oniguruma-to-es@4.3.3:
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -5844,6 +5880,15 @@ packages:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@6.0.1:
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
@ -6038,6 +6083,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@3.7.0:
resolution: {integrity: sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg==}
shimmer@1.2.1:
resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==}
@ -8809,6 +8857,39 @@ snapshots:
- encoding
- supports-color
'@shikijs/core@3.7.0':
dependencies:
'@shikijs/types': 3.7.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.7.0':
dependencies:
'@shikijs/types': 3.7.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.3
'@shikijs/engine-oniguruma@3.7.0':
dependencies:
'@shikijs/types': 3.7.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.7.0':
dependencies:
'@shikijs/types': 3.7.0
'@shikijs/themes@3.7.0':
dependencies:
'@shikijs/types': 3.7.0
'@shikijs/types@3.7.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/utils@0.3.0': {}
@ -10965,6 +11046,20 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.7
@ -11007,6 +11102,8 @@ snapshots:
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
http-errors@2.0.0:
dependencies:
depd: 2.0.0
@ -12150,6 +12247,14 @@ snapshots:
dependencies:
mimic-function: 5.0.1
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.3:
dependencies:
oniguruma-parser: 0.12.1
regex: 6.0.1
regex-recursion: 6.0.2
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@ -12676,6 +12781,16 @@ snapshots:
get-proto: 1.0.1
which-builtin-type: 1.2.1
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.0.1:
dependencies:
regex-utilities: 2.3.0
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
@ -12976,6 +13091,17 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@3.7.0:
dependencies:
'@shikijs/core': 3.7.0
'@shikijs/engine-javascript': 3.7.0
'@shikijs/engine-oniguruma': 3.7.0
'@shikijs/langs': 3.7.0
'@shikijs/themes': 3.7.0
'@shikijs/types': 3.7.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
shimmer@1.2.1: {}
side-channel-list@1.0.0: