Skip to content

Commit 4012d76

Browse files
Merge pull request #108 from FireBird-Technologies/develop
Develop
2 parents 8f06749 + f7d838b commit 4012d76

4 files changed

Lines changed: 215 additions & 25 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
{
2+
"staticPages": [
3+
{
4+
"path": "/submit-your-newsletter",
5+
"changefreq": "monthly",
6+
"priority": "0.9"
7+
},
8+
{
9+
"path": "/blogs",
10+
"changefreq": "weekly",
11+
"priority": "0.6"
12+
}
13+
],
14+
"posts": [
15+
{
16+
"slug": "how-to-get-traffic-to-your-blog",
17+
"publishedAt": "2026-06-15"
18+
},
19+
{
20+
"slug": "how-to-promote-your-blog-for-free",
21+
"publishedAt": "2026-06-17"
22+
},
23+
{
24+
"slug": "how-to-grow-your-blog-audience",
25+
"publishedAt": "2026-06-19"
26+
},
27+
{
28+
"slug": "how-to-get-more-blog-readers",
29+
"publishedAt": "2026-06-21"
30+
},
31+
{
32+
"slug": "why-is-my-blog-not-getting-traffic",
33+
"publishedAt": "2026-06-23"
34+
},
35+
{
36+
"slug": "how-to-get-your-blog-noticed",
37+
"publishedAt": "2026-06-25"
38+
},
39+
{
40+
"slug": "best-blog-directories",
41+
"publishedAt": "2026-06-27"
42+
},
43+
{
44+
"slug": "how-to-submit-your-blog-to-directories",
45+
"publishedAt": "2026-06-29"
46+
},
47+
{
48+
"slug": "blog-promotion-sites",
49+
"publishedAt": "2026-07-01"
50+
},
51+
{
52+
"slug": "where-to-share-your-blog-posts",
53+
"publishedAt": "2026-07-03"
54+
},
55+
{
56+
"slug": "how-to-repurpose-blog-content",
57+
"publishedAt": "2026-07-05"
58+
},
59+
{
60+
"slug": "how-to-turn-a-blog-post-into-a-video",
61+
"publishedAt": "2026-07-07"
62+
},
63+
{
64+
"slug": "content-repurposing-strategy",
65+
"publishedAt": "2026-07-09"
66+
},
67+
{
68+
"slug": "repurpose-blog-posts-into-social-media",
69+
"publishedAt": "2026-07-11"
70+
},
71+
{
72+
"slug": "how-to-write-a-blog-post-that-ranks",
73+
"publishedAt": "2026-07-12"
74+
},
75+
{
76+
"slug": "blog-seo-for-beginners",
77+
"publishedAt": "2026-07-14"
78+
},
79+
{
80+
"slug": "how-to-come-up-with-blog-post-ideas",
81+
"publishedAt": "2026-07-15"
82+
},
83+
{
84+
"slug": "how-to-write-a-catchy-blog-title",
85+
"publishedAt": "2026-07-16"
86+
},
87+
{
88+
"slug": "how-long-should-a-blog-post-be",
89+
"publishedAt": "2026-07-17"
90+
},
91+
{
92+
"slug": "how-to-structure-a-blog-post",
93+
"publishedAt": "2026-07-18"
94+
},
95+
{
96+
"slug": "how-to-start-a-blog-for-beginners",
97+
"publishedAt": "2026-07-19"
98+
},
99+
{
100+
"slug": "best-blogging-platforms",
101+
"publishedAt": "2026-07-20"
102+
},
103+
{
104+
"slug": "build-email-list-from-your-blog",
105+
"publishedAt": "2026-07-21"
106+
},
107+
{
108+
"slug": "how-to-get-your-first-1000-blog-readers",
109+
"publishedAt": "2026-07-21"
110+
},
111+
{
112+
"slug": "how-to-promote-your-newsletter",
113+
"publishedAt": "2026-08-07"
114+
},
115+
{
116+
"slug": "best-newsletter-directories",
117+
"publishedAt": "2026-08-07"
118+
},
119+
{
120+
"slug": "substack-vs-beehiiv",
121+
"publishedAt": "2026-08-06"
122+
},
123+
{
124+
"slug": "substack-alternatives",
125+
"publishedAt": "2026-08-06"
126+
},
127+
{
128+
"slug": "best-newsletter-platforms",
129+
"publishedAt": "2026-08-05"
130+
},
131+
{
132+
"slug": "how-to-grow-on-substack",
133+
"publishedAt": "2026-08-05"
134+
},
135+
{
136+
"slug": "how-to-get-substack-subscribers",
137+
"publishedAt": "2026-08-04"
138+
},
139+
{
140+
"slug": "how-to-start-a-newsletter",
141+
"publishedAt": "2026-08-04"
142+
}
143+
]
144+
}

