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
99 changes: 69 additions & 30 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const allPosts = await Promise.all(
const authors = await getCollection('authors');

const featured = allPosts.filter((p) => p.data.featured).slice(0, 3);
const featuredFinal = featured.length >= 3 ? featured : allPosts.slice(0, 3);
const featuredIds = new Set(featured.map((p) => p.id));
const recent = allPosts.filter((p) => !featuredIds.has(p.id)).slice(0, 6);

const topicCounts = countPostsByTopic(allPosts);
const articleCount = allPosts.length;
Expand Down Expand Up @@ -91,37 +92,75 @@ const featuredTools = allTools
</div>
</section>

<!-- LATEST -->
<section class="section" style="padding-top: 0;">
<div class="container">
<div class="section-head">
<div>
<div class="eyebrow">Latest</div>
<h2 class="section-title">Recently published.</h2>
{
featured.length > 0 && (
<section class="section" style="padding-top: 0;">
<div class="container">
<div class="section-head">
<div>
<div class="eyebrow">Featured</div>
<h2 class="section-title">Editors' picks.</h2>
</div>
<a href="/blog" class="section-link">
All articles →
</a>
</div>
<div class="latest-grid">
{featured.map((p) => (
<a class="article-card" href={`/blog/${p.id}`}>
<div class="article-meta">
<span class="chip chip--sm">{topicName(p.data.topicId)}</span>
</div>
<h3 class="article-title">{p.data.title}</h3>
<p class="article-summary">{p.data.summary}</p>
<div class="article-foot">
<span>{p.authors.map((a) => a.data.name).join(', ')}</span>
<span>
{formatDate(p.data.date.toISOString())} · {p.data.readMin} min
</span>
</div>
</a>
))}
</div>
</div>
<a href="/blog" class="section-link">All articles →</a>
</div>
<div class="latest-grid">
{
featuredFinal.map((p) => (
<a class="article-card" href={`/blog/${p.id}`}>
<div class="article-meta">
<span class="chip chip--sm">{topicName(p.data.topicId)}</span>
</div>
<h3 class="article-title">{p.data.title}</h3>
<p class="article-summary">{p.data.summary}</p>
<div class="article-foot">
<span>{p.authors.map((a) => a.data.name).join(', ')}</span>
<span>
{formatDate(p.data.date.toISOString())} · {p.data.readMin} min
</span>
</div>
</section>
)
}

{
recent.length > 0 && (
<section class="section" style={featured.length > 0 ? undefined : 'padding-top: 0;'}>
<div class="container">
<div class="section-head">
<div>
<div class="eyebrow">Latest</div>
<h2 class="section-title">Recently published.</h2>
</div>
<a href="/blog" class="section-link">
All articles →
</a>
))
}
</div>
</div>
</section>
</div>
<div class="latest-grid">
{recent.map((p) => (
<a class="article-card" href={`/blog/${p.id}`}>
<div class="article-meta">
<span class="chip chip--sm">{topicName(p.data.topicId)}</span>
</div>
<h3 class="article-title">{p.data.title}</h3>
<p class="article-summary">{p.data.summary}</p>
<div class="article-foot">
<span>{p.authors.map((a) => a.data.name).join(', ')}</span>
<span>
{formatDate(p.data.date.toISOString())} · {p.data.readMin} min
</span>
</div>
</a>
))}
</div>
</div>
</section>
)
}

<!-- TOPICS -->
<section class="section">
Expand Down
4 changes: 4 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,10 @@ a.hashtag:hover {
background: var(--paper-2);
padding: 20px;
}
.article-body .inline-figure svg {
max-width: 100%;
height: auto;
}
.article-body .inline-figure-caption {
font-family: var(--font-display);
font-style: italic;
Expand Down
144 changes: 144 additions & 0 deletions src/write/editor/blocks/SvgBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useMemo, useState } from 'react';
import { createReactBlockSpec } from '@blocknote/react';

const looksLikeSvg = (s: string): boolean => /<svg[\s>]/i.test(s);

// The SVG lives in the author's own browser and is reviewed before publish, but
// strip scripts so the in-editor preview can never execute pasted markup.
function stripScripts(svg: string): string {
return svg.replace(/<script[\s\S]*?<\/script>/gi, '');
}

type Check =
| { state: 'empty' }
| { state: 'ok'; html: string }
| { state: 'error'; message: string };

// Strict XML parsing mirrors what MDX/JSX needs to build (closed tags, quoted
// attrs, escaped &), so a preview that passes here won't break the published page.
function checkSvg(raw: string): Check {
const code = stripScripts(raw).trim();
if (!code) return { state: 'empty' };
if (!looksLikeSvg(code))
return { state: 'error', message: 'The markup must contain an <svg> element.' };
try {
const doc = new DOMParser().parseFromString(code, 'image/svg+xml');
const err = doc.querySelector('parsererror');
if (err) {
const detail = (err.textContent || '').replace(/\s+/g, ' ').trim();
return {
state: 'error',
message: detail || 'This isn’t valid SVG — check for unclosed tags.',
};
}
if (doc.documentElement.tagName.toLowerCase() !== 'svg') {
return { state: 'error', message: 'The markup must start with an <svg> element.' };
}
return { state: 'ok', html: code };
} catch {
return { state: 'error', message: 'This isn’t valid SVG markup.' };
}
}

export const createSvgBlock = createReactBlockSpec(
{
type: 'svg',
propSchema: {
code: { default: '' },
caption: { default: '' },
},
content: 'none',
},
{
render: ({ block, editor }) => {
const hasSvg = looksLikeSvg(block.props.code);
const check = useMemo(() => checkSvg(block.props.code), [block.props.code]);
const [mode, setMode] = useState<'edit' | 'preview'>(hasSvg ? 'preview' : 'edit');
const setProps = (patch: Partial<typeof block.props>) =>
editor.updateBlock(block, { props: { ...block.props, ...patch } });

return (
<figure className="write-svg" contentEditable={false}>
<div className="write-svg-bar">
<div className="write-svg-switch" role="tablist" aria-label="SVG view">
<button
type="button"
role="tab"
aria-selected={mode === 'preview'}
className={mode === 'preview' ? 'is-active' : ''}
disabled={!hasSvg}
onClick={() => setMode('preview')}
>
Preview
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'edit'}
className={mode === 'edit' ? 'is-active' : ''}
onClick={() => setMode('edit')}
>
Code
</button>
</div>
{hasSvg && (
<button
type="button"
className="write-svg-clear"
title="Remove SVG"
onClick={() => {
setProps({ code: '' });
setMode('edit');
}}
>
Remove
</button>
)}
</div>

{mode === 'edit' ? (
<div className="write-block-form">
<span className="write-block-label">
Paste SVG markup — use <code>currentColor</code> for strokes and fills so it adapts
to light &amp; dark
</span>
<textarea
autoFocus
rows={6}
className="write-svg-code"
placeholder={'<svg viewBox="0 0 400 200" ...>\n …\n</svg>'}
value={block.props.code}
onChange={(e) => setProps({ code: e.target.value })}
onBlur={() => {
if (looksLikeSvg(block.props.code)) setMode('preview');
}}
/>
</div>
) : check.state === 'ok' ? (
<div className="write-svg-preview" dangerouslySetInnerHTML={{ __html: check.html }} />
) : (
<div className="write-svg-error">
<strong>Can’t render this SVG.</strong>
<span>
{check.state === 'error' ? check.message : 'Paste some SVG markup first.'}
</span>
<button type="button" className="write-chip" onClick={() => setMode('edit')}>
Fix the code
</button>
</div>
)}

{hasSvg && (
<input
type="text"
className="write-caption-input"
placeholder="Caption (optional)"
value={block.props.caption}
onChange={(e) => setProps({ caption: e.target.value })}
/>
)}
</figure>
);
},
},
);
94 changes: 94 additions & 0 deletions src/write/editor/editor-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,100 @@
border-radius: 6px;
}

