From 426f7356392778a7577632c93fdf61bd13f9a964 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Thu, 12 Mar 2026 21:47:28 -0700 Subject: [PATCH 1/9] Add breadcrumbs to blog posts --- src/ssg/mod.rs | 1 + templates/base.html | 33 ++++++++++++++++++++++++++++++--- templates/page.html | 6 ++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/ssg/mod.rs b/src/ssg/mod.rs index 197bc5b..e94e0c4 100644 --- a/src/ssg/mod.rs +++ b/src/ssg/mod.rs @@ -350,6 +350,7 @@ pub async fn build_site( "url".into(), minijinja::Value::from(format!("/blog/{}", post.1)), ); + post_ctx.insert("is_blog_post".into(), minijinja::Value::from(true)); let post_template = env.get_template("page.html")?; let post_html = post_template.render(&post_ctx)?; diff --git a/templates/base.html b/templates/base.html index 1e8df44..19f068e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,10 +6,10 @@ {% if title and title != site_name %}{{ title }} - {% endif %}{{ site_name }} {% if site_description %}{% endif %} - {% if favicon_url %}{% endif %} - {% if logo_url %}{% endif %} + {% if favicon_url %}{% endif %} + {% if logo_url %}{% endif %} - + @@ -136,6 +136,33 @@ font-size: 0.875rem; margin-top: 0.5rem; } + .breadcrumbs { + font-size: 0.8rem; + color: #8b949e; + margin-bottom: 1rem; + display: inline-flex; + align-items: center; + flex-wrap: wrap; + } + .breadcrumbs a { + color: #58a6ff; + text-decoration: none; + } + .breadcrumbs a:hover { + text-decoration: underline; + } + .breadcrumbs .separator { + margin: 0 0.4rem; + color: #484f58; + } + .breadcrumbs .current { + color: #c9d1d9; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } diff --git a/templates/page.html b/templates/page.html index 9d565c1..a7a35de 100644 --- a/templates/page.html +++ b/templates/page.html @@ -2,6 +2,12 @@ {% block content %}
+ {% if is_blog_post %} + + {% endif %} +

{{ title }}

From a4760f05d854e22dddbf13cd8daf8a2d0d607584 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Sat, 14 Mar 2026 16:36:10 -0700 Subject: [PATCH 2/9] Fix page reordering and add blog_sort_order support - Add blog_sort_order column to sites table for blog position - Fix page reordering logic to use sequential sort_order values - Fix SSG to respect blog_sort_order when building nav links - Add toast feedback for deploy with clickable link - Add homepage_type, blog_path, landing_blocks columns to sites table - Fix COALESCE for NULL values in SQL queries --- admin.html | 376 ++++++++++++++++++++++++++++++++++++++--------- src/api/mod.rs | 31 ++++ src/api/sites.rs | 45 +++--- src/main.rs | 21 +++ src/models.rs | 1 + src/ssg/mod.rs | 164 +++++++++++++++++++-- 6 files changed, 535 insertions(+), 103 deletions(-) diff --git a/admin.html b/admin.html index d94fe84..e55fea4 100644 --- a/admin.html +++ b/admin.html @@ -47,6 +47,64 @@ setTimeout(() => toast.remove(), 3000); } + // WYSIWYG Editor Functions + function getWysiwygToolbar(blockId) { + return ` +
+ + + + + + + + + + + + + + +
+ `; + } + + function formatText(command, blockId, value = null) { + const editor = document.getElementById(`editor-${blockId}`); + if (!editor) return; + + editor.focus(); + + if (command === 'formatBlock' && value) { + document.execCommand(command, false, value); + } else if (command === 'createLink') { + const url = prompt('Enter URL:'); + if (url) { + document.execCommand(command, false, url); + } + } else { + document.execCommand(command, false, value); + } + + // Update the block content + updateBlockFromEditor(blockId); + } + + function promptLink(blockId) { + const url = prompt('Enter URL:'); + if (url) { + formatText('createLink', blockId, url); + } + } + + function updateBlockFromEditor(blockId) { + const editor = document.getElementById(`editor-${blockId}`); + if (editor) { + const content = editor.innerHTML; + updateBlock(blockId, content); + } + } + // Escape HTML to prevent XSS function escapeHtml(str) { if (!str) return ''; @@ -359,6 +417,29 @@

${escapeHtml(site.name)}

} catch (err) { showToast('Error: ' + err.message, 'error'); } } + async function deployPages() { + if (!currentSite) return; + try { + showToast('Deploying to Cloudflare Pages...'); + const res = await fetch(API_BASE + '/api/sites/' + currentSite + '/deploy', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + authToken } + }); + const data = await res.json(); + if (res.ok) { + const match = data.message.match(/https:\/\/[^\s]+/); + const url = match ? match[0] : null; + if (url) { + showToast(`Deployed! ${url}`); + } else { + showToast(data.message || 'Deployed successfully!'); + } + } else { + showToast(data.message || 'Deploy failed', 'error'); + } + } catch (err) { showToast('Error: ' + err.message, 'error'); } + } + async function createSite(e) { e.preventDefault(); const name = document.getElementById('site-name').value; @@ -436,9 +517,12 @@

${escapeHtml(site.name)}

`; } else if (block.type === 'heading') { @@ -924,7 +1012,7 @@

${escapeHtml(post.title)}

// Parse blocks from content if (post.content && post.content.length > 0) { blocks = post.content.map((b, i) => { - const blockType = b.block_type || 'paragraph'; + const blockType = b.type || b.block_type || 'paragraph'; let content = ''; if (blockType === 'hero') { content = b.content || { title: '', subtitle: '', backgroundImage: '', ctaText: '', ctaLink: '' }; @@ -932,6 +1020,8 @@

${escapeHtml(post.title)}

content = b.content || { url: '', caption: '' }; } else if (blockType === 'columns') { content = b.content || { left: '', right: '', leftImage: '', rightImage: '' }; + } else if (typeof b.content === 'string') { + content = b.content; } else { content = b.content?.text || b.content?.url || b.content?.href || ''; } @@ -956,20 +1046,23 @@

${escapeHtml(post.title)}