backend/app/routers/sitemap.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import json
12
import re
3+
from pathlib import Path
24
from urllib.parse import quote
35

46
from sqlalchemy import select
@@ -14,18 +16,18 @@
1416

1517
FRONTEND_URL = "https://bloghub.app"
1618

19+
# Hand-written blog posts (frontend/src/content/blogPosts.ts). Regenerated by
20+
# `npm run blog-sitemap` into this JSON so the API sitemap can list them without
21+
# importing the frontend.
22+
_MANUAL_BLOG_POSTS_PATH = Path(__file__).resolve().parent.parent / "data" / "manual_blog_posts.json"
23+
1724
STATIC_URLS = [
1825
{"loc": f"{FRONTEND_URL}/", "changefreq": "weekly", "priority": "1.0"},
1926
{"loc": f"{FRONTEND_URL}/dashboard", "changefreq": "hourly", "priority": "0.9"},
2027
{"loc": f"{FRONTEND_URL}/terms", "changefreq": "yearly", "priority": "0.3"},
2128
{"loc": f"{FRONTEND_URL}/privacy", "changefreq": "yearly", "priority": "0.3"},
2229
]
2330

24-
# NOTE: Blog post URLs are NOT generated here. They live in the frontend TS array
25-
# (frontend/src/content/blogPosts.ts) and are emitted at frontend build time to
26-
# /blog-sitemap.xml (see frontend/scripts/gen-blog-sitemap.mjs + robots.txt).
27-
# Adding a blog post therefore requires no backend change.
28-
2931