.write-svg {
margin: 0;
width: 100%;
align-self: stretch;
}
.write-svg-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.write-svg-switch {
display: inline-flex;
border: 1px solid var(--line-2);
border-radius: 8px;
overflow: hidden;
}
.write-svg-switch button {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-2);
background: var(--paper);
border: none;
padding: 6px 18px;
cursor: pointer;
}
.write-svg-switch button + button {
border-left: 1px solid var(--line-2);
}
.write-svg-switch button.is-active {
background: var(--accent);
color: var(--paper);
}
.write-svg-switch button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.write-svg-clear {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: none;
border: none;
cursor: pointer;
}
.write-svg-clear:hover {
color: var(--accent);
}
.write-svg-code {
font-family: var(--font-mono) !important;
font-size: 12px !important;
line-height: 1.5;
white-space: pre;
overflow-x: auto;
}
.write-svg-preview {
width: 100%;
box-sizing: border-box;
padding: 28px 24px;
text-align: center;
color: var(--ink);
border: 1px solid var(--line-2);
border-radius: 8px;
background: var(--paper);
}
.write-svg-preview svg {
display: block;
max-width: min(100%, 860px) !important;
width: 100%;
height: auto;
margin: 0 auto;
}
.write-svg .write-caption-input,
.write-svg .write-block-form {
width: 100%;
box-sizing: border-box;
}
.write-svg-error {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 14px 16px;
border: 1px solid var(--line-2);
border-left: 3px solid var(--accent);
border-radius: 8px;
font-size: 13px;
color: var(--ink-2);
}
.write-svg-error strong {
color: var(--ink);
}

.bn-editor [data-content-type='table'] table {
border-collapse: collapse;
font-family: var(--font-sans, sans-serif);
Expand Down
2 changes: 2 additions & 0 deletions src/write/editor/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createMathBlock } from './blocks/MathBlock';
import { createFigureBlock } from './blocks/FigureBlock';
import { createGalleryBlock } from './blocks/GalleryBlock';
import { createComponentBlock } from './blocks/ComponentBlock';
import { createSvgBlock } from './blocks/SvgBlock';

export const schema = BlockNoteSchema.create().extend({
blockSpecs: {
Expand All @@ -18,6 +19,7 @@ export const schema = BlockNoteSchema.create().extend({
figure: createFigureBlock(),
gallery: createGalleryBlock(),
customComponent: createComponentBlock(),
svg: createSvgBlock(),
},
});

Expand Down
Loading
Loading