function buildContentFromBlocks() { return blocks.map(b => { if (b.type === 'paragraph') { - return { block_type: 'paragraph', content: { text: b.content } }; + const content = typeof b.content === 'string' ? b.content : (b.content?.text || ''); + return { type: 'paragraph', content }; } else if (b.type === 'heading') { - return { block_type: 'heading', content: { text: b.content, level: 2 } }; + const content = typeof b.content === 'string' ? b.content : (b.content?.text || ''); + return { type: 'heading', content: { text: content, level: 2 } }; } else if (b.type === 'image') { - return { block_type: 'image', content: { url: b.content, alt: '' } }; + return { type: 'image', content: b.content }; } else if (b.type === 'link') { - return { block_type: 'link', content: { href: b.content, text: b.content } }; + return { type: 'link', content: b.content }; } else if (b.type === 'hero') { - return { block_type: 'hero', content: b.content || { title: '', subtitle: '', backgroundImage: '', ctaText: '', ctaLink: '' } }; + return { type: 'hero', content: b.content || { title: '', subtitle: '', backgroundImage: '', ctaText: '', ctaLink: '' } }; } else if (b.type === 'video') { - return { block_type: 'video', content: b.content || { url: '', caption: '' } }; + return { type: 'video', content: b.content || { url: '', caption: '' } }; } else if (b.type === 'columns') { - return { block_type: 'columns', content: b.content || { left: '', right: '', leftImage: '', rightImage: '' } }; + return { type: 'columns', content: b.content || { left: '', right: '', leftImage: '', rightImage: '' } }; } + return { type: 'paragraph', content: b.content || '' }; }); } @@ -1025,6 +1118,8 @@

${escapeHtml(post.title)}

