Skip to content

Commit 3da77af

Browse files
committed
feat(blog): add blog foundation, seed article, rss, sitemap, llms.txt, robots.txt
1 parent db77e58 commit 3da77af

11 files changed

Lines changed: 1177 additions & 1 deletion

File tree

blog-contract.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Blog Architecture Contract
2+
3+
All blog implementations must adhere to these specifications:
4+
5+
## Frontmatter Schema
6+
- `title`: string (required)
7+
- `description`: string (required, 140-160 chars)
8+
- `date`: string (required, YYYY-MM-DD)
9+
- `author`: string (required, real person or editorial masthead)
10+
- `updated`: string (optional, YYYY-MM-DD)
11+
- `tags`: string[] (optional)
12+
- `draft`: boolean (optional, default false)
13+
- `canonical`: string (optional, URL)
14+
15+
## Required Routes & Endpoints
16+
- `/blog/` - Paginated or complete list of posts
17+
- `/blog/<slug>/` - Full post page
18+
- `/rss.xml` - RSS 2.0 feed
19+
- `/sitemap.xml` - XML Sitemap
20+
- `/llms.txt` - LLM context file
21+
- `robots.txt` - Explicitly allowing 20 AI crawlers
22+
23+
## Seed Article Rules
24+
- Word count: 600-900 words
25+
- First screen: 40-60 word definitional paragraph
26+
- Network links: 0 (max 2)
27+
- External citations: >= 4 genuine resolving links
28+
- Anchor text: specific and natural (no 'best ... tool', 'top 10', 'cheap ...')
29+
- No links in intro paragraph or conclusion paragraph

