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
26 changes: 25 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,31 @@ try {
}
}

// Draft posts build and answer at their real URL, but must stay out of the
// sitemap. Collections don't exist yet at config time, so read the frontmatter
// off disk — the same rule content.config.ts applies, one layer earlier.
/** @type {Set<string>} */
const draftPaths = new Set();
try {
const postsDir = join(__dirname, 'src/content/posts');
for (const slug of readdirSync(postsDir)) {
if (slug.startsWith('.')) continue;
try {
const content = readFileSync(join(postsDir, slug, 'index.mdx'), 'utf8');
const fm = content.match(/^---\n([\s\S]*?)\n---/);
if (fm && /^draft:[ \t]*true[ \t]*$/m.test(fm[1])) draftPaths.add(`/blog/${slug}`);
} catch {
// not a post directory, or no index.mdx — nothing to exclude
}
}
} catch (err) {
if (err instanceof Error && 'code' in err && err.code !== 'ENOENT') {
console.warn('[sitemap] draft scan failed:', err.message);
}
}

/** @type {string[]} */
const SKIP_PATTERNS = ['/write', '/search'];
const SKIP_PATTERNS = ['/write', '/search', '/drafts'];

// Paginated listing pages (/blog/2, /topics/x/2, /tags/x/2, /authors/x/articles/2)
// are secondary — keep them below their first page and below real articles.
Expand Down Expand Up @@ -172,6 +195,7 @@ export default defineConfig({
try {
const url = new URL(page);
const p = url.pathname.replace(/\/$/, '') || '/';
if (draftPaths.has(p)) return false;
return !SKIP_PATTERNS.some((skip) => p === skip || p.startsWith(skip));
} catch {
return true;
Expand Down
65 changes: 65 additions & 0 deletions src/components/DraftNotice.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
interface Props {
slug: string;
}

const { slug } = Astro.props;
---

<aside class="draft-notice">
<span class="draft-notice-tag">Draft</span>
<p>Not publicly listed yet. <a href="/drafts">All drafts</a></p>
<a class="draft-notice-publish" href={`/write?edit=${encodeURIComponent(slug)}&publish=1`}>
Publish →
</a>
</aside>

<style>
.draft-notice {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
margin-bottom: var(--sp-5);
padding: var(--sp-3) var(--sp-4);
border: 1px dashed var(--line-2);
border-radius: var(--radius-sm);
background: var(--paper-2);
}

.draft-notice-tag {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 2px var(--sp-2);
border-radius: 999px;
border: 1px solid var(--line-2);
color: var(--ink-3);
}

.draft-notice p {
margin: 0;
flex: 1;
font-size: 14px;
color: var(--ink-2);
}

.draft-notice-publish {
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
padding: var(--sp-2) var(--sp-4);
border: 1px solid var(--line-2);
border-radius: var(--radius-sm);
background: var(--elevated);
color: var(--ink);
text-decoration: none;
white-space: nowrap;
}

.draft-notice-publish:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
10 changes: 8 additions & 2 deletions src/pages/blog/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ import { mdxComponents } from '@/components/MDXComponents.tsx';
import ArticleActions from '@/components/ArticleActions.tsx';
import Comments from '@/components/Comments.tsx';
import Avatar from '@/components/Avatar.astro';
import DraftNotice from '@/components/DraftNotice.astro';
import { formatDate, sortPostsByDate, tagSlug } from '@/lib/data';
import { topicName } from '@/lib/topics';
import { resolvePostAuthors } from '@/lib/posts';
import { SITE } from '@/lib/site';
import katexCssUrl from 'katex/dist/katex.min.css?url';

export async function getStaticPaths() {
const posts = await getCollection('posts', ({ data }) => !data.draft);
// Drafts build and answer at their real URL — they are held back from the
// listings, the sitemap and search, not from the site.
const posts = await getCollection('posts');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
Expand Down Expand Up @@ -128,17 +131,20 @@ const breadcrumbJsonLd = {
section={topicLabel}
author={authorNames}
tags={post.data.tags}
noindex={post.data.draft}
jsonLd={[articleJsonLd, breadcrumbJsonLd]}
>
{hasMath && <link slot="head" rel="stylesheet" href={katexCssUrl} />}
<article
class="article-shell"
data-pagefind-body
data-pagefind-body={post.data.draft ? undefined : true}
data-pagefind-meta={`${topicLabel ? `topic:${topicLabel}, ` : ''}date:${formatDate(dateISO)}, read:${post.data.readMin} min, authors:${authorNames}`}
>
<div class="article-head">
<a href="/blog" class="article-back">← Back to archive</a>

{post.data.draft && <DraftNotice slug={post.id} />}

<div class="article-byline" style="margin-bottom: 4px;">
{post.data.topicId && <span class="chip chip--sm">{topicLabel}</span>}
{
Expand Down
115 changes: 115 additions & 0 deletions src/pages/drafts.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '@/layouts/BaseLayout.astro';
import { formatDate, sortPostsByDate } from '@/lib/data';

const drafts = (await getCollection('posts', ({ data }) => data.draft)).sort(sortPostsByDate);
---

<BaseLayout
title="Drafts"
description="Posts that are written but not publicly listed yet."
noindex={true}
>
<div class="container">
<div class="page-head">
<div class="eyebrow page-eyebrow">Not listed</div>
<h1 class="page-h1">Drafts.</h1>
<p class="page-lede">They are not publicly listed yet, please edit to make it public.</p>
</div>

{
drafts.length === 0 ? (
<p class="draft-empty">Nothing in drafts.</p>
) : (
<div class="draft-list">
{drafts.map((post) => (
<div class="draft-row">
<a href={`/blog/${post.id}`} class="draft-row-main">
<h3 class="draft-row-title">{post.data.title}</h3>
{post.data.summary && <p class="draft-row-desc">{post.data.summary}</p>}
<span class="draft-row-date">{formatDate(post.data.date.toISOString())}</span>
</a>
<a
class="draft-row-publish"
href={`/write?edit=${encodeURIComponent(post.id)}&publish=1`}
>
Publish →
</a>
</div>
))}
</div>
)
}
</div>
</BaseLayout>

<style>
.draft-empty {
margin-top: var(--sp-5);
color: var(--ink-3);
}

.draft-list {
margin-top: var(--sp-5);
border-top: 1px solid var(--line);
}

.draft-row {
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-4) 0;
border-bottom: 1px solid var(--line);
}

.draft-row-main {
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}

.draft-row-title {
margin: 0;
font-family: var(--font-display);
font-size: 20px;
line-height: 1.3;
}

.draft-row-main:hover .draft-row-title {
color: var(--accent);
}

.draft-row-desc {
margin: var(--sp-1) 0 0;
font-size: 14px;
color: var(--ink-2);
}

.draft-row-date {
display: block;
margin-top: var(--sp-2);
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
}

.draft-row-publish {
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
padding: var(--sp-2) var(--sp-4);
border: 1px solid var(--line-2);
border-radius: var(--radius-sm);
background: var(--elevated);
color: var(--ink);
text-decoration: none;
white-space: nowrap;
}

.draft-row-publish:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
57 changes: 53 additions & 4 deletions src/write/WritePortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import { serializePost, type PostMeta, type SBlock, type TableStyle } from './se
import { slugify, suggest, validate } from './serialize/validate';
import { convertMdx } from './convert/mdxToSource.mjs';
import { buildZip } from './serialize/toZip';
import { buildSource, parseSource, type ParsedSource } from './serialize/source';
import { buildSource, parseSource } from './serialize/source';
import { authorPath, buildAuthorJson } from './serialize/author';
import { fetchExisting } from './serialize/fetchExisting';
import { fetchExisting, type LoadedSource } from './serialize/fetchExisting';
import {
assemblePostFiles,
createPullRequest,
Expand Down Expand Up @@ -77,6 +77,7 @@ function emptyMeta(): PostMeta {
slug: '',
coverFileName: '',
ogCard: false,
draft: false,
proposedTopic: '',
newAuthor: null,
};
Expand All @@ -98,6 +99,9 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
const [openError, setOpenError] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [convertible, setConvertible] = useState<string | null>(null);
const [loadNotice, setLoadNotice] = useState<string | null>(null);
const [wantsPublish, setWantsPublish] = useState(false);
const actionsRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const [openUrl, setOpenUrl] = useState('');
const [openDialog, setOpenDialog] = useState(false);
Expand Down Expand Up @@ -246,7 +250,7 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
setRestore(null);
};

const applySource = async (loaded: ParsedSource) => {
const applySource = async (loaded: LoadedSource) => {
clearAssets();
await clearStoredAssets().catch(() => undefined);
setRestore(null);
Expand All @@ -259,6 +263,7 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
authors: Array.isArray(loaded.meta.authors) ? loaded.meta.authors : [],
});
setTableVariants(loaded.tableVariants ?? {});
setLoadNotice(loaded.notice ?? null);
};

const openExisting = async () => {
Expand Down Expand Up @@ -307,6 +312,27 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
})();
}, []);

// Arriving from a draft's Publish button: /write?edit=<slug>&publish=1 opens
// the post, unticks the draft box and scrolls to the actions, so publishing is
// one click from where the reader started.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('publish') !== '1') return;
const url = new URL(window.location.href);
url.searchParams.delete('publish');
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
setWantsPublish(true);
}, []);