try { let res; + let postId = editingPostId; + if (editingPostId) { res = await fetch(API_BASE + '/api/sites/' + currentSite + '/posts/' + editingPostId, { method: 'PUT', @@ -1036,7 +1131,7 @@

${escapeHtml(post.title)}

}); if (res.ok) { const post = await res.json(); - editingPostId = post.id; + postId = post.id; } } else { res = await fetch(API_BASE + '/api/sites/' + currentSite + '/posts', { @@ -1049,13 +1144,23 @@

${escapeHtml(post.title)}

}); if (res.ok) { const post = await res.json(); - editingPostId = post.id; + postId = post.id; + editingPostId = postId; } } - if (!editingPostId) { showToast('Failed to save', 'error'); return; } + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown error' })); + showToast('Failed to save: ' + (err.error || res.status), 'error'); + return; + } + + if (!postId) { + showToast('Failed to save: No post ID returned', 'error'); + return; + } - const publishRes = await fetch(API_BASE + '/api/sites/' + currentSite + '/posts/' + editingPostId + '/publish', { + const publishRes = await fetch(API_BASE + '/api/sites/' + currentSite + '/posts/' + postId + '/publish', { method: 'POST', headers: { 'Authorization': 'Bearer ' + authToken } }); @@ -1230,11 +1335,19 @@

New Page

document.querySelectorAll('#page-blocks-container .editor-block').forEach(b => b.classList.remove('drag-over')); } + function updatePageBlockFromEditor(blockId) { + const editor = document.getElementById(`page-editor-${blockId}`); + if (editor) { + const content = editor.innerHTML; + updatePageBlock(blockId, content); + } + } + function renderPageBlocks() { const container = document.getElementById('page-blocks-container'); container.innerHTML = pageBlocks.map(block => { if (block.type === 'paragraph') { - return `
⋮⋮
`; + return `
⋮⋮
${getWysiwygToolbar(block.id)}
${block.content || ''}
`; } else if (block.type === 'heading') { return `
⋮⋮
`; } else if (block.type === 'image') { @@ -1257,94 +1370,200 @@

New Page

async function loadPages() { if (!currentSite) return; try { - const res = await fetch(API_BASE + '/api/sites/' + currentSite + '/pages', { - headers: { 'Authorization': 'Bearer ' + authToken } - }); - if (res.ok) { - const pages = await res.json(); + const [pagesRes, siteRes] = await Promise.all([ + fetch(API_BASE + '/api/sites/' + currentSite + '/pages', { + headers: { 'Authorization': 'Bearer ' + authToken } + }), + fetch(API_BASE + '/api/sites/' + currentSite, { + headers: { 'Authorization': 'Bearer ' + authToken } + }) + ]); + + if (pagesRes.ok) { + const pages = await pagesRes.json(); + const site = siteRes.ok ? await siteRes.json() : {}; + console.log('loadPages - site:', site); + const homepageType = site.homepage_type || 'both'; + const blogSortOrder = site.blog_sort_order || 1; + const blogPath = site.blog_path || '/blog'; + const list = document.getElementById('pages-list'); - if (pages.length === 0) { + + // Build items array with blog as a special item + let items = pages.map(page => ({ + id: page.id, + title: page.title, + slug: page.slug, + is_homepage: page.is_homepage, + show_in_nav: page.show_in_nav, + sort_order: page.sort_order || 0, + isBlog: false + })); + + // Add blog first if enabled + if (homepageType === 'blog' || homepageType === 'both') { + items.push({ + id: 'blog', + title: 'Blog', + slug: blogPath, + is_homepage: false, + show_in_nav: true, + sort_order: blogSortOrder, + isBlog: true + }); + } + + // Sort by the stored sort_order + items.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); + + // Display using index + 1 for position numbers (not overwriting stored values) + const displayItems = items.map((item, idx) => ({...item, displayOrder: idx + 1})); + + if (displayItems.length === 0) { list.innerHTML = '

No pages yet.

'; } else { - // Sort by sort_order - pages.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); - - list.innerHTML = pages.map((page, index) => ` -
+ list.innerHTML = displayItems.map((page, index) => ` +
${index + 1}
-

${escapeHtml(page.title)}

-

/${escapeHtml(page.slug)}${page.is_homepage ? ' (Homepage)' : ''}

+

${page.isBlog ? '📰 ' : ''}${escapeHtml(page.title)}

+

${escapeHtml(page.slug)}${page.is_homepage ? ' (Homepage)' : ''}

- - - + ${!page.isBlog ? ` + ` : ''}
`).join(''); - - // Attach event handlers after rendering - pages.forEach((page, index) => { - const btn = document.getElementById('navbtn-' + page.id); - if (btn) { - btn.onclick = function() { - var newVal = page.show_in_nav !== false ? false : true; - var url = API_BASE + '/api/sites/' + currentSite + '/pages/' + page.id; - fetch(url, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken }, - body: JSON.stringify({ show_in_nav: newVal }) - }).then(function(res) { - if (res.ok) loadPages(); - }); - }; - } - }); } } } catch (err) { console.error(err); } } async function doMove(pageId, direction) { + console.log('doMove called:', pageId, direction); if (!currentSite) { alert('No site selected'); return; } + + const isBlog = pageId === 'blog'; + console.log('isBlog:', isBlog, 'pageId:', pageId); + + if (isBlog) { + try { + // Get site and pages + const [siteRes, pagesRes] = await Promise.all([ + fetch(API_BASE + '/api/sites/' + currentSite, { + headers: { 'Authorization': 'Bearer ' + authToken } + }), + fetch(API_BASE + '/api/sites/' + currentSite + '/pages', { + headers: { 'Authorization': 'Bearer ' + authToken } + }) + ]); + + const site = await siteRes.json(); + const pages = await pagesRes.json(); + const blogSortOrder = site.blog_sort_order || 1; + + // Build combined list + let items = pages.map(p => ({ + id: p.id, + sort_order: p.sort_order || 0, + isBlog: false + })); + + const homepageType = site.homepage_type || 'both'; + console.log('homepageType:', homepageType, 'blogSortOrder:', blogSortOrder); + if (homepageType === 'blog' || homepageType === 'both') { + items.push({ + id: 'blog', + sort_order: blogSortOrder, + isBlog: true + }); + } + + items.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); + console.log('Items after sort:', JSON.stringify(items.map(i => ({id: i.id, sort_order: i.sort_order, isBlog: i.isBlog})))); + + // Find current blog position + const currentIndex = items.findIndex(i => i.isBlog); + console.log('currentIndex:', currentIndex, 'direction:', direction); + const newIndex = currentIndex + direction; + console.log('newIndex:', newIndex, 'items.length:', items.length); + + if (newIndex < 0 || newIndex >= items.length) { + console.log('Bailing out - bounds check'); + return; + } + + // Move blog to new position + const blogIndex = items.findIndex(i => i.isBlog); + const [blogItem] = items.splice(blogIndex, 1); // Remove blog from current position + items.splice(newIndex, 0, blogItem); // Insert at new position + console.log('Items after splice:', JSON.stringify(items.map(i => ({id: i.id, sort_order: i.sort_order, isBlog: i.isBlog})))); + + // Assign new sequential sort_order values + for (let i = 0; i < items.length; i++) { + console.log(`Updating index ${i}:`, items[i].id, 'isBlog:', items[i].isBlog); + if (items[i].isBlog) { + await fetch(API_BASE + '/api/sites/' + currentSite, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken }, + body: JSON.stringify({ blog_sort_order: i + 1 }) + }).then(r => console.log('Blog update res:', r.status)).catch(e => console.log('Blog update err:', e)); + } else { + await fetch(API_BASE + '/api/sites/' + currentSite + '/pages/' + items[i].id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken }, + body: JSON.stringify({ sort_order: i + 1 }) + }).then(r => console.log('Page', items[i].id, 'update res:', r.status)).catch(e => console.log('Page update err:', e)); + } + } + + loadPages(); + } catch (err) { + console.error(err); + showToast('Error moving blog: ' + err.message, 'error'); + } + return; + } + + // Handle regular page reordering try { - const res = await fetch(API_BASE + '/api/sites/' + currentSite + '/pages', { headers: { 'Authorization': 'Bearer ' + authToken } }); + if (!res.ok) { alert('Failed to load pages'); return; } const pages = await res.json(); + + // Sort by stored sort_order pages.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); + // Find current page const currentIndex = pages.findIndex(p => p.id === pageId); - if (currentIndex === -1) return; + if (currentIndex === -1) { alert('Page not found'); return; } const newIndex = currentIndex + direction; - if (newIndex < 0 || newIndex >= pages.length) return; + if (newIndex < 0 || newIndex >= pages.length) { return; } const page1 = pages[currentIndex]; const page2 = pages[newIndex]; - const order1 = page1.sort_order || 0; - const order2 = page2.sort_order || 0; + // Swap sort_order values (use array position + 1) await fetch(API_BASE + '/api/sites/' + currentSite + '/pages/' + page1.id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken }, - body: JSON.stringify({ sort_order: order2 }) + body: JSON.stringify({ sort_order: newIndex + 1 }) }); await fetch(API_BASE + '/api/sites/' + currentSite + '/pages/' + page2.id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken }, - body: JSON.stringify({ sort_order: order1 }) + body: JSON.stringify({ sort_order: currentIndex + 1 }) }); loadPages(); - } catch (err) { console.error(err); alert('Error: ' + err.message); } + } catch (err) { console.error(err); } } async function editPage(id) { @@ -1364,7 +1583,7 @@

${escapeHtml(page.title)}

if (page.content && page.content.length > 0) { pageBlocks = page.content.map((b, i) => { - const blockType = b.block_type || 'paragraph'; + const blockType = b.type || b.block_type || 'paragraph'; let content = ''; if (blockType === 'hero') { content = b.content || { title: '', subtitle: '', backgroundImage: '', ctaText: '', ctaLink: '' }; @@ -1372,6 +1591,8 @@

${escapeHtml(page.title)}

content = b.content || { url: '', caption: '' }; } else if (blockType === 'columns') { content = b.content || { left: '', right: '', leftImage: '', rightImage: '' }; + } else if (typeof b.content === 'string') { + content = b.content; } else { content = b.content?.text || b.content?.url || b.content?.href || ''; } @@ -1395,13 +1616,24 @@

${escapeHtml(page.title)}

function buildPageContentFromBlocks() { return pageBlocks.map(b => { - if (b.type === 'paragraph') return { block_type: 'paragraph', content: { text: b.content } }; - else if (b.type === 'heading') return { block_type: 'heading', content: { text: b.content, level: 2 } }; - else if (b.type === 'image') return { block_type: 'image', content: { url: b.content, alt: '' } }; - else if (b.type === 'link') return { block_type: 'link', content: { href: b.content, text: b.content } }; - else if (b.type === 'hero') return { block_type: 'hero', content: b.content || { title: '', subtitle: '', backgroundImage: '', ctaText: '', ctaLink: '' } }; - else if (b.type === 'video') return { block_type: 'video', content: b.content || { url: '', caption: '' } }; - else if (b.type === 'columns') return { block_type: 'columns', content: b.content || { left: '', right: '', leftImage: '', rightImage: '' } }; + if (b.type === 'paragraph') { + const content = typeof b.content === 'string' ? b.content : (b.content?.text || ''); + return { type: 'paragraph', content }; + } else if (b.type === 'heading') { + const content = typeof b.content === 'string' ? b.content : (b.content?.text || ''); + return { type: 'heading', content: { text: content, level: 2 } }; + } else if (b.type === 'image') { + return { type: 'image', content: b.content }; + } else if (b.type === 'link') { + return { type: 'link', content: b.content }; + } else if (b.type === 'hero') { + return { type: 'hero', content: b.content || { title: '', subtitle: '', backgroundImage: '', ctaText: '', ctaLink: '' } }; + } else if (b.type === 'video') { + return { type: 'video', content: b.content || { url: '', caption: '' } }; + } else if (b.type === 'columns') { + return { type: 'columns', content: b.content || { left: '', right: '', leftImage: '', rightImage: '' } }; + } + return { type: 'paragraph', content: b.content || '' }; }); } @@ -1682,6 +1914,12 @@

Homepage

+
+ +
+
diff --git a/src/api/mod.rs b/src/api/mod.rs index 5c962a9..7c76482 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -56,6 +56,7 @@ pub fn routes() -> Router { get(sites::list_contact_submissions), ) .route("/api/sites/{site_id}/build", post(build_site)) + .route("/api/sites/{site_id}/deploy", post(deploy_pages)) } async fn build_site( @@ -83,3 +84,33 @@ async fn build_site( } } } + +async fn deploy_pages( + Path(site_id): Path, + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let current_user = auth::require_auth(State(state.clone()), headers) + .await + .map_err(|e| ApiError::new(e.message))?; + + auth::require_site_member(&state, site_id, current_user.user_id) + .await + .map_err(|e| ApiError::new(e.message))?; + + // First build the site + let db = state.db.clone(); + if let Err(e) = crate::ssg::build_site(&db, site_id).await { + tracing::error!("Failed to build site: {}", e); + return Err(ApiError::new(format!("Failed to build site: {}", e))); + } + + // Then deploy to Cloudflare + match crate::ssg::deploy_to_cloudflare().await { + Ok(message) => Ok(Json(serde_json::json!({ "message": message }))), + Err(e) => { + tracing::error!("Failed to deploy to Cloudflare: {}", e); + Err(ApiError::new(format!("Failed to deploy: {}", e))) + } + } +} diff --git a/src/api/sites.rs b/src/api/sites.rs index 9164a87..aa164b8 100644 --- a/src/api/sites.rs +++ b/src/api/sites.rs @@ -18,7 +18,7 @@ pub async fn list( let current_user = require_auth(State(state.clone()), headers).await?; let rows = sqlx::query( - "SELECT id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, blog_path, landing_blocks, settings, created_at FROM sites WHERE id IN (SELECT site_id FROM site_members WHERE user_id = $1) ORDER BY created_at DESC" + "SELECT id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, blog_path, COALESCE(blog_sort_order, 1) as blog_sort_order, landing_blocks, settings, created_at FROM sites WHERE id IN (SELECT site_id FROM site_members WHERE user_id = $1) ORDER BY created_at DESC" ) .bind(current_user.user_id) .fetch_all(&state.db) @@ -44,6 +44,7 @@ pub async fn list( contact_address: row.get("contact_address"), homepage_type: row.get("homepage_type"), blog_path: row.get("blog_path"), + blog_sort_order: row.get("blog_sort_order"), landing_blocks: row.get("landing_blocks"), settings: row.get("settings"), created_at: row.get("created_at"), @@ -62,7 +63,7 @@ pub async fn get( require_site_member(&state, id, current_user.user_id).await?; let row = sqlx::query( - "SELECT id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, blog_path, landing_blocks, settings, created_at FROM sites WHERE id = $1" + "SELECT id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, blog_path, COALESCE(blog_sort_order, 1) as blog_sort_order, landing_blocks, settings, created_at FROM sites WHERE id = $1" ) .bind(id) .fetch_one(&state.db) @@ -84,12 +85,13 @@ pub async fn get( contact_phone: row.get("contact_phone"), contact_email: row.get("contact_email"), contact_address: row.get("contact_address"), - homepage_type: row.get("homepage_type"), - blog_path: row.get("blog_path"), - landing_blocks: row.get("landing_blocks"), - settings: row.get("settings"), - created_at: row.get("created_at"), - }; + homepage_type: row.get("homepage_type"), + blog_path: row.get("blog_path"), + blog_sort_order: row.get("blog_sort_order"), + landing_blocks: row.get("landing_blocks"), + settings: row.get("settings"), + created_at: row.get("created_at"), + }; Ok(Json(site)) } @@ -109,7 +111,7 @@ pub async fn create( let custom_domain = payload.custom_domain.filter(|s| !s.is_empty()); let row = sqlx::query( - "INSERT INTO sites (subdomain, custom_domain, name, description, logo_url, favicon_url, homepage_type, nav_links, blog_path) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, landing_blocks, settings, created_at, blog_path" + "INSERT INTO sites (subdomain, custom_domain, name, description, logo_url, favicon_url, homepage_type, nav_links, blog_path, blog_sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 2) RETURNING id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, blog_path, COALESCE(blog_sort_order, 2) as blog_sort_order, landing_blocks, settings, created_at" ) .bind(subdomain) .bind(custom_domain) @@ -142,9 +144,9 @@ pub async fn create( {"block_type": "paragraph", "content": {"text": "Get in touch with us!"}} ]); - // Insert homepage page + // Insert homepage page with sort_order sqlx::query( - "INSERT INTO pages (site_id, title, slug, content, is_homepage) VALUES ($1, $2, $3, $4, $5)" + "INSERT INTO pages (site_id, title, slug, content, is_homepage, sort_order) VALUES ($1, $2, $3, $4, $5, 1)" ) .bind(site_id) .bind("Home") @@ -155,9 +157,9 @@ pub async fn create( .await .map_err(|e| ApiError::new(format!("Failed to create homepage: {}", e)))?; - // Insert About page + // Insert About page with sort_order sqlx::query( - "INSERT INTO pages (site_id, title, slug, content, is_homepage) VALUES ($1, $2, $3, $4, $5)" + "INSERT INTO pages (site_id, title, slug, content, is_homepage, sort_order) VALUES ($1, $2, $3, $4, $5, 2)" ) .bind(site_id) .bind("About") @@ -168,9 +170,9 @@ pub async fn create( .await .map_err(|e| ApiError::new(format!("Failed to create about page: {}", e)))?; - // Insert Contact page + // Insert Contact page with sort_order sqlx::query( - "INSERT INTO pages (site_id, title, slug, content, is_homepage) VALUES ($1, $2, $3, $4, $5)" + "INSERT INTO pages (site_id, title, slug, content, is_homepage, sort_order) VALUES ($1, $2, $3, $4, $5, 3)" ) .bind(site_id) .bind("Contact") @@ -206,6 +208,7 @@ pub async fn create( contact_address: row.get("contact_address"), homepage_type: row.get("homepage_type"), blog_path: row.get("blog_path"), + blog_sort_order: row.get("blog_sort_order"), landing_blocks: row.get("landing_blocks"), settings: row.get("settings"), created_at: row.get("created_at"), @@ -243,6 +246,7 @@ pub async fn update( let contact_address = payload.get("contact_address").and_then(|v| v.as_str()); let homepage_type = payload.get("homepage_type").and_then(|v| v.as_str()); let blog_path = payload.get("blog_path").and_then(|v| v.as_str()); + let blog_sort_order = payload.get("blog_sort_order").and_then(|v| v.as_i64()); let landing_blocks = payload.get("landing_blocks"); let settings = payload.get("settings"); let favicon_url = payload.get("favicon_url").and_then(|v| v.as_str()); @@ -254,7 +258,7 @@ pub async fn update( subdomain = COALESCE($4, subdomain), custom_domain = COALESCE($5, custom_domain), logo_url = COALESCE($6, logo_url), - favicon_url = COALESCE($18, favicon_url), + favicon_url = COALESCE($19, favicon_url), theme = COALESCE($7, theme), nav_links = COALESCE($8, nav_links), footer_text = COALESCE($9, footer_text), @@ -264,10 +268,11 @@ pub async fn update( contact_address = COALESCE($13, contact_address), homepage_type = COALESCE($14, homepage_type), blog_path = $15, - landing_blocks = COALESCE($16, landing_blocks), - settings = COALESCE($17, settings) + blog_sort_order = COALESCE($16, blog_sort_order), + landing_blocks = COALESCE($17, landing_blocks), + settings = COALESCE($18, settings) WHERE id = $1 - RETURNING id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, blog_path, landing_blocks, settings, created_at" + RETURNING id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, blog_path, COALESCE(blog_sort_order, 1) as blog_sort_order, landing_blocks, settings, created_at" ) .bind(id) .bind(name) @@ -284,6 +289,7 @@ pub async fn update( .bind(contact_address) .bind(homepage_type) .bind(blog_path) + .bind(blog_sort_order) .bind(landing_blocks) .bind(settings) .bind(favicon_url) @@ -308,6 +314,7 @@ pub async fn update( contact_address: row.get("contact_address"), homepage_type: row.get("homepage_type"), blog_path: row.get("blog_path"), + blog_sort_order: row.get("blog_sort_order"), landing_blocks: row.get("landing_blocks"), settings: row.get("settings"), created_at: row.get("created_at"), diff --git a/src/main.rs b/src/main.rs index 9ee396a..22b2c62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -225,6 +225,7 @@ async fn run_migrations(db: &sqlx::PgPool) { homepage_type VARCHAR(20) DEFAULT 'both', landing_blocks JSONB DEFAULT '[]', blog_path VARCHAR(100) DEFAULT '/blog', + blog_sort_order INTEGER DEFAULT 1, created_at TIMESTAMPTZ DEFAULT NOW(), settings JSONB DEFAULT '{}' )", @@ -237,6 +238,26 @@ async fn run_migrations(db: &sqlx::PgPool) { .execute(db) .await .ok(); + sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS blog_sort_order INTEGER DEFAULT 1") + .execute(db) + .await + .ok(); + sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS homepage_type VARCHAR(20) DEFAULT 'both'") + .execute(db) + .await + .ok(); + sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS blog_path VARCHAR(100) DEFAULT '/blog'") + .execute(db) + .await + .ok(); + sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS landing_blocks JSONB DEFAULT '[]'") + .execute(db) + .await + .ok(); + sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS settings JSONB DEFAULT '{}'") + .execute(db) + .await + .ok(); sqlx::query( "CREATE TABLE IF NOT EXISTS users ( diff --git a/src/models.rs b/src/models.rs index 4fca850..6b1553a 100644 --- a/src/models.rs +++ b/src/models.rs @@ -20,6 +20,7 @@ pub struct Site { pub contact_address: Option, pub homepage_type: String, pub blog_path: Option, + pub blog_sort_order: i32, pub landing_blocks: serde_json::Value, pub settings: serde_json::Value, pub created_at: DateTime, diff --git a/src/ssg/mod.rs b/src/ssg/mod.rs index e94e0c4..5ab202a 100644 --- a/src/ssg/mod.rs +++ b/src/ssg/mod.rs @@ -92,7 +92,7 @@ pub async fn build_site( site_id: Uuid, ) -> Result<(), Box> { let site_row = sqlx::query( - "SELECT id, name, description, logo_url, favicon_url, footer_text, social_links, contact_phone, contact_email, contact_address, custom_domain FROM sites WHERE id = $1" + "SELECT id, name, description, logo_url, favicon_url, footer_text, social_links, contact_phone, contact_email, contact_address, custom_domain, COALESCE(homepage_type, 'both') as homepage_type, COALESCE(blog_path, '/blog') as blog_path, COALESCE(blog_sort_order, 1) as blog_sort_order FROM sites WHERE id = $1" ) .bind(site_id) .fetch_one(db) @@ -111,6 +111,11 @@ pub async fn build_site( let contact_email: Option = site_row.get("contact_email"); let contact_address: Option = site_row.get("contact_address"); let domain: Option = site_row.get("custom_domain"); + let homepage_type: Option = site_row.get("homepage_type"); + let blog_path: Option = site_row.get("blog_path"); + let blog_sort_order: i32 = site_row.get("blog_sort_order"); + let homepage_type = homepage_type.unwrap_or_else(|| "both".to_string()); + let blog_path = blog_path.unwrap_or_else(|| "/blog".to_string()); // Build site URL - use custom_domain as the primary domain let site_url = if let Some(d) = domain { @@ -140,8 +145,9 @@ pub async fn build_site( .await?; // Build nav_links from pages with show_in_nav = true (excluding homepage which is always at /) + // Pages are already sorted by sort_order from the SQL query let nav_pages: Vec<_> = pages.iter().filter(|p| p.4 && !p.3).collect(); - let nav_links: Vec = nav_pages + let mut nav_links: Vec = nav_pages .iter() .map(|p| { serde_json::json!({ @@ -150,6 +156,28 @@ pub async fn build_site( }) }) .collect(); + + // Add blog link at the correct position based on blog_sort_order if homepage_type is "blog" or "both" + if homepage_type == "blog" || homepage_type == "both" { + let blog_link = serde_json::json!({ + "label": "Blog", + "url": blog_path + }); + // Find first page with sort_order >= blog_sort_order - insert before it + let insert_pos = nav_links.iter().enumerate() + .find(|(_, link)| { + let url = link.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let page_sort = pages.iter() + .find(|p| format!("/{}", p.1) == url) + .map(|p| p.5) + .unwrap_or(999); + page_sort >= blog_sort_order + }) + .map(|(i, _)| i) + .unwrap_or(nav_links.len()); + + nav_links.insert(insert_pos, blog_link); + } let mut env = Environment::new(); @@ -433,23 +461,51 @@ fn render_blocks(content: &serde_json::Value) -> String { if let Some(blocks) = content.as_array() { blocks.iter() .map(|block| { - let block_type = block.get("block_type").and_then(|b| b.as_str()).unwrap_or("text"); + let block_type = block.get("type").or_else(|| block.get("block_type")).and_then(|b| b.as_str()).unwrap_or("text"); let block_content = block.get("content"); match block_type { "heading" => { let level = block.get("level").and_then(|l| l.as_i64()).unwrap_or(2); - let text = escape_html(block_content.and_then(|c| c.get("text")).and_then(|t| t.as_str()).unwrap_or("")); + let text = block_content + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + let text = escape_html(text); format!("{}", level, text, level) } "paragraph" => { - let text = escape_html(block_content.and_then(|c| c.get("text")).and_then(|t| t.as_str()).unwrap_or("")); - format!("

{}

", text) + let text = if let Some(s) = block_content.and_then(|c| c.as_str()) { + s.to_string() + } else { + block_content + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + .map(|t| escape_html(t)) + .unwrap_or_default() + }; + if text.is_empty() { + String::new() + } else { + format!("

{}

", text) + } } "image" => { - let url = escape_html(block_content.and_then(|c| c.get("url")).and_then(|u| u.as_str()).unwrap_or("")); - let alt = escape_html(block_content.and_then(|c| c.get("alt")).and_then(|a| a.as_str()).unwrap_or("")); - format!("
\"{}\"
{}
", url, alt, alt) + let url = if let Some(s) = block_content.and_then(|c| c.as_str()) { + escape_html(s) + } else { + escape_html(block_content.and_then(|c| c.get("url")).and_then(|u| u.as_str()).unwrap_or("")) + }; + let alt = block_content + .and_then(|c| c.get("alt")) + .and_then(|a| a.as_str()) + .map(escape_html) + .unwrap_or_default(); + if url.is_empty() { + String::new() + } else { + format!("
\"{}\"
{}
", url, alt, alt) + } } "code" => { let code = escape_html(block_content.and_then(|c| c.get("code")).and_then(|c| c.as_str()).unwrap_or("")); @@ -457,8 +513,16 @@ fn render_blocks(content: &serde_json::Value) -> String { format!("
{}
", lang, code) } "quote" => { - let text = escape_html(block_content.and_then(|c| c.get("text")).and_then(|t| t.as_str()).unwrap_or("")); - let citation = escape_html(block_content.and_then(|c| c.get("citation")).and_then(|c| c.as_str()).unwrap_or("")); + let text = block_content + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + .map(escape_html) + .unwrap_or_default(); + let citation = block_content + .and_then(|c| c.get("citation")) + .and_then(|c| c.as_str()) + .map(escape_html) + .unwrap_or_default(); format!("
{}{}
", text, citation) } "hero" => { @@ -501,8 +565,16 @@ fn render_blocks(content: &serde_json::Value) -> String { } else { String::new() } } "columns" => { - let left = escape_html(block_content.and_then(|c| c.get("left")).and_then(|t| t.as_str()).unwrap_or("")); - let right = escape_html(block_content.and_then(|c| c.get("right")).and_then(|t| t.as_str()).unwrap_or("")); + let left = block_content + .and_then(|c| c.get("left")) + .and_then(|t| t.as_str()) + .map(escape_html) + .unwrap_or_default(); + let right = block_content + .and_then(|c| c.get("right")) + .and_then(|t| t.as_str()) + .map(escape_html) + .unwrap_or_default(); let left_img = block_content.and_then(|c| c.get("leftImage")).and_then(|t| t.as_str()).unwrap_or(""); let right_img = block_content.and_then(|c| c.get("rightImage")).and_then(|t| t.as_str()).unwrap_or(""); format!(r#"
@@ -520,8 +592,20 @@ fn render_blocks(content: &serde_json::Value) -> String { ) } _ => { - let text = escape_html(block_content.and_then(|c| c.get("text")).and_then(|t| t.as_str()).unwrap_or("")); - format!("

{}

", text) + let text = if let Some(s) = block_content.and_then(|c| c.as_str()) { + s.to_string() + } else { + block_content + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + .map(escape_html) + .unwrap_or_default() + }; + if text.is_empty() { + String::new() + } else { + format!("

{}

", text) + } } } }) @@ -531,3 +615,53 @@ fn render_blocks(content: &serde_json::Value) -> String { String::new() } } + +pub async fn deploy_to_cloudflare() -> Result> { + let project_name = std::env::var("CLOUDFLARE_PAGES_PROJECT") + .map_err(|_| "CLOUDFLARE_PAGES_PROJECT not set")?; + + let output_dir = std::path::Path::canonicalize(std::path::Path::new("output")) + .map_err(|_| "Output directory does not exist")?; + + if !output_dir.exists() { + return Err("Output directory does not exist. Build the site first.".into()); + } + + tracing::info!( + "Starting Cloudflare deployment using wrangler for project: {}", + project_name + ); + + let output = tokio::process::Command::new("wrangler") + .args([ + "pages", + "deploy", + output_dir.to_str().unwrap_or("output"), + "--project-name", + &project_name, + "--branch", + "main", + "--no-bundle", + ]) + .output() + .await + .map_err(|e| format!("Failed to run wrangler: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + tracing::info!("Wrangler output: {}", stdout); + + if !output.status.success() { + tracing::error!("Wrangler error: {}", stderr); + return Err(format!("Wrangler deployment failed: {}", stderr).into()); + } + + let deployment_url = stdout + .lines() + .find(|l| l.contains("pages.dev")) + .map(|l| l.trim().to_string()) + .unwrap_or_else(|| format!("https://{}.pages.dev", project_name)); + + Ok(format!("Deployed successfully! Visit: {}", deployment_url)) +} From bfd39a1733c728481f7e9be854700d08c6575a4a Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Sat, 14 Mar 2026 18:57:47 -0700 Subject: [PATCH 3/9] Fix nav ordering - include homepage in nav, simplify blog insertion logic --- src/ssg/mod.rs | 51 +++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/ssg/mod.rs b/src/ssg/mod.rs index b394828..813cb34 100644 --- a/src/ssg/mod.rs +++ b/src/ssg/mod.rs @@ -144,18 +144,30 @@ pub async fn build_site( .fetch_all(db) .await?; - // Build nav_links from pages with show_in_nav = true (excluding homepage which is always at /) - // Pages are already sorted by sort_order from the SQL query - let nav_pages: Vec<_> = pages.iter().filter(|p| p.4 && !p.3).collect(); - let mut nav_links: Vec = nav_pages - .iter() - .map(|p| { - serde_json::json!({ - "label": p.0, - "url": format!("/{}", p.1) - }) - }) - .collect(); + // Build nav_links from pages with show_in_nav = true + // Include homepage at position 0, then other pages sorted by sort_order + let mut nav_links: Vec = Vec::new(); + + // Add homepage first if show_in_nav is true + if let Some(homepage) = pages.iter().find(|p| p.3) { + if homepage.4 { + nav_links.push(serde_json::json!({ + "label": homepage.0, + "url": "/" + })); + } + } + + // Add other pages sorted by sort_order + let other_pages: Vec<_> = pages.iter().filter(|p| !p.3).collect(); + for page in other_pages { + if page.4 { + nav_links.push(serde_json::json!({ + "label": page.0, + "url": format!("/{}", page.1) + })); + } + } // Add blog link at the correct position based on blog_sort_order if homepage_type is "blog" or "both" if homepage_type == "blog" || homepage_type == "both" { @@ -163,19 +175,8 @@ pub async fn build_site( "label": "Blog", "url": blog_path }); - // Find first page with sort_order >= blog_sort_order - insert before it - let insert_pos = nav_links.iter().enumerate() - .find(|(_, link)| { - let url = link.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let page_sort = pages.iter() - .find(|p| format!("/{}", p.1) == url) - .map(|p| p.5) - .unwrap_or(999); - page_sort >= blog_sort_order - }) - .map(|(i, _)| i) - .unwrap_or(nav_links.len()); - + // Insert at blog_sort_order position (convert from 1-based to 0-based) + let insert_pos = (blog_sort_order as usize).saturating_sub(1).min(nav_links.len()); nav_links.insert(insert_pos, blog_link); } From bf58012335f0d3a3517ccb5890bed8cb2059a221 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Sat, 14 Mar 2026 19:01:55 -0700 Subject: [PATCH 4/9] Fix Gitzilla issues: add URL validation for logo/favicon, fix post slug update --- src/api/posts.rs | 13 ++++++++----- src/api/sites.rs | 47 ++++++++++++++++++++++++++++++++++++++++------- src/main.rs | 20 ++++++++++++-------- src/ssg/mod.rs | 10 ++++++---- 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/api/posts.rs b/src/api/posts.rs index 607ff96..73d6673 100644 --- a/src/api/posts.rs +++ b/src/api/posts.rs @@ -170,6 +170,7 @@ pub async fn update( } let title = payload.title.clone(); + let slug = payload.slug.clone(); let content = payload.content.clone(); let excerpt = payload.excerpt.clone(); let featured_image = payload.featured_image.clone(); @@ -179,11 +180,12 @@ pub async fn update( let result = sqlx::query_as::<_, PostRow>( "UPDATE posts SET title = COALESCE($3, title), - content = COALESCE($4, content), - excerpt = COALESCE($5, excerpt), - featured_image = COALESCE($6, featured_image), - status = COALESCE($7, status), - seo = COALESCE($8, seo), + slug = COALESCE($4, slug), + content = COALESCE($5, content), + excerpt = COALESCE($6, excerpt), + featured_image = COALESCE($7, featured_image), + status = COALESCE($8, status), + seo = COALESCE($9, seo), updated_at = NOW() WHERE site_id = $1 AND id = $2 RETURNING id, site_id, author_id, title, slug, content, excerpt, featured_image, status, published_at, created_at, updated_at, seo" @@ -191,6 +193,7 @@ pub async fn update( .bind(site_id) .bind(id) .bind(title) + .bind(slug) .bind(content) .bind(excerpt) .bind(featured_image) diff --git a/src/api/sites.rs b/src/api/sites.rs index aa164b8..9d3db18 100644 --- a/src/api/sites.rs +++ b/src/api/sites.rs @@ -9,6 +9,7 @@ use uuid::Uuid; use crate::api::auth::{require_auth, require_site_member}; use crate::models::ContactSubmissionRow; +use crate::util; use crate::{ApiError, AppState, ContactSubmission, CreateSiteRequest, Site}; pub async fn list( @@ -85,13 +86,13 @@ pub async fn get( contact_phone: row.get("contact_phone"), contact_email: row.get("contact_email"), contact_address: row.get("contact_address"), - homepage_type: row.get("homepage_type"), - blog_path: row.get("blog_path"), - blog_sort_order: row.get("blog_sort_order"), - landing_blocks: row.get("landing_blocks"), - settings: row.get("settings"), - created_at: row.get("created_at"), - }; + homepage_type: row.get("homepage_type"), + blog_path: row.get("blog_path"), + blog_sort_order: row.get("blog_sort_order"), + landing_blocks: row.get("landing_blocks"), + settings: row.get("settings"), + created_at: row.get("created_at"), + }; Ok(Json(site)) } @@ -107,6 +108,22 @@ pub async fn create( return Err(ApiError::new("Site name is required")); } + // Validate URLs to prevent XSS + if let Some(ref url) = payload.logo_url { + if !util::is_valid_url(url) { + return Err(ApiError::new( + "Invalid logo URL: javascript: and data: URLs are not allowed", + )); + } + } + if let Some(ref url) = payload.favicon_url { + if !util::is_valid_url(url) { + return Err(ApiError::new( + "Invalid favicon URL: javascript: and data: URLs are not allowed", + )); + } + } + let subdomain = payload.subdomain.filter(|s| !s.is_empty()); let custom_domain = payload.custom_domain.filter(|s| !s.is_empty()); @@ -251,6 +268,22 @@ pub async fn update( let settings = payload.get("settings"); let favicon_url = payload.get("favicon_url").and_then(|v| v.as_str()); + // Validate URLs to prevent XSS + if let Some(url) = logo_url { + if !util::is_valid_url(url) { + return Err(ApiError::new( + "Invalid logo URL: javascript: and data: URLs are not allowed", + )); + } + } + if let Some(url) = favicon_url { + if !util::is_valid_url(url) { + return Err(ApiError::new( + "Invalid favicon URL: javascript: and data: URLs are not allowed", + )); + } + } + let row = sqlx::query( "UPDATE sites SET name = COALESCE($2, name), diff --git a/src/main.rs b/src/main.rs index bdcea2d..723bac1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -244,14 +244,18 @@ async fn run_migrations(db: &sqlx::PgPool) { .execute(db) .await .ok(); - sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS homepage_type VARCHAR(20) DEFAULT 'both'") - .execute(db) - .await - .ok(); - sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS blog_path VARCHAR(100) DEFAULT '/blog'") - .execute(db) - .await - .ok(); + sqlx::query( + "ALTER TABLE sites ADD COLUMN IF NOT EXISTS homepage_type VARCHAR(20) DEFAULT 'both'", + ) + .execute(db) + .await + .ok(); + sqlx::query( + "ALTER TABLE sites ADD COLUMN IF NOT EXISTS blog_path VARCHAR(100) DEFAULT '/blog'", + ) + .execute(db) + .await + .ok(); sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS landing_blocks JSONB DEFAULT '[]'") .execute(db) .await diff --git a/src/ssg/mod.rs b/src/ssg/mod.rs index 813cb34..9f6ff5c 100644 --- a/src/ssg/mod.rs +++ b/src/ssg/mod.rs @@ -147,7 +147,7 @@ pub async fn build_site( // Build nav_links from pages with show_in_nav = true // Include homepage at position 0, then other pages sorted by sort_order let mut nav_links: Vec = Vec::new(); - + // Add homepage first if show_in_nav is true if let Some(homepage) = pages.iter().find(|p| p.3) { if homepage.4 { @@ -157,7 +157,7 @@ pub async fn build_site( })); } } - + // Add other pages sorted by sort_order let other_pages: Vec<_> = pages.iter().filter(|p| !p.3).collect(); for page in other_pages { @@ -168,7 +168,7 @@ pub async fn build_site( })); } } - + // Add blog link at the correct position based on blog_sort_order if homepage_type is "blog" or "both" if homepage_type == "blog" || homepage_type == "both" { let blog_link = serde_json::json!({ @@ -176,7 +176,9 @@ pub async fn build_site( "url": blog_path }); // Insert at blog_sort_order position (convert from 1-based to 0-based) - let insert_pos = (blog_sort_order as usize).saturating_sub(1).min(nav_links.len()); + let insert_pos = (blog_sort_order as usize) + .saturating_sub(1) + .min(nav_links.len()); nav_links.insert(insert_pos, blog_link); } From 4e96705b6ddc2b6fdbb6ff358942fff9c896192f Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Sat, 14 Mar 2026 19:07:46 -0700 Subject: [PATCH 5/9] Fix Gitzilla: remove | safe filter from logo/favicon, fix CSS extra brace --- templates/base.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/templates/base.html b/templates/base.html index 19f068e..ddda04f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,8 +6,8 @@ {% if title and title != site_name %}{{ title }} - {% endif %}{{ site_name }} {% if site_description %}{% endif %} - {% if favicon_url %}{% endif %} - {% if logo_url %}{% endif %} + {% if favicon_url %}{% endif %} + {% if logo_url %}{% endif %} @@ -162,7 +162,6 @@ text-overflow: ellipsis; white-space: nowrap; } - } From 8551ee78c881b29027d56fa40ad21db0cd08af67 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Sat, 14 Mar 2026 19:23:10 -0700 Subject: [PATCH 6/9] Fix Gitzilla: add .html extension to blog post links --- templates/page.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/page.html b/templates/page.html index a7a35de..41c91fe 100644 --- a/templates/page.html +++ b/templates/page.html @@ -4,7 +4,7 @@
{% if is_blog_post %} {% endif %} @@ -15,7 +15,7 @@

{{ title }}

{% for post in posts %}

- {{ post.title }} + {{ post.title }}

{% if post.excerpt %}

{{ post.excerpt }}

{% endif %} {{ post.published_at }} From 3ca180cb9021677b6e672e298577c484137a7c92 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Sat, 14 Mar 2026 19:29:05 -0700 Subject: [PATCH 7/9] Fix Gitzilla: remove duplicate blog_sort_order migration --- src/main.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 723bac1..b938a2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -265,11 +265,6 @@ async fn run_migrations(db: &sqlx::PgPool) { .await .ok(); - sqlx::query("ALTER TABLE sites ADD COLUMN IF NOT EXISTS blog_sort_order INTEGER DEFAULT 1") - .execute(db) - .await - .ok(); - sqlx::query( "CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), From 18a1b2f39d2373ad85526b77eafd114ea786cb86 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Sat, 14 Mar 2026 19:36:58 -0700 Subject: [PATCH 8/9] Fix Gitzilla: add | safe filter to featured images and OG URLs --- templates/base.html | 8 ++++---- templates/index.html | 2 +- templates/page.html | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/templates/base.html b/templates/base.html index ddda04f..ffecb03 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,11 +17,11 @@ {% if site_description %}{% endif %} {% if featured_image %} - + {% elif logo_url %} - + {% endif %} @@ -29,9 +29,9 @@ {% if site_description %}{% endif %} {% if featured_image %} - + {% elif logo_url %} - + {% endif %} diff --git a/templates/index.html b/templates/index.html index c780469..0a57baa 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,7 +9,7 @@

{{ site_name {% for post in posts %}
{% if post.featured_image %} - {{ post.title }} + {{ post.title }} {% endif %}

diff --git a/templates/page.html b/templates/page.html index 41c91fe..d3e6760 100644 --- a/templates/page.html +++ b/templates/page.html @@ -22,7 +22,7 @@

{% endfor %} {% else %} - {% if featured_image %}{{ title }}{% endif %} + {% if featured_image %}{{ title }}{% endif %} {{ content | safe }} {% endif %}

From 83dd6696530b3b87d68e4ac9566759f4f1ec7c30 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Sat, 14 Mar 2026 19:49:00 -0700 Subject: [PATCH 9/9] Fix Gitzilla: fix SQL parameter binding order in site update --- src/api/sites.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/api/sites.rs b/src/api/sites.rs index 9d3db18..1303787 100644 --- a/src/api/sites.rs +++ b/src/api/sites.rs @@ -291,19 +291,19 @@ pub async fn update( subdomain = COALESCE($4, subdomain), custom_domain = COALESCE($5, custom_domain), logo_url = COALESCE($6, logo_url), - favicon_url = COALESCE($19, favicon_url), - theme = COALESCE($7, theme), - nav_links = COALESCE($8, nav_links), - footer_text = COALESCE($9, footer_text), - social_links = COALESCE($10, social_links), - contact_phone = COALESCE($11, contact_phone), - contact_email = COALESCE($12, contact_email), - contact_address = COALESCE($13, contact_address), - homepage_type = COALESCE($14, homepage_type), - blog_path = $15, - blog_sort_order = COALESCE($16, blog_sort_order), - landing_blocks = COALESCE($17, landing_blocks), - settings = COALESCE($18, settings) + favicon_url = COALESCE($7, favicon_url), + theme = COALESCE($8, theme), + nav_links = COALESCE($9, nav_links), + footer_text = COALESCE($10, footer_text), + social_links = COALESCE($11, social_links), + contact_phone = COALESCE($12, contact_phone), + contact_email = COALESCE($13, contact_email), + contact_address = COALESCE($14, contact_address), + homepage_type = COALESCE($15, homepage_type), + blog_path = $16, + blog_sort_order = COALESCE($17, blog_sort_order), + landing_blocks = COALESCE($18, landing_blocks), + settings = COALESCE($19, settings) WHERE id = $1 RETURNING id, subdomain, custom_domain, name, description, logo_url, favicon_url, theme, nav_links, footer_text, social_links, contact_phone, contact_email, contact_address, homepage_type, blog_path, COALESCE(blog_sort_order, 1) as blog_sort_order, landing_blocks, settings, created_at" ) @@ -313,6 +313,7 @@ pub async fn update( .bind(subdomain) .bind(custom_domain) .bind(logo_url) + .bind(favicon_url) .bind(theme) .bind(nav_links) .bind(footer_text) @@ -325,7 +326,6 @@ pub async fn update( .bind(blog_sort_order) .bind(landing_blocks) .bind(settings) - .bind(favicon_url) .fetch_one(&state.db) .await .map_err(|_| ApiError::new("Site not found"))?;