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
9 changes: 9 additions & 0 deletions app/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BLOG_CSS } from '../blog-styles'
import EditorialShell from '@/components/editorial/shell'
import ReadingProgress from '@/components/blog/reading-progress'
import TableOfContents from '@/components/blog/toc'
import TemplateDownload from '@/components/blog/template-download'

export function generateStaticParams() {
return getAllPosts().map((p) => ({ slug: p.slug }))
Expand Down Expand Up @@ -152,6 +153,14 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) {
<article className="kb-article" dir={post.dir} lang={post.lang} dangerouslySetInnerHTML={{ __html: post.html }} />
</div>

{post.templateDownload && (
<TemplateDownload
href={post.templateDownload.href}
label={post.templateDownload.label}
slug={post.slug}
/>
)}

{post.relatedPages.length > 0 && (
<div className="kb-related">
<div className="kb-eyebrow">Related</div>
Expand Down
16 changes: 16 additions & 0 deletions app/app/blog/blog-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ export const BLOG_CSS = `
.kb-article h3{font-family:var(--font-space-grotesk),sans-serif;font-size:16px;font-weight:600;margin:28px 0 10px;color:var(--ink)}
.kb-article h4{font-family:var(--font-space-grotesk),sans-serif;font-size:15px;font-weight:600;margin:24px 0 8px;color:var(--ink)}
.kb-article code{font-family:var(--font-mono),monospace;font-size:.86em;background:var(--bg-muted);border:1px solid var(--border);padding:1px 6px;border-radius:6px;color:var(--ink)}
.kb-article pre{background:var(--bg-muted);border:1px solid var(--border);border-radius:12px;padding:18px 20px;overflow-x:auto;margin:0 0 26px}
.kb-article pre code{background:none;border:0;padding:0;font-size:13px;line-height:1.6;color:var(--ink-2);white-space:pre}
.kb-article table{width:100%;border-collapse:collapse;margin:0 0 26px;font-size:14.5px}
.kb-article th,.kb-article td{border:1px solid var(--border);padding:10px 12px;text-align:left;vertical-align:top}
.kb-article th{background:var(--accent-light);font-family:var(--font-space-grotesk),sans-serif;font-weight:600;color:var(--ink)}
.kb-article td{color:var(--ink-2)}
.kb-article[dir="rtl"] th,.kb-article[dir="rtl"] td{text-align:right}
.kb-article a{color:var(--accent);text-decoration:underline;text-decoration-color:rgba(31,107,58,.3);text-underline-offset:3px}
.kb-article a:hover{text-decoration-color:var(--accent)}
.kb-article ul,.kb-article ol{margin:0 0 22px;padding-left:24px;color:var(--ink-2);font-size:16px;line-height:1.75}
Expand All @@ -44,6 +51,15 @@ export const BLOG_CSS = `
.kb-article[dir="rtl"] ul,.kb-article[dir="rtl"] ol{padding-left:0;padding-right:24px}
.kb-article[dir="rtl"] blockquote{border-left:0;border-right:3px solid var(--accent);padding:4px 20px 4px 0}
.kb-h1[dir="rtl"]{text-align:right}
.kb-template-dl{max-width:720px;margin:0 auto 8px;padding:0 40px}
.kb-template-dl-card{display:flex;align-items:center;gap:16px;background:var(--accent-light);border:1px solid rgba(31,107,58,.25);border-radius:14px;padding:20px 24px}
.kb-template-dl-icon{flex-shrink:0;width:42px;height:42px;border-radius:10px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:19px}
.kb-template-dl-body{flex:1;min-width:0}
.kb-template-dl-label{font-family:var(--font-space-grotesk),sans-serif;font-weight:600;font-size:15px;color:var(--ink);margin-bottom:2px}
.kb-template-dl-sub{font-family:var(--font-mono),monospace;font-size:11.5px;color:var(--ink-3)}
.kb-template-dl-btn{flex-shrink:0;background:var(--accent);color:#fff;text-decoration:none;padding:10px 20px;border-radius:8px;font-weight:600;font-size:14px;white-space:nowrap}
.kb-template-dl-btn:hover{opacity:.9}
@media(max-width:640px){.kb-template-dl{padding:0 20px}.kb-template-dl-card{flex-wrap:wrap}.kb-template-dl-btn{width:100%;text-align:center}}
.kb-related{max-width:720px;margin:0 auto;padding:0 40px 56px}
.kb-related-row{display:flex;gap:10px;flex-wrap:wrap}
.kb-related a{font-family:var(--font-mono),monospace;font-size:12px;color:var(--accent);text-decoration:none;border:1px solid var(--border);background:var(--surface);border-radius:999px;padding:6px 14px}
Expand Down
47 changes: 47 additions & 0 deletions app/components/blog/template-download.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client'

import posthog from 'posthog-js'

/** Tracked download card for a post's free template asset. Fires PostHog + gtag on click. */
export default function TemplateDownload({
href,
label,
slug,
}: {
href: string
label: string
slug: string
}) {
const format = href.split('.').pop()?.toUpperCase() ?? 'FILE'

const track = () => {
try {
posthog.capture('template_download', { slug, href, format })
} catch (_) {}
if (typeof window !== 'undefined' && (window as any).gtag) {
;(window as any).gtag('event', 'template_download', {
slug,
href,
format,
event_category: 'content',
})
}
}

return (
<div className="kb-template-dl">
<div className="kb-template-dl-card">
<div className="kb-template-dl-icon" aria-hidden="true">
&#8595;
</div>
<div className="kb-template-dl-body">
<div className="kb-template-dl-label">{label}</div>
<div className="kb-template-dl-sub">Free · no signup · opens in Word / Google Docs</div>
</div>
<a className="kb-template-dl-btn" href={href} download onClick={track}>
Download {format}
</a>
</div>
</div>
)
}
63 changes: 59 additions & 4 deletions app/lib/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import matter from 'gray-matter'
* Blog content lib — reads markdown posts from content/blog/ at build time.
* Posts are produced by the blogger routine (routines/blogger/) and validated
* by its gates before landing here, so the markdown surface is deliberately
* small: headings, paragraphs, links, inline code, bold/italic. No external
* markdown dependency — the renderer below covers exactly that surface.
* small: headings, paragraphs, links, inline code, bold/italic, GFM tables,
* fenced code blocks. No external markdown dependency — the renderer below
* covers exactly that surface.
*/

const BLOG_DIR = path.join(process.cwd(), 'content', 'blog')
Expand All @@ -28,6 +29,8 @@ export interface BlogPostMeta {
dir: 'ltr' | 'rtl'
/** Absolute-from-root path to the post's social/OG image, or null. */
ogImage: string | null
/** Free downloadable asset (e.g. a copy-paste template as .docx), or null. */
templateDownload: { href: string; label: string } | null
}

/**
Expand Down Expand Up @@ -98,17 +101,29 @@ function renderInline(s: string): string {
})
}

/** True if `line` is a GFM table separator row, e.g. `|---|:--:|---|` or `---|---`. */
function isTableSeparator(line: string): boolean {
const cells = line.trim().replace(/^\||\|$/g, '').split('|')
return cells.length > 0 && cells.every((c) => /^\s*:?-{3,}:?\s*$/.test(c))
}

function splitTableRow(line: string): string[] {
return line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
}

/**
* Block-level markdown → HTML. Covers the post pipeline's surface:
* h1–h4 (leading h1 dropped — the page renders the frontmatter title),
* paragraphs, unordered/ordered lists, blockquotes, hr.
* paragraphs, unordered/ordered lists, blockquotes, hr, GFM tables,
* fenced code blocks, `:::key/note/warn` callouts.
*/
export function renderMarkdown(md: string): string {
const lines = md.split(/\r?\n/)
const out: string[] = []
let para: string[] = []
let list: { tag: 'ul' | 'ol'; items: string[] } | null = null
let callout: { kind: string; lines: string[] } | null = null
let codeFence: string[] | null = null
let firstH1Dropped = false

const flushPara = () => {
Expand All @@ -124,9 +139,26 @@ export function renderMarkdown(md: string): string {
}
}

for (const raw of lines) {
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]
const line = raw.trimEnd()

// Fenced code blocks: ```[lang] ... ``` → <pre><code>, content preserved verbatim (not reflowed).
if (codeFence) {
if (/^```\s*$/.test(line)) {
out.push(`<pre><code>${escapeHtml(codeFence.join('\n'))}</code></pre>`)
codeFence = null
} else {
codeFence.push(raw)
}
continue
}
if (/^```/.test(line)) {
flushPara(); flushList()
codeFence = []
continue
}

// Callout fences: :::key ... ::: → styled <aside>. `kind` ∈ key|note|warn (default note).
const fenceOpen = line.match(/^:::\s*(\w+)?\s*$/)
if (callout) {
Expand All @@ -147,6 +179,25 @@ export function renderMarkdown(md: string): string {
continue
}

// GFM table: a `| a | b |` header row immediately followed by a `|---|---|` separator row.
if (line.trim().startsWith('|') && lines[i + 1] && isTableSeparator(lines[i + 1])) {
flushPara(); flushList()
const headerCells = splitTableRow(line)
const bodyRows: string[][] = []
let j = i + 2
while (j < lines.length && lines[j].trim().startsWith('|')) {
bodyRows.push(splitTableRow(lines[j]))
j++
}
const th = headerCells.map((c) => `<th>${renderInline(escapeHtml(c))}</th>`).join('')
const rows = bodyRows
.map((r) => `<tr>${r.map((c) => `<td>${renderInline(escapeHtml(c))}</td>`).join('')}</tr>`)
.join('')
out.push(`<table><thead><tr>${th}</tr></thead><tbody>${rows}</tbody></table>`)
i = j - 1
continue
}

const heading = line.match(/^(#{1,4})\s+(.*)$/)
const ulItem = line.match(/^\s*[-*]\s+(.*)$/)
const olItem = line.match(/^\s*\d+\.\s+(.*)$/)
Expand Down Expand Up @@ -213,6 +264,10 @@ function toMeta(slug: string, data: Record<string, any>, body: string): BlogPost
lang,
dir,
ogImage: resolveOgImage(slug, data),
templateDownload:
data.template_download_href
? { href: String(data.template_download_href), label: String(data.template_download_label ?? 'Download the template') }
: null,
}
}

Expand Down
109 changes: 109 additions & 0 deletions scripts/gen-template-docx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
gen-template-docx.py — turn a plain-text copy-paste template into a real
downloadable .docx, for the blogger routine's "free template" posts.

Reads a text file (the same content that used to go into a broken
```-fenced code block on the blog page) and structures it into a Word
document: ALL-CAPS lines become section headings, "- " lines become
bullets, everything else is a body paragraph (blank underscores render
fine in any font, no monospace needed).

Usage:
python3 scripts/gen-template-docx.py \
--title "Meeting Minutes Template" \
--subtitle "Free copy-paste template - knowcap.ai/blog/how-to-do-meeting-minutes" \
--text-file /tmp/template.txt \
--out app/public/downloads/meeting-minutes-template.docx

Requires: pip install python-docx
"""
import argparse
import os
import sys

from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH

ACCENT = RGBColor(0x1F, 0x6B, 0x3A)
INK = RGBColor(0x18, 0x18, 0x1B)
INK_2 = RGBColor(0x4A, 0x4F, 0x5A)


def is_section_heading(line: str) -> bool:
letters = [c for c in line if c.isalpha()]
return bool(letters) and line == line.upper() and len(line.strip()) > 1


def build(title: str, subtitle: str, text: str, out_path: str, source_url: str):
doc = Document()

style = doc.styles["Normal"]
style.font.name = "Calibri"
style.font.size = Pt(11)

h = doc.add_paragraph()
run = h.add_run(title)
run.bold = True
run.font.size = Pt(22)
run.font.color.rgb = INK

if subtitle:
sub = doc.add_paragraph()
srun = sub.add_run(subtitle)
srun.italic = True
srun.font.size = Pt(10.5)
srun.font.color.rgb = INK_2

doc.add_paragraph()

for raw_line in text.splitlines():
line = raw_line.rstrip()
if not line.strip():
doc.add_paragraph()
continue
if is_section_heading(line):
p = doc.add_paragraph()
r = p.add_run(line)
r.bold = True
r.font.size = Pt(13)
r.font.color.rgb = ACCENT
p.space_before = Pt(10)
continue
if line.strip().startswith("- "):
doc.add_paragraph(line.strip()[2:], style="List Bullet")
continue
p = doc.add_paragraph()
r = p.add_run(line)
r.font.color.rgb = INK_2

doc.add_paragraph()
foot = doc.add_paragraph()
foot.alignment = WD_ALIGN_PARAGRAPH.CENTER
frun = foot.add_run(f"Free template from Knowcap — {source_url}")
frun.font.size = Pt(9)
frun.font.color.rgb = INK_2

os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
doc.save(out_path)
print(f"OK {out_path}")


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--title", required=True)
ap.add_argument("--subtitle", default="")
ap.add_argument("--text-file", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--source-url", default="knowcap.ai/blog")
args = ap.parse_args()

with open(args.text_file, "r", encoding="utf-8") as f:
text = f.read()

build(args.title, args.subtitle, text, args.out, args.source_url)


if __name__ == "__main__":
main()