lint-post.mjs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
#!/usr/bin/env node
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
const NETWORK_DOMAINS = [
6+
'hacktribune.com',
7+
'hexdigest.com',
8+
'openonholiday.com',
9+
'0xegg.com',
10+
'openthebook.lol',
11+
'sudonotes.com',
12+
'tryseep.com',
13+
'formharvester.com',
14+
'codeamsterdam.nl',
15+
'freelancesoftware.nl',
16+
'mory.dev'
17+
];
18+
19+
const DENYLIST_PATTERNS = [
20+
/\bbest\s+[\w\s-]*\btool\b/i,
21+
/\btop\s+10\b/i,
22+
/\bcheap\s+[\w\s-]+\b/i,
23+
/\bclick\s+here\b/i,
24+
/\bbuy\s+now\b/i,
25+
];
26+
27+
function parseFrontmatter(rawContent) {
28+
const match = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
29+
if (!match) {
30+
return { frontmatter: null, body: rawContent, error: 'Missing or malformed frontmatter delimiters (---)' };
31+
}
32+
const yamlBlock = match[1];
33+
const body = match[2];
34+
const frontmatter = {};
35+
36+
const lines = yamlBlock.split(/\r?\n/);
37+
for (let i = 0; i < lines.length; i++) {
38+
const line = lines[i];
39+
if (!line.trim() || line.trim().startsWith('#')) continue;
40+
const colonIdx = line.indexOf(':');
41+
if (colonIdx === -1) continue;
42+
const key = line.slice(0, colonIdx).trim();
43+
let val = line.slice(colonIdx + 1).trim();
44+
45+
if (val.startsWith('[') && val.endsWith(']')) {
46+
const inner = val.slice(1, -1).trim();
47+
val = inner ? inner.split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')) : [];
48+
} else if (val.startsWith('"') && val.endsWith('"')) {
49+
val = val.slice(1, -1);
50+
} else if (val.startsWith("'") && val.endsWith("'")) {
51+
val = val.slice(1, -1);
52+
} else if (val === 'true') {
53+
val = true;
54+
} else if (val === 'false') {
55+
val = false;
56+
}
57+
frontmatter[key] = val;
58+
}
59+
60+
return { frontmatter, body };
61+
}
62+
63+
function extractLinks(markdown) {
64+
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s\)]+)\)/g;
65+
const links = [];
66+
let match;
67+
while ((match = linkRegex.exec(markdown)) !== null) {
68+
links.push({
69+
anchor: match[1].trim(),
70+
url: match[2].trim(),
71+
index: match.index
72+
});
73+
}
74+
return links;
75+
}
76+
77+
function extractParagraphs(body) {
78+
const noCode = body.replace(/```[\s\S]*?```/g, '');
79+
const lines = noCode.split(/\r?\n/);
80+
const paragraphs = [];
81+
let currentPara = [];
82+
83+
for (const line of lines) {
84+
const trimmed = line.trim();
85+
if (!trimmed) {
86+
if (currentPara.length > 0) {
87+
paragraphs.push(currentPara.join(' '));
88+
currentPara = [];
89+
}
90+
continue;
91+
}
92+
if (trimmed.startsWith('#') || trimmed.startsWith('---') || trimmed.startsWith('***') || trimmed.startsWith('|')) {
93+
if (currentPara.length > 0) {
94+
paragraphs.push(currentPara.join(' '));
95+
currentPara = [];
96+
}
97+
continue;
98+
}
99+
currentPara.push(trimmed);
100+
}
101+
if (currentPara.length > 0) {
102+
paragraphs.push(currentPara.join(' '));
103+
}
104+
return paragraphs;
105+
}
106+
107+
export function lintMarkdown(filePath) {
108+
const errors = [];
109+
const warnings = [];
110+
111+
if (!fs.existsSync(filePath)) {
112+
return { valid: false, errors: [`File not found: ${filePath}`], warnings: [] };
113+
}
114+
115+
const raw = fs.readFileSync(filePath, 'utf8');
116+
const { frontmatter, body, error: fmError } = parseFrontmatter(raw);
117+
118+
if (fmError || !frontmatter) {
119+
return { valid: false, errors: [fmError || 'Invalid frontmatter'], warnings: [] };
120+
}
121+
122+
if (!frontmatter.title || typeof frontmatter.title !== 'string' || !frontmatter.title.trim()) {
123+
errors.push('Frontmatter missing required field: "title"');
124+
}
125+
126+
if (!frontmatter.description || typeof frontmatter.description !== 'string') {
127+
errors.push('Frontmatter missing required field: "description"');
128+
} else {
129+
const descLen = frontmatter.description.trim().length;
130+
if (descLen < 140 || descLen > 160) {
131+
errors.push(`Description length must be between 140 and 160 characters (currently ${descLen} chars: "${frontmatter.description.trim()}")`);
132+
}
133+
}
134+
135+
if (!frontmatter.date) {
136+
errors.push('Frontmatter missing required field: "date" (YYYY-MM-DD)');
137+
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(String(frontmatter.date).trim())) {
138+
errors.push(`Date format must be YYYY-MM-DD (got: "${frontmatter.date}")`);
139+
}
140+
141+
if (!frontmatter.author || typeof frontmatter.author !== 'string' || !frontmatter.author.trim()) {
142+
errors.push('Frontmatter missing required field: "author" (real name or editorial masthead)');
143+
}
144+
145+
if (frontmatter.updated && !/^\d{4}-\d{2}-\d{2}$/.test(String(frontmatter.updated).trim())) {
146+
errors.push(`Updated date format must be YYYY-MM-DD (got: "${frontmatter.updated}")`);
147+
}
148+
149+
const links = extractLinks(body);
150+
let networkLinksCount = 0;
151+
let externalCitationsCount = 0;
152+
153+
for (const l of links) {
154+
let hostname = '';
155+
try {
156+
hostname = new URL(l.url).hostname.replace(/^www\./, '');
157+
} catch {
158+
errors.push(`Invalid URL in link: ${l.url}`);
159+
continue;
160+
}
161+
162+
const isNetwork = NETWORK_DOMAINS.some(d => hostname === d || hostname.endsWith(`.${d}`));
163+
if (isNetwork) {
164+
networkLinksCount++;
165+
} else {
166+
externalCitationsCount++;
167+
}
168+
169+
for (const pattern of DENYLIST_PATTERNS) {
170+
if (pattern.test(l.anchor)) {
171+
errors.push(`Anchor text "${l.anchor}" violates denylist rule (${pattern})`);
172+
}
173+
}
174+
}
175+
176+
if (networkLinksCount > 2) {
177+
errors.push(`Network link count is ${networkLinksCount} (maximum allowed is 2)`);
178+
}
179+
180+
if (externalCitationsCount < 4) {
181+
errors.push(`External citation count is ${externalCitationsCount} (minimum required is 4 genuine external links)`);
182+
}
183+
184+
const paragraphs = extractParagraphs(body);
185+
if (paragraphs.length === 0) {
186+
errors.push('Article body has no content paragraphs');
187+
} else {
188+
const firstPara = paragraphs[0];
189+
const lastPara = paragraphs[paragraphs.length - 1];
190+
191+
if (extractLinks(firstPara).length > 0 || /https?:\/\//.test(firstPara)) {
192+
errors.push('First paragraph (introduction) must not contain any links');
193+
}
194+
195+
if (extractLinks(lastPara).length > 0 || /https?:\/\//.test(lastPara)) {
196+
errors.push('Last paragraph (conclusion) must not contain any links (no CTA link)');
197+
}
198+
}
199+
200+
return {
201+
valid: errors.length === 0,
202+
errors,
203+
warnings,
204+
stats: {
205+
networkLinks: networkLinksCount,
206+
externalCitations: externalCitationsCount,
207+
paragraphCount: paragraphs.length,
208+
descLength: frontmatter.description ? frontmatter.description.trim().length : 0
209+
}
210+
};
211+
}
212+
213+
if (process.argv[1] && (path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')) || process.argv[1].endsWith('lint-post.mjs'))) {
214+
const targetFile = process.argv[2];
215+
if (!targetFile) {
216+
console.error('Usage: node lint-post.mjs <path-to-post.md>');
217+
process.exit(1);
218+
}
219+
220+
const result = lintMarkdown(path.resolve(targetFile));
221+
console.log(`\nLinting: ${targetFile}`);
222+
console.log(`Status: ${result.valid ? 'PASSED' : 'FAILED'}`);
223+
if (result.stats) {
224+
console.log(`- Description length: ${result.stats.descLength} chars`);
225+
console.log(`- Network links: ${result.stats.networkLinks} (max 2)`);
226+
console.log(`- External citations: ${result.stats.externalCitations} (min 4)`);
227+
console.log(`- Body paragraphs: ${result.stats.paragraphCount}`);
228+
}
229+
230+
if (result.errors.length > 0) {
231+
console.error('\nErrors:');
232+
result.errors.forEach(e => console.error(` ✖ ${e}`));
233+
}
234+
if (result.warnings.length > 0) {
235+
console.warn('\nWarnings:');
236+
result.warnings.forEach(w => console.warn(` ⚠ ${w}`));
237+
}
238+
239+
process.exit(result.valid ? 0 : 1);
240+
}

site/public/llms.txt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# sudonotes
2+
3+
> sudonotes is a free, MIT-licensed, local-first notebook for AI prompts and developer ideas. It stores notes as plain Markdown files in a local folder with bidirectional linking, model tracking, and zero account requirements or cloud telemetry.
4+
5+
## Blog & Engineering Articles
6+
7+
- [Local-First Prompt Management: Architecting Developer Notebooks for AI Engineering](https://sudonotes.com/blog/local-first-prompt-management-developer-notebooks): Why AI developers are replacing cloud prompt databases with local-first plain text Markdown notebooks, file system synchronization, and local inference.
8+
- [Blog Overview](https://sudonotes.com/blog): Engineering guides, architecture breakdowns, and local AI workflows.
9+
10+
## Key Pages
11+
12+
- [Homepage](https://sudonotes.com/): Product overview, features, and local workflow illustration.
13+
- [Documentation](https://sudonotes.com/docs): Guides, vault formats, shortcuts, backups, and recovery.
14+
- [Download](https://sudonotes.com/download): Desktop installers for Windows, macOS, and Linux.
15+
- [Open Source](https://sudonotes.com/open-source): MIT repository, contributions, and architecture notes.
16+
- [RSS Feed](https://sudonotes.com/rss.xml): RSS 2.0 feed of all published engineering articles.

site/public/robots.txt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,68 @@
11
User-agent: *
22
Allow: /
33

4+
User-agent: GPTBot
5+
Allow: /
6+
7+
User-agent: ChatGPT-User
8+
Allow: /
9+
10+
User-agent: OAI-SearchBot
11+
Allow: /
12+
13+
User-agent: ClaudeBot
14+
Allow: /
15+
16+
User-agent: Claude-User
17+
Allow: /
18+
19+
User-agent: Claude-SearchBot
20+
Allow: /
21+
22+
User-agent: anthropic-ai
23+
Allow: /
24+
25+
User-agent: PerplexityBot
26+
Allow: /
27+
28+
User-agent: Perplexity-User
29+
Allow: /
30+
31+
User-agent: Google-Extended
32+
Allow: /
33+
34+
User-agent: Applebot-Extended
35+
Allow: /
36+
37+
User-agent: Bingbot
38+
Allow: /
39+
40+
User-agent: CCBot
41+
Allow: /
42+
43+
User-agent: cohere-ai
44+
Allow: /
45+
46+
User-agent: Meta-ExternalAgent
47+
Allow: /
48+
49+
User-agent: Amazonbot
50+
Allow: /
51+
52+
User-agent: Bytespider
53+
Allow: /
54+
55+
User-agent: Diffbot
56+
Allow: /
57+
58+
User-agent: ImagesiftBot
59+
Allow: /
60+
61+
User-agent: Omgili
62+
Allow: /
63+
64+
User-agent: YouBot
65+
Allow: /
66+
67+
Sitemap: https://sudonotes.com/sitemap.xml
468
Sitemap: https://sudonotes.com/sitemap-index.xml

site/src/content.config.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,18 @@ const docs = defineCollection({
2424
}),
2525
});
2626

27-
export const collections = { docs };
27+
const blog = defineCollection({
28+
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
29+
schema: z.object({
30+
title: z.string(),
31+
description: z.string(),
32+
date: z.string(),
33+
author: z.string(),
34+
updated: z.string().optional(),
35+
tags: z.array(z.string()).default([]),
36+
draft: z.boolean().default(false),
37+
canonical: z.string().optional(),
38+
}),
39+
});
40+
41+
export const collections = { docs, blog };

0 commit comments

Comments
 (0)