3032
def _slugify(title: str) -> str:
3133
slug = re.sub(r"[^a-z0-9]+", "-", (title or "").lower()).strip("-")
@@ -40,6 +42,42 @@ def _url_entry(loc: str, lastmod: str | None = None, changefreq: str = "weekly",
4042
return "\n".join(parts)
4143

4244

45+
def _manual_blog_entries() -> list[str]:
46+
"""URLs for hand-written /blogs/<slug> articles + a few static marketing pages."""
47+
if not _MANUAL_BLOG_POSTS_PATH.is_file():
48+
return []
49+
50+
data = json.loads(_MANUAL_BLOG_POSTS_PATH.read_text(encoding="utf-8"))
51+
entries: list[str] = []
52+
53+
for page in data.get("staticPages") or []:
54+
path = page.get("path")
55+
if not path:
56+
continue
57+
entries.append(
58+
_url_entry(
59+
f"{FRONTEND_URL}{path}",
60+
changefreq=page.get("changefreq") or "monthly",
61+
priority=str(page.get("priority") or "0.6"),
62+
)
63+
)
64+
65+
for post in data.get("posts") or []:
66+
slug = post.get("slug")
67+
if not slug:
68+
continue
69+
entries.append(
70+
_url_entry(
71+
f"{FRONTEND_URL}/blogs/{slug}",
72+
lastmod=post.get("publishedAt"),
73+
changefreq="monthly",
74+
priority="0.6",
75+
)
76+
)
77+
78+
return entries
79+
80+
4381
@router.get("/sitemap.xml", include_in_schema=False)
4482
async def sitemap(db: AsyncSession = Depends(get_db)):
4583
entries: list[str] = []
@@ -67,6 +105,9 @@ async def sitemap(db: AsyncSession = Depends(get_db)):
67105
lastmod = created_at.strftime("%Y-%m-%d")
68106
entries.append(_url_entry(loc, lastmod=lastmod, changefreq="monthly", priority="0.7"))
69107

108+
# Hand-written articles/guides (from blogPosts.ts via manual_blog_posts.json).
109+
entries.extend(_manual_blog_entries())
110+
70111
# Monthly category roundup pages (auto-generated, DB-backed) under /blogs/<slug>.
71112
# Each roundup with underrated entries also has a companion /blogs/<slug>/underrated page.
72113
roundups = await db.execute(

frontend/scripts/gen-blog-sitemap.mjs

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,32 @@
1-
// Build-time generator for the blog sitemap.
1+
// Build-time generator for blog sitemap artifacts.
22
//
3-
// Reads frontend/src/content/blogPosts.ts and writes public/blog-sitemap.xml so
4-
// that adding a post only requires editing blogPosts.ts — no backend change.
3+
// Reads frontend/src/content/blogPosts.ts and writes:
4+
// 1. public/blog-sitemap.xml — served at bloghub.app/blog-sitemap.xml
5+
// 2. backend/app/data/manual_blog_posts.json — consumed by api.bloghub.app/sitemap.xml
6+
//
7+
// Adding a post only requires editing blogPosts.ts, then running `npm run blog-sitemap`
8+
// (also runs automatically on `npm run build`).
59
//
610
// We parse the slug/publishedAt pairs out of the .ts source with a regex rather
711
// than importing it, so the script stays dependency-free (no tsx/ts-node).
812

9-
import { readFileSync, writeFileSync } from "node:fs";
13+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1014
import { fileURLToPath } from "node:url";
1115
import { dirname, resolve } from "node:path";
1216

1317
const SITE_URL = "https://bloghub.app";
1418

15-
// Static, crawlable frontend routes that aren't blog posts. The backend sitemap at
16-
// api.bloghub.app/sitemap.xml covers publication pages; anything hand-built in
17-
// src/pages that we want indexed belongs here.
19+
// Static, crawlable frontend routes that aren't blog posts. Also mirrored into the
20+
// backend sitemap so api.bloghub.app/sitemap.xml is the single discovery source.
1821
const STATIC_PAGES = [
1922
{ path: "/submit-your-newsletter", changefreq: "monthly", priority: "0.9" },
23+
{ path: "/blogs", changefreq: "weekly", priority: "0.6" },
2024
];
2125

2226
const __dirname = dirname(fileURLToPath(import.meta.url));
2327
const dataFile = resolve(__dirname, "../src/content/blogPosts.ts");
24-
const outFile = resolve(__dirname, "../public/blog-sitemap.xml");
28+
const outXml = resolve(__dirname, "../public/blog-sitemap.xml");
29+
const outJson = resolve(__dirname, "../../backend/app/data/manual_blog_posts.json");
2530

2631
const source = readFileSync(dataFile, "utf8");
2732

@@ -35,18 +40,18 @@ while ((match = slugRe.exec(source)) !== null) {
3540
// Find the publishedAt that follows this slug within the same object.
3641
const rest = source.slice(match.index);
3742
const dateMatch = rest.match(/publishedAt:\s*["'`](\d{4}-\d{2}-\d{2})["'`]/);
38-
posts.push({ slug, lastmod: dateMatch ? dateMatch[1] : null });
43+
posts.push({ slug, publishedAt: dateMatch ? dateMatch[1] : null });
3944
}
4045

4146
if (posts.length === 0) {
4247
console.warn("[gen-blog-sitemap] No posts found in blogPosts.ts");
4348
}
4449

45-
const urlEntry = ({ slug, lastmod }) => {
50+
const urlEntry = ({ slug, publishedAt }) => {
4651
const lines = [
4752
" <url>",
4853
` <loc>${SITE_URL}/blogs/${slug}</loc>`,
49-
...(lastmod ? [` <lastmod>${lastmod}</lastmod>`] : []),
54+
...(publishedAt ? [` <lastmod>${publishedAt}</lastmod>`] : []),
5055
" <changefreq>monthly</changefreq>",
5156
" <priority>0.6</priority>",
5257
" </url>",
@@ -67,18 +72,17 @@ const xml = [
6772
'<?xml version="1.0" encoding="UTF-8"?>',
6873
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
6974
...STATIC_PAGES.map(staticEntry),
70-
// The blog index page itself.
71-
" <url>",
72-
` <loc>${SITE_URL}/blogs</loc>`,
73-
" <changefreq>weekly</changefreq>",
74-
" <priority>0.6</priority>",
75-
" </url>",
7675
...posts.map(urlEntry),
7776
"</urlset>",
7877
"",
7978
].join("\n");
8079

81-
writeFileSync(outFile, xml, "utf8");
80+
writeFileSync(outXml, xml, "utf8");
81+
82+
mkdirSync(dirname(outJson), { recursive: true });
83+
writeFileSync(outJson, `${JSON.stringify({ staticPages: STATIC_PAGES, posts }, null, 2)}\n`, "utf8");
84+
8285
console.log(
8386
`[gen-blog-sitemap] Wrote ${STATIC_PAGES.length} static page(s) and ${posts.length} post(s) to public/blog-sitemap.xml`,
8487
);
88+
console.log(`[gen-blog-sitemap] Wrote ${posts.length} post(s) to backend/app/data/manual_blog_posts.json`);

frontend/src/content/blogPosts.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import type { BlogPost, DistributionAsset } from "./seoTypes";
44
// /blogs (index) and /blogs/<slug> (post) automatically — no CMS, no markdown parsing,
55
// and NO backend change required.
66
//
7-
// On `npm run build`, scripts/gen-blog-sitemap.mjs reads this file and regenerates
8-
// public/blog-sitemap.xml, so new posts are crawlable automatically.
7+
// On `npm run build` (or `npm run blog-sitemap`), scripts/gen-blog-sitemap.mjs
8+
// regenerates public/blog-sitemap.xml and backend/app/data/manual_blog_posts.json
9+
// so the API sitemap at /sitemap.xml lists every post automatically.
910
//
1011
// The posts below target keyword gaps surfaced with the DataForSEO API — low-
1112
// difficulty, high-intent search terms that BlogHub's readers (writers growing a

0 commit comments

Comments
 (0)