diff --git a/src/components/BlogFilter.astro b/src/components/BlogFilter.astro index 51ed049..c3d68ab 100644 --- a/src/components/BlogFilter.astro +++ b/src/components/BlogFilter.astro @@ -1,57 +1,25 @@ --- -// Topic filter chips. Each chip is a real anchor — works for direct URLs, -// crawlers, and JS-disabled clients. A small enhancement script intercepts -// clicks for an instant in-page filter (no reload). +// Topic chips — each links to its topic hub page; "All" is the paginated archive. -import { TOPICS } from '@/lib/data'; +import { TOPICS } from '@/lib/topics'; interface Props { - activeTopic: string; allCount: number; topicCounts: Record; } -const { activeTopic, allCount, topicCounts } = Astro.props; +const { allCount, topicCounts } = Astro.props; + +// Topic pages are only generated for topics with posts — hide empty ones. +const visibleTopics = TOPICS.filter((t) => (topicCounts[t.id] ?? 0) > 0); --- -
- - All ({allCount}) - + - - diff --git a/src/components/Pager.astro b/src/components/Pager.astro new file mode 100644 index 0000000..bc0985d --- /dev/null +++ b/src/components/Pager.astro @@ -0,0 +1,107 @@ +--- +import type { Page } from 'astro'; + +interface Props { + page: Page; +} +const { page } = Astro.props; + +const base = + page.url.first ?? + (page.currentPage === 1 ? page.url.current : page.url.current.replace(/\/\d+$/, '')); +const urlFor = (n: number) => (n === 1 ? base : `${base}/${n}`); + +// First, last, and a one-page window around the current page; gaps become ellipses. +const shown = [ + ...new Set( + [1, page.currentPage - 1, page.currentPage, page.currentPage + 1, page.lastPage].filter( + (n) => n >= 1 && n <= page.lastPage, + ), + ), +].sort((a, b) => a - b); + +const items: (number | '…')[] = []; +shown.forEach((n, i) => { + if (i > 0 && n - shown[i - 1] > 1) items.push('…'); + items.push(n); +}); +--- + +{ + page.lastPage > 1 && ( + + ) +} + + diff --git a/src/components/PostRow.astro b/src/components/PostRow.astro index 869a408..b432653 100644 --- a/src/components/PostRow.astro +++ b/src/components/PostRow.astro @@ -1,5 +1,6 @@ --- -import { formatDate, topicName } from '@/lib/data'; +import { formatDate } from '@/lib/data'; +import { topicName } from '@/lib/topics'; import type { PostWithAuthors } from '@/lib/posts'; interface Props { diff --git a/src/components/SearchInline.tsx b/src/components/SearchInline.tsx index b81fd66..d228ceb 100644 --- a/src/components/SearchInline.tsx +++ b/src/components/SearchInline.tsx @@ -1,9 +1,10 @@ 'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { TOPICS } from '@/lib/data'; import { loadPagefind, type PagefindResultData } from '@/lib/pagefind'; +type Topic = { id: string; name: string; desc: string }; + type Group = 'topic' | 'article' | 'tool' | 'author' | 'page'; type RowItem = { @@ -43,7 +44,7 @@ function buildMeta(group: Group, meta: PagefindResultData['meta']): string { return parts.join(' · '); } -export default function SearchInline() { +export default function SearchInline({ topics }: { topics: Topic[] }) { const [query, setQuery] = useState(''); const [pageResults, setPageResults] = useState([]); const [loading, setLoading] = useState(false); @@ -69,17 +70,16 @@ export default function SearchInline() { const topicMatches = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; - return TOPICS.filter( - (t) => t.name.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q), - ) + return topics + .filter((t) => t.name.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q)) .slice(0, 3) .map((t) => ({ group: 'topic' as const, - url: `/blog?topic=${t.id}`, + url: `/topics/${t.id}`, title: t.name, excerpt: t.desc, })); - }, [query]); + }, [query, topics]); const rows = useMemo(() => { const fromPagefind: RowItem[] = pageResults.map((r) => { @@ -210,8 +210,8 @@ export default function SearchInline() {
Browse by topic
- {TOPICS.map((t) => ( - + {topics.map((t) => ( + {t.name} ))} diff --git a/src/components/SearchModal.astro b/src/components/SearchModal.astro index 5d80217..fabe56f 100644 --- a/src/components/SearchModal.astro +++ b/src/components/SearchModal.astro @@ -1,9 +1,12 @@ --- -import { TOPICS } from '@/lib/data'; +import { TOPICS } from '@/lib/topics'; const HINT_TERMS = ['attention', 'quantization', 'vLLM', 'FSDP', 'evals']; --- + + diff --git a/src/pages/contribute/index.astro b/src/pages/contribute/index.astro index 53f88e0..ee6f35f 100644 --- a/src/pages/contribute/index.astro +++ b/src/pages/contribute/index.astro @@ -17,7 +17,7 @@ const STEPS = [ { n: '03', title: 'Draft', - body: 'Write it your way. Use our in-browser editor at /write — no markdown needed — or write MDX directly if you prefer. Either way, add code, figures, equations, or links if they help.', + body: 'Write in the in-browser editor at /write — headings, images, tables, video, math, no markdown needed. One click submits it when you are done.', }, { n: '04', @@ -70,22 +70,22 @@ const STEPS = [ Pitch on GitHub Discussions Email an idea - Open a PR
-

Two ways to write

+

Write in your browser

- New to this? Use the in-browser editor — add headings, images, tables, video, and math visually, then download a ready-to-publish - folder. No markdown, no setup. + The editor handles everything — visual + writing, live preview, and one-click submission. No account, no markdown, no setup.

-

- Comfortable with MDX? Write it directly. Either way, send it to us as a pull request or - by email and we'll take it from there. +

+ Prefer raw MDX? You can still write it by hand and open a + pull request yourself.

diff --git a/src/pages/index.astro b/src/pages/index.astro index 58b26ce..2451f1f 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -4,7 +4,8 @@ import BaseLayout from '@/layouts/BaseLayout.astro'; import HeroFigure from '@/components/HeroFigure.tsx'; import ToolGlyph from '@/components/ToolGlyph.tsx'; import Stat from '@/components/Stat.astro'; -import { TOPICS, countPostsByTopic, formatDate, sortPostsByDate, topicName } from '@/lib/data'; +import { formatDate, sortPostsByDate } from '@/lib/data'; +import { TOPICS, countPostsByTopic, topicName } from '@/lib/topics'; import { resolvePostAuthors } from '@/lib/posts'; import { SITE, SOCIALS } from '@/lib/site'; @@ -220,7 +221,7 @@ const featuredTools = allTools {t.totalCount > TOPIC_PREVIEW_LIMIT && ( - + All {t.totalCount} articles in {t.name} → )} diff --git a/src/pages/og/post/[slug].png.ts b/src/pages/og/post/[slug].png.ts index a866a2b..77670f3 100644 --- a/src/pages/og/post/[slug].png.ts +++ b/src/pages/og/post/[slug].png.ts @@ -5,7 +5,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { generateOgPng } from '@/lib/og'; import { pngResponseWithFallback } from '@/lib/og-response'; -import { topicName } from '@/lib/data'; +import { topicName } from '@/lib/topics'; // Per-post OG cards (title composited over the cover) render ONLY for posts that // opted in via `ogCard: true` + a cover. Everyone else uses the raw cover or the diff --git a/src/pages/playground/index.astro b/src/pages/playground/index.astro index b964e6f..b647206 100644 --- a/src/pages/playground/index.astro +++ b/src/pages/playground/index.astro @@ -3,10 +3,12 @@ import { getCollection } from 'astro:content'; import { Image } from 'astro:assets'; import BaseLayout from '@/layouts/BaseLayout.astro'; import Playground from '@/components/Playground.tsx'; -import { EXTERNAL_TOOLS } from '@/lib/data'; - const allTools = await getCollection('tools', ({ data }) => !data.draft); +const EXTERNAL_TOOLS = (await getCollection('externalTools')) + .sort((a, b) => a.data.order - b.data.order) + .map((e) => e.data); + const TAG_ORDER: Record = { Live: 0, Beta: 1, Experimental: 2, Soon: 3 }; const coreTools = allTools .filter((t) => t.data.core) @@ -487,4 +489,18 @@ const catalogTools = allTools.sort((a, b) => { if (empty) empty.hidden = visible > 0; }); }); + + // Hand wheel scrolling off to the page when the catalog box hits an edge, + // so the pointer resting inside it never traps the user (macOS scroll latching). + document.querySelectorAll('.tools-catalog-scroll').forEach((box) => { + box.addEventListener( + 'wheel', + (e) => { + const atTop = e.deltaY < 0 && box.scrollTop <= 0; + const atBottom = e.deltaY > 0 && box.scrollTop + box.clientHeight >= box.scrollHeight - 1; + if (atTop || atBottom) window.scrollBy(0, e.deltaY); + }, + { passive: true }, + ); + }); diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts index 2f8bd25..31bd797 100644 --- a/src/pages/rss.xml.ts +++ b/src/pages/rss.xml.ts @@ -2,7 +2,8 @@ import rss from '@astrojs/rss'; import { getCollection } from 'astro:content'; import type { APIContext } from 'astro'; import { SITE } from '@/lib/site'; -import { sortPostsByDate, topicName } from '@/lib/data'; +import { sortPostsByDate } from '@/lib/data'; +import { topicName } from '@/lib/topics'; import { authorNames, resolvePostAuthors } from '@/lib/posts'; export async function GET(context: APIContext) { diff --git a/src/pages/search.astro b/src/pages/search.astro index 7a09cb2..9a6f83f 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -1,7 +1,7 @@ --- import BaseLayout from '@/layouts/BaseLayout.astro'; import SearchInline from '@/components/SearchInline.tsx'; -import { TOPICS } from '@/lib/data'; +import { TOPICS } from '@/lib/topics'; --- Articles, topics, tools, and authors — one box.

- + diff --git a/src/pages/topics/[id].astro b/src/pages/topics/[id]/[...page].astro similarity index 64% rename from src/pages/topics/[id].astro rename to src/pages/topics/[id]/[...page].astro index b790c58..2e37ed7 100644 --- a/src/pages/topics/[id].astro +++ b/src/pages/topics/[id]/[...page].astro @@ -1,39 +1,39 @@ --- +import type { GetStaticPaths, Page } from 'astro'; import { getCollection } from 'astro:content'; import BaseLayout from '@/layouts/BaseLayout.astro'; +import Pager from '@/components/Pager.astro'; import PostRow from '@/components/PostRow.astro'; -import { TOPICS, formatMonth, sortPostsByDate } from '@/lib/data'; -import { resolvePostAuthors } from '@/lib/posts'; +import { formatMonth, sortPostsByDate } from '@/lib/data'; +import { TOPICS } from '@/lib/topics'; +import { resolvePostAuthors, type PostWithAuthors } from '@/lib/posts'; import { SITE } from '@/lib/site'; -export async function getStaticPaths() { - const posts = await getCollection('posts', ({ data }) => !data.draft); - const active = new Set(posts.map((p) => p.data.topicId)); - return TOPICS.filter((t) => active.has(t.id)).map((t) => ({ - params: { id: t.id }, - props: { topic: t }, - })); -} +export const getStaticPaths = (async ({ paginate }) => { + const all = await getCollection('posts', ({ data }) => !data.draft); + const paths = []; + for (const topic of TOPICS) { + const raw = all.filter((p) => p.data.topicId === topic.id).sort(sortPostsByDate); + const posts = await resolvePostAuthors(raw); + paths.push(...paginate(posts, { params: { id: topic.id }, pageSize: 20, props: { topic } })); + } + return paths; +}) satisfies GetStaticPaths; interface Props { + page: Page; topic: (typeof TOPICS)[number]; } -const { topic } = Astro.props; - -const allRaw = (await getCollection('posts', ({ data }) => !data.draft)) - .filter((p) => p.data.topicId === topic.id) - .sort(sortPostsByDate); - -const posts = await resolvePostAuthors(allRaw); +const { page, topic } = Astro.props; -const grouped: Record = {}; -posts.forEach((a) => { +const grouped: Record = {}; +page.data.forEach((a) => { const m = formatMonth(a.data.date); if (!grouped[m]) grouped[m] = []; grouped[m].push(a); }); -const canonical = `${SITE.url}/topics/${topic.id}`; +const canonical = `${SITE.url}${page.url.current}`; const collectionJsonLd = { '@context': 'https://schema.org', @@ -44,9 +44,9 @@ const collectionJsonLd = { isPartOf: { '@type': 'WebSite', name: SITE.name, url: SITE.url }, mainEntity: { '@type': 'ItemList', - itemListElement: posts.map((p, i) => ({ + itemListElement: page.data.map((p, i) => ({ '@type': 'ListItem', - position: i + 1, + position: page.start + i + 1, url: `${SITE.url}/blog/${p.id}`, name: p.data.title, })), @@ -59,13 +59,18 @@ const breadcrumbJsonLd = { itemListElement: [ { '@type': 'ListItem', position: 1, name: 'Home', item: SITE.url }, { '@type': 'ListItem', position: 2, name: 'Topics', item: `${SITE.url}/topics` }, - { '@type': 'ListItem', position: 3, name: topic.name, item: canonical }, + { '@type': 'ListItem', position: 3, name: topic.name, item: `${SITE.url}/topics/${topic.id}` }, ], }; + +const title = + page.currentPage > 1 + ? `${topic.name}: guides, primers & case studies — page ${page.currentPage}` + : `${topic.name}: guides, primers & case studies`; --- { - posts.length === 0 ? ( + page.data.length === 0 ? (
No articles in this topic yet. Be the first to{' '} @@ -102,5 +107,7 @@ const breadcrumbJsonLd = { )) ) } + +
diff --git a/src/pages/topics/index.astro b/src/pages/topics/index.astro index e8d0e82..cec9a60 100644 --- a/src/pages/topics/index.astro +++ b/src/pages/topics/index.astro @@ -1,7 +1,7 @@ --- import { getCollection } from 'astro:content'; import BaseLayout from '@/layouts/BaseLayout.astro'; -import { TOPICS, countPostsByTopic } from '@/lib/data'; +import { TOPICS, countPostsByTopic } from '@/lib/topics'; const posts = await getCollection('posts', ({ data }) => !data.draft); const topicCounts = countPostsByTopic(posts); diff --git a/src/pages/write.astro b/src/pages/write.astro index dd801ea..bb05fad 100644 --- a/src/pages/write.astro +++ b/src/pages/write.astro @@ -2,7 +2,7 @@ import { getCollection } from 'astro:content'; import BaseLayout from '@/layouts/BaseLayout.astro'; import WritePortal from '@/write/WritePortal.tsx'; -import { TOPICS } from '@/lib/data'; +import { TOPICS } from '@/lib/topics'; import { SITE } from '@/lib/site'; const authorEntries = await getCollection('authors'); diff --git a/src/styles/global.css b/src/styles/global.css index 6a40740..31e5140 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1192,6 +1192,8 @@ a.hashtag:hover { gap: 16px; overflow-x: auto; scroll-snap-type: x proximity; + padding-top: 4px; + margin-top: -4px; padding-bottom: 10px; scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; @@ -1853,6 +1855,7 @@ a.hashtag:hover { .article-body table { width: max-content; max-width: 100%; + margin-inline: auto; border-collapse: collapse; font-family: var(--font-sans); font-size: 15px; diff --git a/src/write/WritePortal.tsx b/src/write/WritePortal.tsx index 808532b..4127d69 100644 --- a/src/write/WritePortal.tsx +++ b/src/write/WritePortal.tsx @@ -24,8 +24,10 @@ import { import { OpenExistingDialog } from './dialogs/OpenExistingDialog'; import { PublishDialog, type PublishStage } from './dialogs/PublishDialog'; import { MetaForm, type Option } from './meta/MetaForm'; +import { PreviewPane } from './preview/PreviewPane'; +import { usePasteImages } from './editor/usePasteImages'; import { serializePost, type PostMeta, type SBlock, type TableStyle } from './serialize/toMdx'; -import { validate } from './serialize/validate'; +import { suggest, validate } from './serialize/validate'; import { buildZip } from './serialize/toZip'; import { buildSource } from './serialize/source'; import { authorPath, buildAuthorJson } from './serialize/author'; @@ -99,10 +101,20 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: const [publishStage, setPublishStage] = useState('idle'); const [publishError, setPublishError] = useState(null); const [prUrl, setPrUrl] = useState(null); + const [previewOn, setPreviewOn] = useState(false); + const [suggestions, setSuggestions] = useState([]); // Single place for the BlockNote → serializer block-shape cast. const docBlocks = useCallback(() => editor.document as unknown as SBlock[], [editor]); + const bylineNames = + meta.authors + .map((id) => authors.find((a) => a.id === id)?.name ?? id) + .filter(Boolean) + .join(', ') || + meta.writerName || + 'You'; + const variantCss = useMemo(() => tableVariantCss(tableVariants), [tableVariants]); const images = useMemo(() => collectImages(docBlocks()), [docBlocks, editor.document]); @@ -172,6 +184,20 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: saveDraftDebounced(getDraft, () => setStorageOff(true)); }, [getDraft, restore, editor, meta]); + usePasteImages(editor, autosave); + + const continueWriting = () => { + const doc = editor.document; + const last = doc[doc.length - 1]; + const lastIsEmptyParagraph = + last.type === 'paragraph' && (!Array.isArray(last.content) || last.content.length === 0); + const target = lastIsEmptyParagraph + ? last + : (editor.insertBlocks([{ type: 'paragraph' }], last, 'after')[0] ?? last); + editor.setTextCursorPosition(target, 'end'); + editor.focus(); + }; + useEditorSelectionChange(() => { try { const sel = editor.getSelection(); @@ -280,6 +306,7 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: const found = [...validate(meta, blocks), ...oversizedIssues()]; setIssues(found); if (found.length > 0) return; + setSuggestions(suggest(meta)); setPublishError(null); setPrUrl(null); setPublishStage('idle'); @@ -378,16 +405,27 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: onClose={() => setOpenDialog(false)} /> - { - setMeta(m); - autosave(); - }} - /> + {!previewOn && ( + { + setMeta(m); + autosave(); + }} + /> + )} + + {previewOn && ( + + )} {currentTableId && barPos && @@ -444,7 +482,11 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: )} -
+
filterSuggestionItems(slashItems, query)} /> + {!restore && ( + + )} +
+ +
+
{issues.length > 0 && ( @@ -507,7 +560,7 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
)} -
+
{storageOff && ( Autosave is off — your browser blocked storage. )} @@ -526,6 +579,7 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: stage={publishStage} error={publishError} prUrl={prUrl} + suggestions={suggestions} onSubmit={() => void submitToGithub()} onClose={() => setPublishOpen(false)} /> diff --git a/src/write/dialogs/PublishDialog.tsx b/src/write/dialogs/PublishDialog.tsx index 82a3f31..8409762 100644 --- a/src/write/dialogs/PublishDialog.tsx +++ b/src/write/dialogs/PublishDialog.tsx @@ -5,11 +5,20 @@ type Props = { stage: PublishStage; error: string | null; prUrl: string | null; + suggestions?: string[]; onSubmit: () => void; onClose: () => void; }; -export function PublishDialog({ open, stage, error, prUrl, onSubmit, onClose }: Props) { +export function PublishDialog({ + open, + stage, + error, + prUrl, + suggestions = [], + onSubmit, + onClose, +}: Props) { if (!open) return null; return (
+ {suggestions.length > 0 && ( +
+ + {suggestions.length} suggestion{suggestions.length > 1 ? 's' : ''} — optional + +
    + {suggestions.map((s) => ( +
  • {s}
  • + ))} +
+
+ )} {error && (

{error} diff --git a/src/write/editor/blocks/FigureBlock.tsx b/src/write/editor/blocks/FigureBlock.tsx index 97b042e..00fd95f 100644 --- a/src/write/editor/blocks/FigureBlock.tsx +++ b/src/write/editor/blocks/FigureBlock.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { createReactBlockSpec } from '@blocknote/react'; import { addAsset, getAssetUrl, removeAsset } from '../../storage/assets'; +import { optimizeImage } from '../../storage/optimizeImage'; // preview is a relative width so sizes stay distinct in the narrow editor column. const SIZES: { key: string; label: string; width: number; preview: string }[] = [ @@ -48,9 +49,9 @@ export const createFigureBlock = createReactBlockSpec( { + onChange={async (e) => { const file = e.target.files?.[0]; - if (file) setProps({ fileName: addAsset(file) }); + if (file) setProps({ fileName: addAsset(await optimizeImage(file)) }); }} />

diff --git a/src/write/editor/blocks/GalleryBlock.tsx b/src/write/editor/blocks/GalleryBlock.tsx index 5cfa537..840b5b9 100644 --- a/src/write/editor/blocks/GalleryBlock.tsx +++ b/src/write/editor/blocks/GalleryBlock.tsx @@ -1,5 +1,6 @@ import { createReactBlockSpec } from '@blocknote/react'; import { addAsset, getAssetUrl, removeAsset } from '../../storage/assets'; +import { optimizeImage } from '../../storage/optimizeImage'; function parseList(value: string): string[] { try { @@ -75,12 +76,13 @@ export const createGalleryBlock = createReactBlockSpec( type="file" accept="image/*" multiple - onChange={(e) => { + onChange={async (e) => { const files = [...(e.target.files ?? [])]; + e.target.value = ''; if (files.length === 0) return; - const added = files.map((f) => addAsset(f)); + const optimized = await Promise.all(files.map(optimizeImage)); + const added = optimized.map((f) => addAsset(f)); update([...fileNames, ...added], [...alts, ...added.map(() => '')]); - e.target.value = ''; }} />
diff --git a/src/write/editor/editor-theme.css b/src/write/editor/editor-theme.css index 11547fb..83f1ff0 100644 --- a/src/write/editor/editor-theme.css +++ b/src/write/editor/editor-theme.css @@ -187,6 +187,30 @@ justify-content: flex-end; margin-bottom: 12px; } + +.write-preview-toggle-row { + display: flex; + justify-content: flex-end; + margin: 12px 0 4px; +} +.write-preview-btn { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; + color: var(--accent); + background: none; + border: 1px solid var(--accent-soft); + border-radius: var(--radius-pill, 99px); + padding: 8px 18px; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease; +} +.write-preview-btn:hover { + border-color: var(--accent); + background: var(--accent-soft); +} .write-open-link { font-family: var(--font-mono); font-size: 12px; @@ -200,6 +224,105 @@ color: var(--accent); } +.write-continue { + display: block; + width: 100%; + background: none; + border: none; + padding: 12px 54px 20px; + text-align: left; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-3); + opacity: 0; + cursor: text; + transition: opacity 0.12s ease; +} +.write-continue:hover, +.write-continue:focus-visible { + opacity: 1; +} + +.write-suggest { + margin: 0 0 14px; + color: var(--tc-orange, #b5610a); +} +.write-suggest summary { + cursor: pointer; + font-family: var(--font-mono); + font-size: 12px; +} +.write-suggest ul { + margin: 8px 0 0; + padding-left: 18px; + font-size: 13px; + line-height: 1.5; +} +.write-suggest li { + margin: 4px 0; +} + +.write-preview { + max-width: var(--text-w, 720px); + margin: 0 auto; + padding: 8px 0 48px; +} +.write-preview-title { + font-family: var(--font-display); + font-size: 40px; + font-weight: 400; + line-height: 1.12; + letter-spacing: -0.5px; + margin: 0 0 14px; + color: var(--ink); +} +.write-preview-lede { + font-size: 18px; + line-height: 1.5; + color: var(--ink-2); + margin: 0 0 14px; +} +.write-preview-byline { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-3); + padding-bottom: 20px; + margin-bottom: 28px; + border-bottom: 1px solid var(--line); +} +.write-preview-cover { + width: 100%; + border-radius: var(--radius-md, 10px); + margin-bottom: 28px; + display: block; +} +.write-preview-code { + background: #24292e; + color: #e1e4e8; + border-radius: 8px; + padding: 16px 18px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 13.5px; + line-height: 1.55; +} +.write-preview-code code { + background: none; + color: inherit; + font-family: inherit; + padding: 0; +} +.write-preview-component { + border: 1px dashed var(--line-2); + border-radius: 8px; + padding: 18px; + margin: 24px 0; + text-align: center; + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--ink-3); +} + .write-modal-backdrop { position: fixed; inset: 0; @@ -999,6 +1122,7 @@ .bn-editor [data-content-type='table'] table { border-collapse: collapse; + margin-inline: auto; font-family: var(--font-sans, sans-serif); font-size: 15px; line-height: 1.5; @@ -1024,6 +1148,18 @@ border-bottom: none; } +.bn-editor .bn-block-content[data-content-type='quote'] > blockquote, +.bn-editor blockquote { + border-left: 2px solid var(--accent); + margin: 0; + padding: 4px 0 4px 20px; + font-family: var(--font-display); + font-style: italic; + font-size: 22px; + line-height: 1.4; + color: var(--ink-2); +} + .bn-editor .bn-block-content[data-content-type='codeBlock'] { background: #24292e; color: #e1e4e8; diff --git a/src/write/editor/usePasteImages.ts b/src/write/editor/usePasteImages.ts new file mode 100644 index 0000000..93a6831 --- /dev/null +++ b/src/write/editor/usePasteImages.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import type { WriteEditor } from './schema'; +import { addAsset } from '../storage/assets'; +import { optimizeImage } from '../storage/optimizeImage'; + +// Pasting an image (e.g. a screenshot) into the editor inserts it as a figure +// block, running through the same optimize + asset pipeline as file uploads. +export function usePasteImages(editor: WriteEditor, onInserted: () => void): void { + useEffect(() => { + function onPaste(e: ClipboardEvent) { + const target = e.target as HTMLElement | null; + if (!target?.closest('.bn-editor')) return; + const images = [...(e.clipboardData?.files ?? [])].filter((f) => f.type.startsWith('image/')); + if (images.length === 0) return; + e.preventDefault(); + e.stopPropagation(); + void (async () => { + const names: string[] = []; + for (const file of images) names.push(addAsset(await optimizeImage(file))); + editor.insertBlocks( + names.map((fileName) => ({ type: 'figure' as const, props: { fileName } })), + editor.getTextCursorPosition().block, + 'after', + ); + onInserted(); + })(); + } + document.addEventListener('paste', onPaste, true); + return () => document.removeEventListener('paste', onPaste, true); + }, [editor, onInserted]); +} diff --git a/src/write/preview/PreviewPane.tsx b/src/write/preview/PreviewPane.tsx new file mode 100644 index 0000000..67d0110 --- /dev/null +++ b/src/write/preview/PreviewPane.tsx @@ -0,0 +1,297 @@ +import type { CSSProperties, ReactNode } from 'react'; +import katex from 'katex'; +import { mdxComponents as C } from '@/components/MDXComponents'; +import { getAssetUrl } from '../storage/assets'; +import { + BG_COLORS, + TEXT_COLORS, + type InlineRun, + type PostMeta, + type SBlock, + type TableStyle, +} from '../serialize/toMdx'; + +// Renders the draft with the same components and CSS classes the published +// article uses (MDXComponents + .article-body styles from global.css), so the +// preview is the real reading experience — not an approximation. + +function styledRun(run: { text: string; styles: Record }, key: number) { + const { text, styles } = run; + let node: ReactNode = text; + if (styles.code) node = {node}; + if (styles.bold) node = {node}; + if (styles.italic) node = {node}; + if (styles.strike) node = {node}; + if (styles.underline) node = {node}; + const style: CSSProperties = {}; + if (typeof styles.textColor === 'string' && styles.textColor && styles.textColor !== 'default') { + style.color = `var(--tc-${styles.textColor}, ${TEXT_COLORS[styles.textColor] ?? styles.textColor})`; + } + if (typeof styles.backgroundColor === 'string' && styles.backgroundColor !== 'default') { + style.backgroundColor = `var(--mark-${styles.backgroundColor}, ${BG_COLORS[styles.backgroundColor] ?? styles.backgroundColor})`; + } + return ( + + {node} + + ); +} + +function runs(content: unknown): ReactNode { + if (!Array.isArray(content)) return null; + return (content as InlineRun[]).map((run, i) => { + if (run.type === 'link') { + return ( +
+ {run.content.map((r, j) => styledRun(r, j))} + + ); + } + if (run.type === 'text') return styledRun(run, i); + return null; + }); +} + +function plainText(content: unknown): string { + if (!Array.isArray(content)) return ''; + return (content as InlineRun[]) + .map((run) => + run.type === 'link' ? run.content.map((r) => r.text).join('') : (run.text ?? ''), + ) + .join(''); +} + +function alignStyle(block: SBlock): CSSProperties | undefined { + const a = block.props.textAlignment; + return typeof a === 'string' && a !== 'left' ? { textAlign: a as 'center' | 'right' } : undefined; +} + +function cellRuns(cell: unknown): ReactNode { + if (Array.isArray(cell)) return runs(cell); + if (cell && typeof cell === 'object' && 'content' in cell) { + return runs((cell as { content: unknown }).content); + } + return null; +} + +function tableRows(block: SBlock): unknown[][] { + const rows = ((block.content as { rows?: unknown[] })?.rows ?? []) as unknown[]; + return rows.map((row) => { + const cells = (row as { cells?: unknown[] }).cells; + return Array.isArray(cells) ? cells : []; + }); +} + +type Variants = Record; + +function renderTable(block: SBlock, variants: Variants) { + const rows = tableRows(block); + if (rows.length === 0) return null; + const style = variants[block.id]; + const border = style?.border ?? 'rule'; + const zebra = style?.zebra ?? false; + return ( +
+ + + + {rows[0].map((cell, i) => ( + + ))} + + + + {rows.slice(1).map((cells, r) => ( + + {cells.map((cell, i) => ( + + ))} + + ))} + +
{cellRuns(cell)}
{cellRuns(cell)}
+
+ ); +} + +function detailsFor(block: SBlock, variants: Variants) { + return ( +
+ {runs(block.content) ?? 'Details'} + {block.children?.length ? renderBlocks(block.children, variants) : null} +
+ ); +} + +function renderOne(block: SBlock, variants: Variants): ReactNode { + switch (block.type) { + case 'paragraph': + return plainText(block.content) ? ( +

+ {runs(block.content)} +

+ ) : null; + case 'heading': { + if (block.props.isToggleable) return detailsFor(block, variants); + const level = Math.min(Number(block.props.level ?? 1), 3); + const Tag = (['h2', 'h3', 'h4'] as const)[level - 1]; + return ( + + {runs(block.content)} + + ); + } + case 'toggleListItem': + return detailsFor(block, variants); + case 'quote': + return
{runs(block.content)}
; + case 'codeBlock': + return ( +
+          {plainText(block.content)}
+        
+ ); + case 'separator': + return
; + case 'math': { + const latex = String(block.props.latex ?? ''); + return latex ? ( +
+ ) : null; + } + case 'note': { + const inner = runs(block.content); + return inner ? {inner} : null; + } + case 'figure': { + const fileName = String(block.props.fileName ?? ''); + const src = fileName ? getAssetUrl(fileName) : String(block.props.src ?? ''); + if (!src) return null; + const width = block.props.width; + return ( + + {String(block.props.alt + + ); + } + case 'svg': { + const code = String(block.props.code ?? '').trim(); + if (!code) return null; + return ( + + {/* Editor-sanitized SVG markup (scripts stripped in SvgBlock). */} +
+ + ); + } + case 'gallery': { + const names = JSON.parse(String(block.props.fileNames || '[]')) as string[]; + if (names.length === 0) return null; + const alts = JSON.parse(String(block.props.alts || '[]')) as string[]; + const min = block.props.min; + return ( + + {names.map((f, i) => ( + {alts[i] + ))} + + ); + } + case 'video': { + const id = String(block.props.videoId ?? ''); + return id ? ( + + ) : null; + } + case 'customComponent': { + const name = String(block.props.componentName ?? ''); + return ( +
+ ⚡ Interactive component {name ? `<${name}>` : ''} — runs live on the published site +
+ ); + } + case 'table': + return renderTable(block, variants); + default: + return plainText(block.content) ?

{runs(block.content)}

: null; + } +} + +function renderBlocks(blocks: SBlock[], variants: Variants): ReactNode { + const out: ReactNode[] = []; + let i = 0; + while (i < blocks.length) { + const type = blocks[i].type; + if (type === 'bulletListItem' || type === 'numberedListItem' || type === 'checkListItem') { + const items: SBlock[] = []; + while (i < blocks.length && blocks[i].type === type) items.push(blocks[i++]); + const lis = items.map((it) => ( +
  • + {type === 'checkListItem' && ( + + )} + + {runs(it.content)} + {it.children?.length ? renderBlocks(it.children, variants) : null} + +
  • + )); + out.push( + type === 'numberedListItem' ? ( +
      {lis}
    + ) : ( +
      + {lis} +
    + ), + ); + continue; + } + out.push(renderOne(blocks[i], variants)); + i++; + } + return out; +} + +type Props = { + meta: PostMeta; + blocks: SBlock[]; + tableVariants: Variants; + byline: string; +}; + +export function PreviewPane({ meta, blocks, tableVariants, byline }: Props) { + const date = new Date().toLocaleDateString('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + }); + return ( +
    +
    +

    {meta.title || 'Untitled'}

    + {meta.summary &&

    {meta.summary}

    } +
    + {byline} · {meta.date || date} +
    +
    + {meta.coverFileName && ( + + )} +
    {renderBlocks(blocks, tableVariants)}
    +
    + ); +} diff --git a/src/write/serialize/toMdx.ts b/src/write/serialize/toMdx.ts index 46f1845..ddd7b90 100644 --- a/src/write/serialize/toMdx.ts +++ b/src/write/serialize/toMdx.ts @@ -78,7 +78,7 @@ export function escapeText(s: string): string { .replace(/^(\s{0,3})([>#+-])/, '$1\\$2'); } -const TEXT_COLORS: Record = { +export const TEXT_COLORS: Record = { gray: '#9b9a97', brown: '#64473a', red: '#e03e3e', @@ -90,7 +90,7 @@ const TEXT_COLORS: Record = { pink: '#c14c8a', }; -const BG_COLORS: Record = { +export const BG_COLORS: Record = { gray: '#ebeced', brown: '#e9e5e3', red: '#fbe4e4', diff --git a/src/write/serialize/validate.ts b/src/write/serialize/validate.ts index d719c02..1f9d250 100644 --- a/src/write/serialize/validate.ts +++ b/src/write/serialize/validate.ts @@ -61,6 +61,24 @@ export function validate(meta: PostMeta, blocks: SBlock[]): string[] { return [...new Set(issues)]; } +// Non-blocking SEO/quality nudges shown in the publish dialog. +export function suggest(meta: PostMeta): string[] { + const s: string[] = []; + const title = meta.title.trim().length; + if (title > 60) s.push(`Title is ${title} characters — search results cut off around 60.`); + const sum = meta.summary.trim().length; + if (sum > 160) { + s.push(`Summary is ${sum} characters — it doubles as the meta description; ~155 fits.`); + } else if (sum > 0 && sum < 50) { + s.push( + 'Summary is one short phrase — a full sentence reads better in search and social cards.', + ); + } + if (meta.tags.length === 0) + s.push('No tags yet — tags power tag pages and related-article matching.'); + return s; +} + export function slugify(title: string): string { return title .toLowerCase() diff --git a/src/write/storage/optimizeImage.test.ts b/src/write/storage/optimizeImage.test.ts new file mode 100644 index 0000000..6ba0614 --- /dev/null +++ b/src/write/storage/optimizeImage.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { optimizeImage } from './optimizeImage'; + +function fakeFile(name: string, type: string, bytes: number): File { + return new File([new Uint8Array(bytes)], name, { type }); +} + +describe('optimizeImage', () => { + it('passes small images through untouched', async () => { + const file = fakeFile('photo.png', 'image/png', 100 * 1024); + expect(await optimizeImage(file)).toBe(file); + }); + + it('skips gifs and svgs regardless of size', async () => { + const gif = fakeFile('anim.gif', 'image/gif', 900 * 1024); + const svg = fakeFile('chart.svg', 'image/svg+xml', 900 * 1024); + expect(await optimizeImage(gif)).toBe(gif); + expect(await optimizeImage(svg)).toBe(svg); + }); + + it('falls back to the original when decoding is unavailable', async () => { + const file = fakeFile('big.png', 'image/png', 900 * 1024); + expect(await optimizeImage(file)).toBe(file); + }); +}); diff --git a/src/write/storage/optimizeImage.ts b/src/write/storage/optimizeImage.ts new file mode 100644 index 0000000..671678b --- /dev/null +++ b/src/write/storage/optimizeImage.ts @@ -0,0 +1,38 @@ +// Optional compression pass in front of addAsset(). To drop the feature, delete +// this file and unwrap the optimizeImage() calls in FigureBlock and GalleryBlock. + +const SKIP_UNDER_BYTES = 400 * 1024; +const MAX_EDGE = 2400; +const QUALITY = 0.85; + +// GIFs would lose animation; SVGs are text and already small. +const SKIP_TYPES = new Set(['image/gif', 'image/svg+xml']); + +export async function optimizeImage(file: File): Promise { + if (file.size < SKIP_UNDER_BYTES || SKIP_TYPES.has(file.type)) return file; + try { + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height)); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(bitmap.width * scale); + canvas.height = Math.round(bitmap.height * scale); + const ctx = canvas.getContext('2d'); + if (!ctx) { + bitmap.close(); + return file; + } + ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close(); + + const blob = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b), 'image/webp', QUALITY), + ); + // Keep the original when the browser can't emit webp or re-encoding didn't shrink it. + if (!blob || blob.type !== 'image/webp' || blob.size >= file.size) return file; + + const stem = file.name.replace(/\.[^.]+$/, '') || 'image'; + return new File([blob], `${stem}.webp`, { type: 'image/webp' }); + } catch { + return file; + } +}