// Runs once the deep-linked post has finished loading — applySource would
// otherwise overwrite the untick with the draft flag read from frontmatter.
useEffect(() => {
if (!wantsPublish || autoLoading || !loadedSlug) return;
setWantsPublish(false);
setMeta((m) => ({ ...m, draft: false }));
actionsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [wantsPublish, autoLoading, loadedSlug]);

const uploadPostZip = async (file: File) => {
setUploadError(null);
setConvertible(null);
Expand Down Expand Up @@ -499,6 +525,14 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
Loading the post into the editor…
</div>
)}
{loadNotice && (
<div className="write-load-notice" role="status">
<span>{loadNotice}</span>
<button type="button" onClick={() => setLoadNotice(null)} aria-label="Dismiss">
</button>
</div>
)}
<div className="write-topbar">
<input
ref={uploadInputRef}
Expand Down Expand Up @@ -741,10 +775,25 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
</div>
)}

<div className="write-actions" style={previewOn ? { display: 'none' } : undefined}>
<div
className="write-actions"
ref={actionsRef}
style={previewOn ? { display: 'none' } : undefined}
>
{storageOff && (
<span className="write-note-inline">Autosave is off — your browser blocked storage.</span>
)}
<label
className="write-draft-toggle"
title="Builds and stays at its real URL, but is left out of every listing, the sitemap and search."
>
<input
type="checkbox"
checked={!!meta.draft}
onChange={(e) => setMeta({ ...meta, draft: e.target.checked })}
/>
Keep as draft
</label>
<button type="button" className="write-ghost-btn" disabled={busy} onClick={download}>
{busy ? 'Packaging…' : 'Download post'}
</button>
Expand Down
Loading
Loading