Spaces:
Runtime error
Runtime error
Commit ·
f97221f
1
Parent(s): f1ab9e4
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- frontend/public/_redirects +5 -0
- frontend/seo/prerender.ts +269 -0
- frontend/seo/sitemap.ts +4 -0
- frontend/src/App.tsx +3 -0
- frontend/src/components/PublicChrome.tsx +10 -0
- frontend/src/content/blogPosts.ts +1 -0
- frontend/src/content/missionPosts.ts +6 -5
- frontend/src/content/notebooklmAlternative.ts +79 -0
- frontend/src/content/tools.ts +225 -0
- frontend/src/lib/podcastScript.ts +85 -0
- frontend/src/pages/AuthCallback.tsx +1 -1
- frontend/src/pages/AuthPage.tsx +2 -1
- frontend/src/pages/Blog.tsx +1 -1
- frontend/src/pages/NotebookLMAlternative.tsx +5 -78
- frontend/src/pages/Tools.tsx +222 -0
- frontend/src/styles/tools.css +93 -0
- frontend/tsconfig.tsbuildinfo +1 -1
- frontend/vite.config.ts +3 -0
frontend/public/_redirects
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Signed in and auth routes get the empty app shell (noindex), not the prerendered home page.
|
| 2 |
+
/app/* /app 200
|
| 3 |
+
/auth /app 200
|
| 4 |
+
/auth/* /app 200
|
| 5 |
+
/welcome /app 200
|
frontend/seo/prerender.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import type { Plugin, ResolvedConfig } from "vite";
|
| 4 |
+
import { blogPosts } from "../src/content/blogPosts";
|
| 5 |
+
import { NOTEBOOKLM_ALT_FAQ, NOTEBOOKLM_ALT_META, NOTEBOOKLM_ALT_ROWS } from "../src/content/notebooklmAlternative";
|
| 6 |
+
import type { BlogPost } from "../src/content/seoTypes";
|
| 7 |
+
import { tools, toolsHub, type ToolDef } from "../src/content/tools";
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Writes a static HTML file per public route after `vite build`, so crawlers that do not run JavaScript still get the
|
| 11 |
+
* page's title, description, canonical, headings, copy and links. The React app replaces #root on load.
|
| 12 |
+
*
|
| 13 |
+
* Files are written as <route>.html, not <route>/index.html: Cloudflare Pages serves /blogs/x from blogs/x.html with no
|
| 14 |
+
* redirect, while a folder index would 308 to /blogs/x/ and fight the canonical (which has no trailing slash).
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
type Rendered = { path: string; title: string; description: string; body: string; jsonLd?: object[]; ogType?: string };
|
| 18 |
+
|
| 19 |
+
const esc = (s: string) =>
|
| 20 |
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
| 21 |
+
|
| 22 |
+
/** Post copy supports [text](url) links and **bold**; render them as real anchors. */
|
| 23 |
+
const inline = (text: string) =>
|
| 24 |
+
esc(text)
|
| 25 |
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label: string, href: string) => `<a href="${href}">${label}</a>`)
|
| 26 |
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
| 27 |
+
|
| 28 |
+
const plain = (text: string) => text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1");
|
| 29 |
+
|
| 30 |
+
const faqLd = (items: { q: string; a: string }[]) => ({
|
| 31 |
+
"@context": "https://schema.org",
|
| 32 |
+
"@type": "FAQPage",
|
| 33 |
+
mainEntity: items.map((f) => ({ "@type": "Question", name: f.q, acceptedAnswer: { "@type": "Answer", text: plain(f.a) } })),
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
const faqHtml = (items: { q: string; a: string }[]) =>
|
| 37 |
+
items.length
|
| 38 |
+
? `<section><h2>Questions</h2>${items.map((f) => `<h3>${esc(f.q)}</h3><p>${inline(f.a)}</p>`).join("")}</section>`
|
| 39 |
+
: "";
|
| 40 |
+
|
| 41 |
+
const nav = () =>
|
| 42 |
+
`<header><nav aria-label="Main"><a href="/">Notestack</a> <a href="/notebooklm-alternative">Notestack vs NotebookLM</a> <a href="/tools">Free tools</a> <a href="/pricing">Pricing</a> <a href="/blogs">Blog</a></nav></header>`;
|
| 43 |
+
|
| 44 |
+
const footer = () =>
|
| 45 |
+
`<footer><nav aria-label="Footer"><h2>Free tools</h2><ul>${tools
|
| 46 |
+
.map((t) => `<li><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a></li>`)
|
| 47 |
+
.join("")}</ul><h2>More from Firebird Technologies</h2><ul><li><a href="https://blog2video.app">Blog2Video: blog to video</a></li><li><a href="https://pdf2vid.com">PDF2Video: PDF to video</a></li><li><a href="https://bloghub.app">BlogHub: blog and newsletter directory</a></li></ul></nav><p>© Firebird Technologies</p></footer>`;
|
| 48 |
+
|
| 49 |
+
const page = (main: string) => `${nav()}<main>${main}</main>${footer()}`;
|
| 50 |
+
|
| 51 |
+
function pathTitle(p: string) {
|
| 52 |
+
const post = blogPosts.find((b) => `/blogs/${b.slug}` === p);
|
| 53 |
+
if (post) return post.title;
|
| 54 |
+
const tool = tools.find((t) => `/tools/${t.slug}` === p);
|
| 55 |
+
if (tool) return tool.heroTitle;
|
| 56 |
+
if (p === "/notebooklm-alternative") return "Notestack vs NotebookLM";
|
| 57 |
+
return p;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function renderPost(post: BlogPost): Rendered {
|
| 61 |
+
const sections = post.sections
|
| 62 |
+
.map(
|
| 63 |
+
(s) =>
|
| 64 |
+
`<section><h2>${esc(s.heading)}</h2>${s.paragraphs.map((p) => `<p>${inline(p)}</p>`).join("")}${
|
| 65 |
+
s.bullets?.length ? `<ul>${s.bullets.map((b) => `<li>${inline(b)}</li>`).join("")}</ul>` : ""
|
| 66 |
+
}${s.callout ? `<p>${inline(s.callout)}</p>` : ""}</section>`,
|
| 67 |
+
)
|
| 68 |
+
.join("");
|
| 69 |
+
const mission = post.mission ? `<p><strong>${esc(post.mission.question)}</strong> ${inline(post.mission.answer)}</p>` : "";
|
| 70 |
+
const related = post.relatedPaths.length
|
| 71 |
+
? `<nav aria-label="Related"><h2>Related</h2><ul>${post.relatedPaths
|
| 72 |
+
.map((p) => `<li><a href="${p}">${esc(pathTitle(p))}</a></li>`)
|
| 73 |
+
.join("")}</ul></nav>`
|
| 74 |
+
: "";
|
| 75 |
+
const faq = post.faq.map((f) => ({ q: f.question, a: f.answer }));
|
| 76 |
+
return {
|
| 77 |
+
path: `/blogs/${post.slug}`,
|
| 78 |
+
title: `${post.title} | Notestack`,
|
| 79 |
+
description: post.description,
|
| 80 |
+
ogType: "article",
|
| 81 |
+
body: page(
|
| 82 |
+
`<article><p>${esc(post.heroEyebrow)}</p><h1>${esc(post.title)}</h1><p>${esc(post.heroDescription)}</p><time datetime="${post.publishedAt}">${post.publishedAt}</time>${mission}${sections}${faqHtml(faq)}</article>${related}`,
|
| 83 |
+
),
|
| 84 |
+
jsonLd: [
|
| 85 |
+
{
|
| 86 |
+
"@context": "https://schema.org",
|
| 87 |
+
"@type": "BlogPosting",
|
| 88 |
+
headline: post.title,
|
| 89 |
+
description: post.description,
|
| 90 |
+
datePublished: post.publishedAt,
|
| 91 |
+
author: { "@type": "Organization", name: "Notestack" },
|
| 92 |
+
publisher: { "@type": "Organization", name: "Firebird Technologies" },
|
| 93 |
+
},
|
| 94 |
+
...(faq.length ? [faqLd(faq)] : []),
|
| 95 |
+
],
|
| 96 |
+
};
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function renderTool(tool: ToolDef): Rendered {
|
| 100 |
+
const faq = tool.faq.map((f) => ({ q: f.question, a: f.answer }));
|
| 101 |
+
const sections = tool.sections
|
| 102 |
+
.map(
|
| 103 |
+
(s) =>
|
| 104 |
+
`<section><h2>${esc(s.heading)}</h2>${s.paragraphs.map((p) => `<p>${inline(p)}</p>`).join("")}${
|
| 105 |
+
s.bullets?.length ? `<ul>${s.bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>` : ""
|
| 106 |
+
}</section>`,
|
| 107 |
+
)
|
| 108 |
+
.join("");
|
| 109 |
+
const next = encodeURIComponent(`/tools/${tool.slug}`);
|
| 110 |
+
return {
|
| 111 |
+
path: `/tools/${tool.slug}`,
|
| 112 |
+
title: tool.metaTitle,
|
| 113 |
+
description: tool.description,
|
| 114 |
+
body: page(
|
| 115 |
+
`<article><p>${esc(tool.eyebrow)}</p><h1>${esc(tool.heroTitle)}</h1><p>${esc(tool.heroDescription)}</p><p><a href="/auth?mode=signup&next=${next}">Sign up free to use the ${esc(
|
| 116 |
+
tool.eyebrow.toLowerCase(),
|
| 117 |
+
)}</a></p>${sections}${faqHtml(faq)}</article><nav aria-label="Related"><h2>Keep exploring</h2><ul>${tool.relatedPaths
|
| 118 |
+
.map((p) => `<li><a href="${p}">${esc(pathTitle(p))}</a></li>`)
|
| 119 |
+
.join("")}</ul></nav>`,
|
| 120 |
+
),
|
| 121 |
+
jsonLd: [
|
| 122 |
+
{
|
| 123 |
+
"@context": "https://schema.org",
|
| 124 |
+
"@type": "SoftwareApplication",
|
| 125 |
+
name: tool.heroTitle,
|
| 126 |
+
applicationCategory: "MultimediaApplication",
|
| 127 |
+
operatingSystem: "Web",
|
| 128 |
+
description: tool.description,
|
| 129 |
+
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
| 130 |
+
publisher: { "@type": "Organization", name: "Notestack" },
|
| 131 |
+
},
|
| 132 |
+
faqLd(faq),
|
| 133 |
+
],
|
| 134 |
+
};
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function routes(): Rendered[] {
|
| 138 |
+
const posts = [...blogPosts].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
| 139 |
+
const altFaq = NOTEBOOKLM_ALT_FAQ.map((f) => ({ q: f.q, a: f.a }));
|
| 140 |
+
return [
|
| 141 |
+
{
|
| 142 |
+
path: "/",
|
| 143 |
+
title: "Notestack: your knowledge, in orbit | NotebookLM alternative for writers",
|
| 144 |
+
description:
|
| 145 |
+
"Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice.",
|
| 146 |
+
body: page(
|
| 147 |
+
`<h1>Your knowledge, in orbit.</h1><p>Notestack turns your blog, newsletter, site or markdown files into a research notebook you can question, podcasts you can publish and launch kits for every platform. Every word traced back to what you actually wrote.</p><p><a href="/auth?mode=signup">Start free</a> or see <a href="/notebooklm-alternative">Notestack vs NotebookLM</a>.</p><h2>Free tools</h2><ul>${tools
|
| 148 |
+
.map((t) => `<li><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a>: ${esc(t.description)}</li>`)
|
| 149 |
+
.join("")}</ul><h2>Field notes</h2><ul>${posts
|
| 150 |
+
.map((p) => `<li><a href="/blogs/${p.slug}">${esc(p.title)}</a></li>`)
|
| 151 |
+
.join("")}</ul>`,
|
| 152 |
+
),
|
| 153 |
+
jsonLd: [
|
| 154 |
+
{
|
| 155 |
+
"@context": "https://schema.org",
|
| 156 |
+
"@type": "SoftwareApplication",
|
| 157 |
+
name: "Notestack",
|
| 158 |
+
applicationCategory: "BusinessApplication",
|
| 159 |
+
operatingSystem: "Web",
|
| 160 |
+
description: "The NotebookLM alternative for writers: a grounded research notebook built from your own archive.",
|
| 161 |
+
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
| 162 |
+
publisher: { "@type": "Organization", name: "Firebird Technologies" },
|
| 163 |
+
},
|
| 164 |
+
],
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
path: "/notebooklm-alternative",
|
| 168 |
+
title: NOTEBOOKLM_ALT_META.title,
|
| 169 |
+
description: NOTEBOOKLM_ALT_META.description,
|
| 170 |
+
body: page(
|
| 171 |
+
`<article><p>NotebookLM alternative</p><h1>Notestack vs NotebookLM: the NotebookLM alternative for writers</h1><p>NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.</p><p><a href="/auth?mode=signup">Start free</a></p><section><h2>Notestack vs NotebookLM</h2><table><thead><tr><th></th><th>Notestack</th><th>NotebookLM</th></tr></thead><tbody>${NOTEBOOKLM_ALT_ROWS.map(
|
| 172 |
+
(r) => `<tr><th>${esc(r.feature)}</th><td>${esc(r.notestack)}</td><td>${esc(r.notebooklm)}</td></tr>`,
|
| 173 |
+
).join("")}</tbody></table></section><section><h2>When NotebookLM is still the right call</h2><p>If you are studying someone else's documents for a one off project, want a free tool from Google, or live in Google Drive, NotebookLM is excellent. Notestack is the NotebookLM alternative when the sources are your own writing and the goal is to reuse and publish it.</p></section><section><h2>Pair it with the right publishing tool</h2><ul><li><a href="https://blog2video.app/blog-to-video">Blog2Video</a>: turn blog posts, URLs and scripts into finished videos.</li><li><a href="https://pdf2vid.com">PDF2Video</a>: turn any PDF, report, paper or deck into a narrated video.</li><li><a href="https://bloghub.app/submit-your-newsletter">BlogHub</a>: list your blog or newsletter in a free directory.</li></ul></section>${faqHtml(
|
| 174 |
+
altFaq,
|
| 175 |
+
)}<p>Comparing more tools? Read our guide to <a href="/blogs/notebooklm-alternatives">NotebookLM alternatives</a> and <a href="/blogs/notebooklm-vs-chatgpt">NotebookLM vs ChatGPT</a>.</p></article>`,
|
| 176 |
+
),
|
| 177 |
+
jsonLd: [faqLd(altFaq)],
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
path: "/pricing",
|
| 181 |
+
title: "Pricing | Notestack",
|
| 182 |
+
description:
|
| 183 |
+
"Notestack pricing: Free ($0), Writer ($24.99 a month, $18.99 billed annually) and Studio ($48.99 a month, $36.99 billed annually). Annual billing saves 24%.",
|
| 184 |
+
body: page(
|
| 185 |
+
`<h1>Pick your trajectory</h1><ul><li><h2>Free</h2><p>$0. 1 source, first 5 posts indexed, 3 audio minutes, 1 video minute, 2 Launch Kits a month.</p></li><li><h2>Writer</h2><p>$24.99 a month ($18.99 a month billed annually). 3 sources, 500 posts, 60 audio minutes, 30 video minutes, 50 Launch Kits.</p></li><li><h2>Studio</h2><p>$48.99 a month ($36.99 a month billed annually). 10 sources, 5,000 posts, 240 audio minutes, 120 video minutes, unlimited Launch Kits.</p></li></ul><p><a href="/auth?mode=signup">Start free</a></p>`,
|
| 186 |
+
),
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
path: "/blogs",
|
| 190 |
+
title: "Blog | Notestack",
|
| 191 |
+
description: "Field notes on research, deep writing and turning your archive into audio, video and launches.",
|
| 192 |
+
body: page(
|
| 193 |
+
`<h1>Every post is a search for an answer</h1>${posts
|
| 194 |
+
.map(
|
| 195 |
+
(p) =>
|
| 196 |
+
`<article><h2><a href="/blogs/${p.slug}">${esc(p.title)}</a></h2><p>${esc(p.description)}</p><time datetime="${p.publishedAt}">${p.publishedAt}</time></article>`,
|
| 197 |
+
)
|
| 198 |
+
.join("")}`,
|
| 199 |
+
),
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
path: "/tools",
|
| 203 |
+
title: toolsHub.metaTitle,
|
| 204 |
+
description: toolsHub.description,
|
| 205 |
+
body: page(
|
| 206 |
+
`<h1>${esc(toolsHub.heroTitle)}</h1><p>${esc(toolsHub.heroDescription)}</p><ul>${tools
|
| 207 |
+
.map((t) => `<li><h2><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a></h2><p>${esc(t.description)}</p></li>`)
|
| 208 |
+
.join("")}</ul>`,
|
| 209 |
+
),
|
| 210 |
+
},
|
| 211 |
+
...tools.map(renderTool),
|
| 212 |
+
...posts.filter((p) => !p.canonicalPath).map(renderPost),
|
| 213 |
+
];
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/** Strips the head tags this plugin owns, so every route gets exactly one of each. */
|
| 217 |
+
function sanitize(template: string) {
|
| 218 |
+
return template
|
| 219 |
+
.replace(/<title>[\s\S]*?<\/title>\s*/gi, "")
|
| 220 |
+
.replace(/<meta\s+name="description"[^>]*>\s*/gi, "")
|
| 221 |
+
.replace(/<meta\s+property="og:(title|description|url|type)"[^>]*>\s*/gi, "")
|
| 222 |
+
.replace(/<meta\s+name="twitter:(title|description)"[^>]*>\s*/gi, "")
|
| 223 |
+
.replace(/<link\s+rel="canonical"[^>]*>\s*/gi, "");
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
function head(r: Rendered, origin: string) {
|
| 227 |
+
const url = `${origin}${r.path === "/" ? "/" : r.path}`;
|
| 228 |
+
const tags = [
|
| 229 |
+
`<title>${esc(r.title)}</title>`,
|
| 230 |
+
`<meta name="description" content="${esc(r.description)}" />`,
|
| 231 |
+
`<link rel="canonical" href="${url}" />`,
|
| 232 |
+
`<meta property="og:type" content="${r.ogType ?? "website"}" />`,
|
| 233 |
+
`<meta property="og:title" content="${esc(r.title)}" />`,
|
| 234 |
+
`<meta property="og:description" content="${esc(r.description)}" />`,
|
| 235 |
+
`<meta property="og:url" content="${url}" />`,
|
| 236 |
+
`<meta name="twitter:title" content="${esc(r.title)}" />`,
|
| 237 |
+
`<meta name="twitter:description" content="${esc(r.description)}" />`,
|
| 238 |
+
...(r.jsonLd ?? []).map((ld) => `<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`),
|
| 239 |
+
];
|
| 240 |
+
return tags.join("\n ");
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
export function prerenderPlugin(siteUrl: string): Plugin {
|
| 244 |
+
const origin = siteUrl.replace(/\/+$/, "");
|
| 245 |
+
let config: ResolvedConfig;
|
| 246 |
+
return {
|
| 247 |
+
name: "notestack-prerender",
|
| 248 |
+
apply: "build",
|
| 249 |
+
configResolved(c) {
|
| 250 |
+
config = c;
|
| 251 |
+
},
|
| 252 |
+
async closeBundle() {
|
| 253 |
+
const outDir = path.resolve(config.root, config.build.outDir);
|
| 254 |
+
const template = sanitize(await readFile(path.join(outDir, "index.html"), "utf8"));
|
| 255 |
+
const all = routes();
|
| 256 |
+
for (const r of all) {
|
| 257 |
+
const html = template
|
| 258 |
+
.replace("</head>", ` ${head(r, origin)}\n </head>`)
|
| 259 |
+
.replace('<div id="root"></div>', `<div id="root">${r.body}</div>`);
|
| 260 |
+
const file = r.path === "/" ? path.join(outDir, "index.html") : path.join(outDir, `${r.path.slice(1)}.html`);
|
| 261 |
+
await mkdir(path.dirname(file), { recursive: true });
|
| 262 |
+
await writeFile(file, html, "utf8");
|
| 263 |
+
}
|
| 264 |
+
// Signed in and unknown routes fall back to an app shell with no page content (see public/_redirects).
|
| 265 |
+
await writeFile(path.join(outDir, "app.html"), template.replace("</head>", ` <meta name="robots" content="noindex" />\n <title>Notestack</title>\n </head>`), "utf8");
|
| 266 |
+
config.logger.info(`prerendered ${all.length} public routes`);
|
| 267 |
+
},
|
| 268 |
+
};
|
| 269 |
+
}
|
frontend/seo/sitemap.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import type { Plugin } from "vite";
|
| 2 |
import { blogPosts } from "../src/content/blogPosts";
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* sitemap.xml and robots.txt, generated from src/content/blogPosts.ts on every build (and served live
|
|
@@ -13,6 +14,8 @@ const STATIC_PAGES: { path: string; priority: string; changefreq: string }[] = [
|
|
| 13 |
{ path: "/notebooklm-alternative", priority: "0.9", changefreq: "monthly" },
|
| 14 |
{ path: "/pricing", priority: "0.8", changefreq: "monthly" },
|
| 15 |
{ path: "/blogs", priority: "0.9", changefreq: "weekly" },
|
|
|
|
|
|
|
| 16 |
];
|
| 17 |
|
| 18 |
const escapeXml = (s: string) =>
|
|
@@ -106,6 +109,7 @@ differs from NotebookLM.
|
|
| 106 |
- [Notestack vs NotebookLM](${base}/notebooklm-alternative): side by side comparison and FAQ
|
| 107 |
- [Pricing](${base}/pricing): plans and limits
|
| 108 |
- [Blog](${base}/blogs): guides on NotebookLM alternatives, AI for research and AI tools for writers
|
|
|
|
| 109 |
|
| 110 |
## Blog
|
| 111 |
|
|
|
|
| 1 |
import type { Plugin } from "vite";
|
| 2 |
import { blogPosts } from "../src/content/blogPosts";
|
| 3 |
+
import { tools } from "../src/content/tools";
|
| 4 |
|
| 5 |
/**
|
| 6 |
* sitemap.xml and robots.txt, generated from src/content/blogPosts.ts on every build (and served live
|
|
|
|
| 14 |
{ path: "/notebooklm-alternative", priority: "0.9", changefreq: "monthly" },
|
| 15 |
{ path: "/pricing", priority: "0.8", changefreq: "monthly" },
|
| 16 |
{ path: "/blogs", priority: "0.9", changefreq: "weekly" },
|
| 17 |
+
{ path: "/tools", priority: "0.8", changefreq: "monthly" },
|
| 18 |
+
...tools.map((t) => ({ path: `/tools/${t.slug}`, priority: "0.8", changefreq: "monthly" })),
|
| 19 |
];
|
| 20 |
|
| 21 |
const escapeXml = (s: string) =>
|
|
|
|
| 109 |
- [Notestack vs NotebookLM](${base}/notebooklm-alternative): side by side comparison and FAQ
|
| 110 |
- [Pricing](${base}/pricing): plans and limits
|
| 111 |
- [Blog](${base}/blogs): guides on NotebookLM alternatives, AI for research and AI tools for writers
|
| 112 |
+
- [Free tools](${base}/tools): ${tools.map((t) => `[${t.heroTitle}](${base}/tools/${t.slug})`).join(", ")} (free with sign in)
|
| 113 |
|
| 114 |
## Blog
|
| 115 |
|
frontend/src/App.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import Settings from "./pages/Settings";
|
|
| 19 |
import Sources from "./pages/Sources";
|
| 20 |
import Studio from "./pages/Studio";
|
| 21 |
import TopicMap from "./pages/TopicMap";
|
|
|
|
| 22 |
import VoiceProfile from "./pages/VoiceProfile";
|
| 23 |
import Welcome from "./pages/Welcome";
|
| 24 |
|
|
@@ -43,6 +44,8 @@ export default function App() {
|
|
| 43 |
<Route path="/" element={<Landing />} />
|
| 44 |
<Route path="/pricing" element={<PricingPage />} />
|
| 45 |
<Route path="/notebooklm-alternative" element={<NotebookLMAlternative />} />
|
|
|
|
|
|
|
| 46 |
<Route path="/blogs" element={<Blog />} />
|
| 47 |
<Route path="/blogs/:slug" element={<BlogPostPage />} />
|
| 48 |
<Route path="/auth" element={<AuthPage />} />
|
|
|
|
| 19 |
import Sources from "./pages/Sources";
|
| 20 |
import Studio from "./pages/Studio";
|
| 21 |
import TopicMap from "./pages/TopicMap";
|
| 22 |
+
import { ToolPage, ToolsHub } from "./pages/Tools";
|
| 23 |
import VoiceProfile from "./pages/VoiceProfile";
|
| 24 |
import Welcome from "./pages/Welcome";
|
| 25 |
|
|
|
|
| 44 |
<Route path="/" element={<Landing />} />
|
| 45 |
<Route path="/pricing" element={<PricingPage />} />
|
| 46 |
<Route path="/notebooklm-alternative" element={<NotebookLMAlternative />} />
|
| 47 |
+
<Route path="/tools" element={<ToolsHub />} />
|
| 48 |
+
<Route path="/tools/:slug" element={<ToolPage />} />
|
| 49 |
<Route path="/blogs" element={<Blog />} />
|
| 50 |
<Route path="/blogs/:slug" element={<BlogPostPage />} />
|
| 51 |
<Route path="/auth" element={<AuthPage />} />
|
frontend/src/components/PublicChrome.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { Link, NavLink } from "react-router-dom";
|
| 2 |
import { useAuth } from "../hooks/useAuth";
|
| 3 |
import { blogPosts } from "../content/blogPosts";
|
|
|
|
| 4 |
import Logo from "./Logo";
|
| 5 |
|
| 6 |
export function PublicNav() {
|
|
@@ -14,6 +15,7 @@ export function PublicNav() {
|
|
| 14 |
<nav className="nav-links" aria-label="Main">
|
| 15 |
<NavLink to="/#features">Features</NavLink>
|
| 16 |
<NavLink to="/pricing">Pricing</NavLink>
|
|
|
|
| 17 |
<NavLink to="/blogs">Blog</NavLink>
|
| 18 |
</nav>
|
| 19 |
{user ? (
|
|
@@ -52,6 +54,14 @@ export function PublicFooter() {
|
|
| 52 |
<Link to="/blogs">Blog</Link>
|
| 53 |
<Link to="/auth">Sign in</Link>
|
| 54 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
<div>
|
| 56 |
<p className="eyebrow">From the blog</p>
|
| 57 |
{blogPosts.slice(0, 3).map((p) => (
|
|
|
|
| 1 |
import { Link, NavLink } from "react-router-dom";
|
| 2 |
import { useAuth } from "../hooks/useAuth";
|
| 3 |
import { blogPosts } from "../content/blogPosts";
|
| 4 |
+
import { tools } from "../content/tools";
|
| 5 |
import Logo from "./Logo";
|
| 6 |
|
| 7 |
export function PublicNav() {
|
|
|
|
| 15 |
<nav className="nav-links" aria-label="Main">
|
| 16 |
<NavLink to="/#features">Features</NavLink>
|
| 17 |
<NavLink to="/pricing">Pricing</NavLink>
|
| 18 |
+
<NavLink to="/tools">Free tools</NavLink>
|
| 19 |
<NavLink to="/blogs">Blog</NavLink>
|
| 20 |
</nav>
|
| 21 |
{user ? (
|
|
|
|
| 54 |
<Link to="/blogs">Blog</Link>
|
| 55 |
<Link to="/auth">Sign in</Link>
|
| 56 |
</div>
|
| 57 |
+
<div>
|
| 58 |
+
<p className="eyebrow">Free tools</p>
|
| 59 |
+
{tools.map((t) => (
|
| 60 |
+
<Link key={t.slug} to={`/tools/${t.slug}`}>
|
| 61 |
+
{t.eyebrow}
|
| 62 |
+
</Link>
|
| 63 |
+
))}
|
| 64 |
+
</div>
|
| 65 |
<div>
|
| 66 |
<p className="eyebrow">From the blog</p>
|
| 67 |
{blogPosts.slice(0, 3).map((p) => (
|
frontend/src/content/blogPosts.ts
CHANGED
|
@@ -46,6 +46,7 @@ export const blogPosts: BlogPost[] = [
|
|
| 46 |
paragraphs: [
|
| 47 |
"Paste your Substack URL. Pick a post or build a notebook of related posts. Choose a format: deep dive, brief or debate. Notestack writes the script, you review it, and ElevenLabs voices it.",
|
| 48 |
"Publish the file to your podcast feed, attach it to the original post, or cut a 60 second audiogram for social.",
|
|
|
|
| 49 |
],
|
| 50 |
},
|
| 51 |
],
|
|
|
|
| 46 |
paragraphs: [
|
| 47 |
"Paste your Substack URL. Pick a post or build a notebook of related posts. Choose a format: deep dive, brief or debate. Notestack writes the script, you review it, and ElevenLabs voices it.",
|
| 48 |
"Publish the file to your podcast feed, attach it to the original post, or cut a 60 second audiogram for social.",
|
| 49 |
+
"Want to see the shape of the episode before you connect anything? Paste a post into the free [AI podcast generator](/tools/ai-podcast-generator) for a two host script, or use the [podcast script generator](/tools/podcast-script-generator) if you plan to record it yourself.",
|
| 50 |
],
|
| 51 |
},
|
| 52 |
],
|
frontend/src/content/missionPosts.ts
CHANGED
|
@@ -77,6 +77,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 77 |
{
|
| 78 |
heading: "For audio and video overviews",
|
| 79 |
paragraphs: [
|
|
|
|
| 80 |
"Audio overviews are the feature people miss most. Notestack makes two host audio overviews from a post or a notebook, with every line tied back to a passage, and you can pick from ElevenLabs voices or clone your own with consent.",
|
| 81 |
"Video is where dedicated tools win. If your source is a report, paper or deck, [PDF2Video](https://pdf2vid.com) turns any PDF into a narrated video. If it is a blog post, URL or script, [Blog2Video](https://blog2video.app) turns it into a finished video without you touching an editor. Both are a better fit than a notebook when the video itself is the deliverable.",
|
| 82 |
],
|
|
@@ -86,7 +87,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 86 |
heading: "How to switch without losing your work",
|
| 87 |
paragraphs: [
|
| 88 |
"Export what you can first: your notes, saved answers and the list of sources. Then rebuild around the job you named at the start rather than recreating every notebook one to one.",
|
| 89 |
-
"If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.",
|
| 90 |
],
|
| 91 |
},
|
| 92 |
],
|
|
@@ -173,7 +174,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 173 |
heading: "Stage four: share what you found",
|
| 174 |
paragraphs: [
|
| 175 |
"Research that stays in a document rarely gets read. Turn the dense parts into formats people actually consume. [PDF2Video](https://pdf2vid.com) turns a paper, report or slide deck into a narrated video, which is often the fastest way to brief a team or a class.",
|
| 176 |
-
"For findings you have written up as a post, [Blog2Video](https://blog2video.app) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.",
|
| 177 |
],
|
| 178 |
},
|
| 179 |
{
|
|
@@ -274,7 +275,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 274 |
heading: "Distribution without the busywork",
|
| 275 |
paragraphs: [
|
| 276 |
"Deep posts deserve a long life, and that is where AI saves the most time. A Launch Kit turns one post into a thread, a LinkedIn post, Substack Notes and a carousel, written from claims the post actually makes.",
|
| 277 |
-
"Video extends the reach further: [Blog2Video](https://blog2video.app) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app), a free directory of blogs and newsletters.",
|
| 278 |
],
|
| 279 |
},
|
| 280 |
{
|
|
@@ -302,7 +303,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 302 |
{
|
| 303 |
question: "How do I grow readers for a newsletter?",
|
| 304 |
answer:
|
| 305 |
-
"Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app) so readers browsing for new blogs and newsletters can find it.",
|
| 306 |
},
|
| 307 |
{
|
| 308 |
question: "Can AI turn my blog posts into videos?",
|
|
@@ -348,7 +349,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 348 |
heading: "Where ChatGPT wins",
|
| 349 |
paragraphs: [
|
| 350 |
"ChatGPT is the better thinking partner when you do not have sources yet. It explains unfamiliar concepts, suggests angles, drafts outlines and argues the other side.",
|
| 351 |
-
"Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source.",
|
| 352 |
],
|
| 353 |
bullets: ["Brainstorming and outlining", "Explaining concepts you are new to", "Stress testing an argument"],
|
| 354 |
},
|
|
|
|
| 77 |
{
|
| 78 |
heading: "For audio and video overviews",
|
| 79 |
paragraphs: [
|
| 80 |
+
"For a quick start, the free [AI podcast generator](/tools/ai-podcast-generator) turns any post into a two host script, and [PDF to podcast](/tools/pdf-to-podcast) does the same for papers and reports.",
|
| 81 |
"Audio overviews are the feature people miss most. Notestack makes two host audio overviews from a post or a notebook, with every line tied back to a passage, and you can pick from ElevenLabs voices or clone your own with consent.",
|
| 82 |
"Video is where dedicated tools win. If your source is a report, paper or deck, [PDF2Video](https://pdf2vid.com) turns any PDF into a narrated video. If it is a blog post, URL or script, [Blog2Video](https://blog2video.app) turns it into a finished video without you touching an editor. Both are a better fit than a notebook when the video itself is the deliverable.",
|
| 83 |
],
|
|
|
|
| 87 |
heading: "How to switch without losing your work",
|
| 88 |
paragraphs: [
|
| 89 |
"Export what you can first: your notes, saved answers and the list of sources. Then rebuild around the job you named at the start rather than recreating every notebook one to one.",
|
| 90 |
+
"If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app/submit-your-newsletter), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.",
|
| 91 |
],
|
| 92 |
},
|
| 93 |
],
|
|
|
|
| 174 |
heading: "Stage four: share what you found",
|
| 175 |
paragraphs: [
|
| 176 |
"Research that stays in a document rarely gets read. Turn the dense parts into formats people actually consume. [PDF2Video](https://pdf2vid.com) turns a paper, report or slide deck into a narrated video, which is often the fastest way to brief a team or a class.",
|
| 177 |
+
"For findings you have written up as a post, [Blog2Video](https://blog2video.app/url-to-video) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.",
|
| 178 |
],
|
| 179 |
},
|
| 180 |
{
|
|
|
|
| 275 |
heading: "Distribution without the busywork",
|
| 276 |
paragraphs: [
|
| 277 |
"Deep posts deserve a long life, and that is where AI saves the most time. A Launch Kit turns one post into a thread, a LinkedIn post, Substack Notes and a carousel, written from claims the post actually makes.",
|
| 278 |
+
"Video extends the reach further: [Blog2Video](https://blog2video.app/blog-to-shorts) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app/submit-your-newsletter), a free directory of blogs and newsletters.",
|
| 279 |
],
|
| 280 |
},
|
| 281 |
{
|
|
|
|
| 303 |
{
|
| 304 |
question: "How do I grow readers for a newsletter?",
|
| 305 |
answer:
|
| 306 |
+
"Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app/submit-your-newsletter) so readers browsing for new blogs and newsletters can find it. For more places to share your work, see this list of [article submission sites](https://bloghub.app/blogs/article-submission-sites).",
|
| 307 |
},
|
| 308 |
{
|
| 309 |
question: "Can AI turn my blog posts into videos?",
|
|
|
|
| 349 |
heading: "Where ChatGPT wins",
|
| 350 |
paragraphs: [
|
| 351 |
"ChatGPT is the better thinking partner when you do not have sources yet. It explains unfamiliar concepts, suggests angles, drafts outlines and argues the other side.",
|
| 352 |
+
"Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source. And if you are wondering whether it can go further and produce media, here is the honest answer to [can ChatGPT make videos](https://blog2video.app/blogs/can-chatgpt-make-videos).",
|
| 353 |
],
|
| 354 |
bullets: ["Brainstorming and outlining", "Explaining concepts you are new to", "Stress testing an argument"],
|
| 355 |
},
|
frontend/src/content/notebooklmAlternative.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Copy for /notebooklm-alternative, shared by the React page and the build time prerender (seo/prerender.ts).
|
| 2 |
+
|
| 3 |
+
export const NOTEBOOKLM_ALT_META = {
|
| 4 |
+
title: "Notestack vs NotebookLM: The NotebookLM Alternative for Writers | Notestack",
|
| 5 |
+
description:
|
| 6 |
+
"Notestack vs NotebookLM, side by side. Notestack is the NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.",
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export const NOTEBOOKLM_ALT_ROWS: { feature: string; notestack: string; notebooklm: string }[] = [
|
| 10 |
+
{
|
| 11 |
+
feature: "Getting your writing in",
|
| 12 |
+
notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads",
|
| 13 |
+
notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos",
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
feature: "Stays in sync",
|
| 17 |
+
notestack: "New posts arrive automatically on every sync",
|
| 18 |
+
notebooklm: "Re-add sources by hand when they change",
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
feature: "Citations",
|
| 22 |
+
notestack: "Exact line ranges, read back and verified before you see them",
|
| 23 |
+
notebooklm: "Passage level citations to the source",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
feature: "When the answer is not in your sources",
|
| 27 |
+
notestack: "Says so instead of guessing; unverifiable claims are dropped",
|
| 28 |
+
notebooklm: "Grounded in sources, with passage citations",
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
feature: "Chat memory",
|
| 32 |
+
notestack: "Follow ups resolved from the conversation; off topic questions answered instantly",
|
| 33 |
+
notebooklm: "Conversation within a notebook",
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
feature: "Audio overviews",
|
| 37 |
+
notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone",
|
| 38 |
+
notebooklm: "Two host audio overviews with Google voices",
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
feature: "Video",
|
| 42 |
+
notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand",
|
| 43 |
+
notebooklm: "Video overviews",
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
feature: "Publishing",
|
| 47 |
+
notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar",
|
| 48 |
+
notebooklm: "Not a publishing tool",
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
feature: "Your voice",
|
| 52 |
+
notestack: "Voice profile learned from your posts, used for every draft",
|
| 53 |
+
notebooklm: "General purpose writing",
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
feature: "Seeing your archive",
|
| 57 |
+
notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing",
|
| 58 |
+
notebooklm: "Mind maps per notebook",
|
| 59 |
+
},
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
export const NOTEBOOKLM_ALT_FAQ = [
|
| 63 |
+
{
|
| 64 |
+
q: "What is the best NotebookLM alternative?",
|
| 65 |
+
a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.",
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
q: "Is Notestack free?",
|
| 69 |
+
a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.",
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
q: "Can Notestack make NotebookLM style audio overviews?",
|
| 73 |
+
a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
q: "What if my writing is not on Substack?",
|
| 77 |
+
a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.",
|
| 78 |
+
},
|
| 79 |
+
];
|
frontend/src/content/tools.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Free tools at /tools/<slug>. Copy is shared by the React page and the build time prerender (seo/prerender.ts).
|
| 2 |
+
// Every tool widget requires sign in; the copy below stays public so the page can rank.
|
| 3 |
+
// Volumes are US Google Ads, Sep 2026. No em dashes (npm run check:copy).
|
| 4 |
+
|
| 5 |
+
export type ToolSection = { heading: string; paragraphs: string[]; bullets?: string[] };
|
| 6 |
+
|
| 7 |
+
export type ToolDef = {
|
| 8 |
+
slug: string;
|
| 9 |
+
/** <title> and meta description. */
|
| 10 |
+
metaTitle: string;
|
| 11 |
+
description: string;
|
| 12 |
+
eyebrow: string;
|
| 13 |
+
heroTitle: string;
|
| 14 |
+
heroDescription: string;
|
| 15 |
+
primaryKeyword: string;
|
| 16 |
+
/** What the widget asks for. */
|
| 17 |
+
inputLabel: string;
|
| 18 |
+
inputPlaceholder: string;
|
| 19 |
+
/** Optional line under the input (inline [text](url) links allowed). */
|
| 20 |
+
inputHint?: string;
|
| 21 |
+
sections: ToolSection[];
|
| 22 |
+
faq: { question: string; answer: string }[];
|
| 23 |
+
relatedPaths: string[];
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export const toolsHub = {
|
| 27 |
+
path: "/tools",
|
| 28 |
+
metaTitle: "Free AI Podcast Tools for Writers | Notestack",
|
| 29 |
+
description:
|
| 30 |
+
"Free tools that turn your writing into podcasts: an AI podcast generator, a PDF to podcast converter and a two host podcast script generator.",
|
| 31 |
+
heroTitle: "Free tools for turning writing into audio",
|
| 32 |
+
heroDescription:
|
| 33 |
+
"Every tool works on words you already wrote. Sign in free, paste a post or a PDF's text, and get a two host script you can voice in Notestack.",
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export const tools: ToolDef[] = [
|
| 37 |
+
{
|
| 38 |
+
slug: "ai-podcast-generator",
|
| 39 |
+
metaTitle: "Free AI Podcast Generator: Turn Any Post Into a Two Host Show | Notestack",
|
| 40 |
+
description:
|
| 41 |
+
"Free AI podcast generator for writers. Paste a blog post or newsletter and get a two host podcast script grounded in your words, then voice it as a full audio overview in Notestack.",
|
| 42 |
+
eyebrow: "AI podcast generator",
|
| 43 |
+
heroTitle: "AI podcast generator for writers",
|
| 44 |
+
heroDescription:
|
| 45 |
+
"Paste a blog post, newsletter or article and get a two host podcast script built only from what you wrote. Voice it as a finished audio overview in one click.",
|
| 46 |
+
primaryKeyword: "ai podcast generator",
|
| 47 |
+
inputLabel: "Paste your post",
|
| 48 |
+
inputPlaceholder: "Paste a blog post, newsletter issue or article. The script is built only from this text.",
|
| 49 |
+
sections: [
|
| 50 |
+
{
|
| 51 |
+
heading: "How the AI podcast generator works",
|
| 52 |
+
paragraphs: [
|
| 53 |
+
"Most AI podcast generators write a new show and hope it matches your ideas. This one starts from your text. It picks the sentences that carry your argument, then stages them as a conversation between two hosts: one who guides, one who asks the questions a listener would ask.",
|
| 54 |
+
"Because every host line comes from a passage you wrote, nothing is invented. In the full Notestack app each line keeps a citation back to the exact source lines, so you can check the show before anyone hears it.",
|
| 55 |
+
],
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
heading: "From script to finished episode",
|
| 59 |
+
paragraphs: [
|
| 60 |
+
"The free tool gives you the script. To hear it, connect your blog or newsletter to Notestack and generate an audio overview: two natural voices, delivery controls, and optional consented voice cloning so one host can sound like you. The free plan includes audio minutes to try it.",
|
| 61 |
+
"Want a video instead of audio? [Blog2Video](https://blog2video.app/blog-to-video) turns the same post into a narrated video, and [PDF2Video](https://pdf2vid.com) does the same for reports and papers.",
|
| 62 |
+
],
|
| 63 |
+
bullets: [
|
| 64 |
+
"Two hosts, grounded in your own post",
|
| 65 |
+
"Runtime estimate before you record anything",
|
| 66 |
+
"Copy or download the script as text",
|
| 67 |
+
"Voice it in Notestack with two host audio overviews",
|
| 68 |
+
],
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
heading: "Why writers use a podcast generator",
|
| 72 |
+
paragraphs: [
|
| 73 |
+
"A post that took a week to write gets read once. A two host episode of the same post reaches people on walks, commutes and at the gym, and it keeps your archive working long after publish day. It is the fastest way to add audio to a newsletter without booking a guest or sitting at a microphone.",
|
| 74 |
+
"If you are comparing tools, our guide to the [best NotebookLM alternatives](/blogs/notebooklm-alternatives) covers the other audio overview options, and [Notestack vs NotebookLM](/notebooklm-alternative) explains the difference side by side.",
|
| 75 |
+
],
|
| 76 |
+
},
|
| 77 |
+
],
|
| 78 |
+
faq: [
|
| 79 |
+
{
|
| 80 |
+
question: "Is the AI podcast generator free?",
|
| 81 |
+
answer:
|
| 82 |
+
"Yes. Sign in with a free Notestack account and generate as many scripts as you like. Turning a script into audio uses the audio minutes included in every plan, free plan included.",
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
question: "Does it make up content?",
|
| 86 |
+
answer:
|
| 87 |
+
"No. The script is assembled only from sentences in the text you paste. The hosts reframe and question your points, but the substance is always yours.",
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
question: "How is this different from NotebookLM audio overviews?",
|
| 91 |
+
answer:
|
| 92 |
+
"NotebookLM works on sources you upload one at a time. Notestack syncs your whole blog or newsletter, keeps citations to the exact lines, and can voice one host in your own cloned voice with consent.",
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
question: "How long will my episode be?",
|
| 96 |
+
answer:
|
| 97 |
+
"The tool estimates runtime at about 150 spoken words per minute. A 1,500 word post usually becomes a six to nine minute episode.",
|
| 98 |
+
},
|
| 99 |
+
],
|
| 100 |
+
relatedPaths: ["/tools/pdf-to-podcast", "/tools/podcast-script-generator", "/blogs/turn-substack-archive-into-podcast", "/notebooklm-alternative"],
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
slug: "pdf-to-podcast",
|
| 104 |
+
metaTitle: "PDF to Podcast: Turn Any PDF Into a Two Host Podcast Free | Notestack",
|
| 105 |
+
description:
|
| 106 |
+
"Turn a PDF, research paper or report into a two host podcast. Paste the PDF's text or upload the file to Notestack, and get an audio overview grounded in the document.",
|
| 107 |
+
eyebrow: "PDF to podcast",
|
| 108 |
+
heroTitle: "Turn any PDF into a podcast",
|
| 109 |
+
heroDescription:
|
| 110 |
+
"Research papers, reports, ebooks and slide decks become a two host conversation you can listen to. Every line stays tied to the page it came from.",
|
| 111 |
+
primaryKeyword: "pdf to podcast",
|
| 112 |
+
inputLabel: "Paste the text from your PDF",
|
| 113 |
+
inputPlaceholder: "Paste the text of your PDF here. The script is built only from this text.",
|
| 114 |
+
inputHint:
|
| 115 |
+
"Need the text first? Pull it out with the free [PDF to text tool](https://pdf2vid.com/tools/pdf-to-text), or upload the PDF straight into a Notestack notebook.",
|
| 116 |
+
sections: [
|
| 117 |
+
{
|
| 118 |
+
heading: "How PDF to podcast works",
|
| 119 |
+
paragraphs: [
|
| 120 |
+
"Paste the text from your PDF and the tool pulls out the sentences that carry the document: the claim, the evidence and the conclusion. It stages them as a two host conversation, with one host explaining and the other asking what a listener would ask.",
|
| 121 |
+
"In the Notestack app you can skip the copy and paste. Upload the PDF into a notebook and generate a full audio overview, with every host line cited to the page and lines it came from.",
|
| 122 |
+
],
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
heading: "Which PDFs make the best podcasts",
|
| 126 |
+
paragraphs: [
|
| 127 |
+
"Documents with an argument work best: research papers, white papers, annual reports, long reads and ebook chapters. Pure reference material such as manuals or spreadsheets makes a thinner show.",
|
| 128 |
+
],
|
| 129 |
+
bullets: [
|
| 130 |
+
"Research papers: the hosts walk through the question, method and finding",
|
| 131 |
+
"Reports: turn the executive summary into a ten minute briefing",
|
| 132 |
+
"Ebooks: one episode per chapter becomes a series",
|
| 133 |
+
"Slide decks: the narration fills in what the slides leave out",
|
| 134 |
+
],
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
heading: "Podcast or video?",
|
| 138 |
+
paragraphs: [
|
| 139 |
+
"Audio suits people who listen on the move. If your audience watches instead, [PDF2Video](https://pdf2vid.com) turns the same PDF into a narrated video with slides, and its [PDF to audio](https://pdf2vid.com/tools/pdf-to-audio) tool reads a document aloud word for word. A podcast is different: two hosts talk the ideas through.",
|
| 140 |
+
"Researching across many PDFs? Read our guide to the [best AI for research](/blogs/best-ai-for-research).",
|
| 141 |
+
],
|
| 142 |
+
},
|
| 143 |
+
],
|
| 144 |
+
faq: [
|
| 145 |
+
{
|
| 146 |
+
question: "Can I turn a PDF into a podcast for free?",
|
| 147 |
+
answer:
|
| 148 |
+
"Yes. Sign in free, paste the PDF's text and get a two host script. Voicing it in Notestack uses the audio minutes included in the free plan.",
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
question: "Does it work with scanned PDFs?",
|
| 152 |
+
answer:
|
| 153 |
+
"The tool needs selectable text. If your PDF is a scan, run it through OCR first, then paste the text.",
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
question: "Is PDF to podcast the same as PDF to audio?",
|
| 157 |
+
answer:
|
| 158 |
+
"No. PDF to audio reads the document aloud. PDF to podcast turns it into a conversation between two hosts who explain and question the ideas, which is easier to follow for long or technical documents.",
|
| 159 |
+
},
|
| 160 |
+
],
|
| 161 |
+
relatedPaths: ["/tools/ai-podcast-generator", "/tools/podcast-script-generator", "/blogs/best-ai-for-research", "/notebooklm-alternative"],
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
slug: "podcast-script-generator",
|
| 165 |
+
metaTitle: "Free Podcast Script Generator: Two Host Scripts From Your Writing | Notestack",
|
| 166 |
+
description:
|
| 167 |
+
"Free podcast script generator. Paste your notes, post or article and get a formatted two host podcast script with an intro, segments, questions and an outro, built only from your words.",
|
| 168 |
+
eyebrow: "Podcast script generator",
|
| 169 |
+
heroTitle: "Podcast script generator",
|
| 170 |
+
heroDescription:
|
| 171 |
+
"Turn notes, a post or an article into a ready to record two host script: cold open, segments, the questions your listeners would ask, and a clean outro.",
|
| 172 |
+
primaryKeyword: "podcast script generator",
|
| 173 |
+
inputLabel: "Paste your notes or post",
|
| 174 |
+
inputPlaceholder: "Paste notes, an outline or a full post. The script is built only from this text.",
|
| 175 |
+
sections: [
|
| 176 |
+
{
|
| 177 |
+
heading: "What you get",
|
| 178 |
+
paragraphs: [
|
| 179 |
+
"A script in the standard two host format: a cold open that states the big idea, segments built from your key points, host questions that set up each point, and an outro that lands the takeaway. Each line is labelled with its speaker so you can record straight from it.",
|
| 180 |
+
],
|
| 181 |
+
bullets: [
|
| 182 |
+
"Speaker labelled lines for Host A and Host B",
|
| 183 |
+
"Segments ordered the way your text builds its argument",
|
| 184 |
+
"Estimated runtime at a natural speaking pace",
|
| 185 |
+
"Plain text you can copy into any teleprompter or doc",
|
| 186 |
+
],
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
heading: "Record it, or let Notestack voice it",
|
| 190 |
+
paragraphs: [
|
| 191 |
+
"Record the script yourself with a co host, or generate a voiced audio overview in Notestack with two natural voices. Writers who publish every week connect their newsletter once, and every new post can become an episode.",
|
| 192 |
+
"Turning a whole back catalog into a show? Read [how to turn your Substack archive into a podcast](/blogs/turn-substack-archive-into-podcast).",
|
| 193 |
+
],
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
heading: "Tips for a better script",
|
| 197 |
+
paragraphs: ["The generator follows your text, so the clearer the source, the better the show."],
|
| 198 |
+
bullets: [
|
| 199 |
+
"Paste the full post, not a summary: the hosts need your specifics",
|
| 200 |
+
"Keep one idea per paragraph so segments split cleanly",
|
| 201 |
+
"Edit the questions to sound like your real audience",
|
| 202 |
+
"Aim for 800 to 2,000 words for a five to fifteen minute episode",
|
| 203 |
+
],
|
| 204 |
+
},
|
| 205 |
+
],
|
| 206 |
+
faq: [
|
| 207 |
+
{
|
| 208 |
+
question: "Is the podcast script generator free?",
|
| 209 |
+
answer: "Yes. It is free with a Notestack account. Sign in and generate as many scripts as you need.",
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
question: "Can I use the script commercially?",
|
| 213 |
+
answer: "Yes. The script is built from your own text, so it is yours to record, publish and monetize.",
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
question: "Does it support one host shows?",
|
| 217 |
+
answer:
|
| 218 |
+
"The generator writes for two hosts, the format that keeps long ideas easy to follow. For a solo show, read Host A's lines and turn Host B's questions into your own transitions.",
|
| 219 |
+
},
|
| 220 |
+
],
|
| 221 |
+
relatedPaths: ["/tools/ai-podcast-generator", "/tools/pdf-to-podcast", "/blogs/substack-launch-kit", "/blogs/ai-tools-for-writers"],
|
| 222 |
+
},
|
| 223 |
+
];
|
| 224 |
+
|
| 225 |
+
export const getTool = (slug: string) => tools.find((t) => t.slug === slug);
|
frontend/src/lib/podcastScript.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Builds a two host podcast script from pasted text, entirely in the browser. Every substantive line is a sentence
|
| 2 |
+
// from the source; the hosts only add framing and questions around it, so nothing is invented.
|
| 3 |
+
|
| 4 |
+
export type ScriptLine = { host: "A" | "B"; text: string };
|
| 5 |
+
export type PodcastScript = { lines: ScriptLine[]; words: number; minutes: number; sourceSentences: number };
|
| 6 |
+
|
| 7 |
+
const WORDS_PER_MINUTE = 150;
|
| 8 |
+
|
| 9 |
+
const STOP = new Set(
|
| 10 |
+
"a an and are as at be but by for from has have he her his i if in into is it its of on or our she so than that the their them then there these they this to was we were what when which who will with you your not can do does did just also more most very about over out up".split(
|
| 11 |
+
" ",
|
| 12 |
+
),
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
const QUESTIONS = [
|
| 16 |
+
"So where does it start?",
|
| 17 |
+
"Okay, and why does that matter?",
|
| 18 |
+
"What is the part people usually miss?",
|
| 19 |
+
"Can you make that concrete?",
|
| 20 |
+
"So what changes because of that?",
|
| 21 |
+
"Is there a catch?",
|
| 22 |
+
"How does that play out in practice?",
|
| 23 |
+
"And where does that leave us?",
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
const words = (s: string) => s.trim().split(/\s+/).filter(Boolean);
|
| 27 |
+
|
| 28 |
+
function sentences(text: string): string[] {
|
| 29 |
+
return text
|
| 30 |
+
.replace(/\r/g, "")
|
| 31 |
+
.split(/\n{2,}|(?<=[.!?])\s+(?=[A-Z0-9"'(])/)
|
| 32 |
+
.map((s) => s.replace(/\s+/g, " ").trim())
|
| 33 |
+
.filter((s) => {
|
| 34 |
+
const n = words(s).length;
|
| 35 |
+
return n >= 6 && n <= 60 && /[a-z]/i.test(s);
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/** Scores sentences by how many of the text's recurring terms they carry, with a small lead bias. */
|
| 40 |
+
function pickPoints(all: string[]): string[] {
|
| 41 |
+
const freq = new Map<string, number>();
|
| 42 |
+
for (const s of all)
|
| 43 |
+
for (const w of words(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ")))
|
| 44 |
+
if (w.length > 3 && !STOP.has(w)) freq.set(w, (freq.get(w) ?? 0) + 1);
|
| 45 |
+
const scored = all.map((s, i) => {
|
| 46 |
+
const terms = words(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ")).filter((w) => freq.has(w));
|
| 47 |
+
const score = terms.reduce((sum, w) => sum + Math.min(freq.get(w) ?? 0, 5), 0) / Math.sqrt(words(s).length);
|
| 48 |
+
return { s, i, score: score + (i === 0 ? 2 : 0) + (i === all.length - 1 ? 1 : 0) };
|
| 49 |
+
});
|
| 50 |
+
return scored
|
| 51 |
+
.sort((a, b) => b.score - a.score)
|
| 52 |
+
// Roughly one point per three source sentences, so longer posts make longer episodes.
|
| 53 |
+
.slice(0, Math.min(16, Math.max(5, Math.round(all.length / 3))))
|
| 54 |
+
.sort((a, b) => a.i - b.i)
|
| 55 |
+
.map((x) => x.s);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function buildPodcastScript(text: string): PodcastScript | null {
|
| 59 |
+
const all = sentences(text);
|
| 60 |
+
if (all.length < 3) return null;
|
| 61 |
+
const points = pickPoints(all);
|
| 62 |
+
const [opening, ...body] = points;
|
| 63 |
+
const closing = body.length > 2 ? body.pop()! : undefined;
|
| 64 |
+
|
| 65 |
+
const lines: ScriptLine[] = [
|
| 66 |
+
{ host: "A", text: "Welcome back. Today we are walking through one piece of writing, and staying close to what it actually says." },
|
| 67 |
+
{ host: "B", text: "Good, because I want the argument, not a summary. What is the big idea?" },
|
| 68 |
+
{ host: "A", text: opening },
|
| 69 |
+
];
|
| 70 |
+
body.forEach((point, i) => {
|
| 71 |
+
lines.push({ host: "B", text: QUESTIONS[i % QUESTIONS.length] });
|
| 72 |
+
lines.push({ host: "A", text: point });
|
| 73 |
+
});
|
| 74 |
+
if (closing) {
|
| 75 |
+
lines.push({ host: "B", text: "If someone remembers one thing from this, what should it be?" });
|
| 76 |
+
lines.push({ host: "A", text: closing });
|
| 77 |
+
}
|
| 78 |
+
lines.push({ host: "B", text: "That is the episode. The full piece goes deeper, so read it if this landed for you." });
|
| 79 |
+
|
| 80 |
+
const count = lines.reduce((n, l) => n + words(l.text).length, 0);
|
| 81 |
+
return { lines, words: count, minutes: count / WORDS_PER_MINUTE, sourceSentences: all.length };
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export const scriptToText = (script: PodcastScript) =>
|
| 85 |
+
script.lines.map((l) => `HOST ${l.host}: ${l.text}`).join("\n\n");
|
frontend/src/pages/AuthCallback.tsx
CHANGED
|
@@ -24,7 +24,7 @@ export default function AuthCallback() {
|
|
| 24 |
.redeemTicket(ticket)
|
| 25 |
.then((result) => {
|
| 26 |
complete(result);
|
| 27 |
-
navigate(result.created ? "/welcome" : next, { replace: true });
|
| 28 |
})
|
| 29 |
.catch(() => {
|
| 30 |
const error = new URLSearchParams({ error: "Google sign in expired. Try again." });
|
|
|
|
| 24 |
.redeemTicket(ticket)
|
| 25 |
.then((result) => {
|
| 26 |
complete(result);
|
| 27 |
+
navigate(result.created && !next.startsWith("/tools/") ? "/welcome" : next, { replace: true });
|
| 28 |
})
|
| 29 |
.catch(() => {
|
| 30 |
const error = new URLSearchParams({ error: "Google sign in expired. Try again." });
|
frontend/src/pages/AuthPage.tsx
CHANGED
|
@@ -85,7 +85,8 @@ export default function AuthPage() {
|
|
| 85 |
const next = safeNext(params.get("next"));
|
| 86 |
|
| 87 |
useEffect(() => {
|
| 88 |
-
|
|
|
|
| 89 |
}, [user, navigate, next, isNew]);
|
| 90 |
|
| 91 |
useEffect(() => {
|
|
|
|
| 85 |
const next = safeNext(params.get("next"));
|
| 86 |
|
| 87 |
useEffect(() => {
|
| 88 |
+
// New accounts get onboarding, except people who signed up to use a free tool: send them straight back to it.
|
| 89 |
+
if (user) navigate(isNew && !next.startsWith("/tools/") ? "/welcome" : next, { replace: true });
|
| 90 |
}, [user, navigate, next, isNew]);
|
| 91 |
|
| 92 |
useEffect(() => {
|
frontend/src/pages/Blog.tsx
CHANGED
|
@@ -45,7 +45,7 @@ const missionOf = (post: BlogPost): Mission =>
|
|
| 45 |
const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g;
|
| 46 |
|
| 47 |
/** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */
|
| 48 |
-
function Inline({ text }: { text: string }) {
|
| 49 |
const out: ReactNode[] = [];
|
| 50 |
let last = 0;
|
| 51 |
let i = 0;
|
|
|
|
| 45 |
const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g;
|
| 46 |
|
| 47 |
/** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */
|
| 48 |
+
export function Inline({ text }: { text: string }) {
|
| 49 |
const out: ReactNode[] = [];
|
| 50 |
let last = 0;
|
| 51 |
let i = 0;
|
frontend/src/pages/NotebookLMAlternative.tsx
CHANGED
|
@@ -3,86 +3,13 @@ import SkyCanvas from "../components/SkyCanvas";
|
|
| 3 |
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 4 |
import { useMeta } from "./Blog";
|
| 5 |
import "../styles/blog.css";
|
|
|
|
| 6 |
|
| 7 |
-
/**
|
| 8 |
-
|
| 9 |
-
const ROWS: { feature: string; notestack: string; notebooklm: string }[] = [
|
| 10 |
-
{
|
| 11 |
-
feature: "Getting your writing in",
|
| 12 |
-
notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads",
|
| 13 |
-
notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos",
|
| 14 |
-
},
|
| 15 |
-
{
|
| 16 |
-
feature: "Stays in sync",
|
| 17 |
-
notestack: "New posts arrive automatically on every sync",
|
| 18 |
-
notebooklm: "Re-add sources by hand when they change",
|
| 19 |
-
},
|
| 20 |
-
{
|
| 21 |
-
feature: "Citations",
|
| 22 |
-
notestack: "Exact line ranges, read back and verified before you see them",
|
| 23 |
-
notebooklm: "Passage level citations to the source",
|
| 24 |
-
},
|
| 25 |
-
{
|
| 26 |
-
feature: "When the answer is not in your sources",
|
| 27 |
-
notestack: "Says so instead of guessing; unverifiable claims are dropped",
|
| 28 |
-
notebooklm: "Grounded in sources, with passage citations",
|
| 29 |
-
},
|
| 30 |
-
{
|
| 31 |
-
feature: "Chat memory",
|
| 32 |
-
notestack: "Follow ups resolved from the conversation; off topic questions answered instantly",
|
| 33 |
-
notebooklm: "Conversation within a notebook",
|
| 34 |
-
},
|
| 35 |
-
{
|
| 36 |
-
feature: "Audio overviews",
|
| 37 |
-
notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone",
|
| 38 |
-
notebooklm: "Two host audio overviews with Google voices",
|
| 39 |
-
},
|
| 40 |
-
{
|
| 41 |
-
feature: "Video",
|
| 42 |
-
notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand",
|
| 43 |
-
notebooklm: "Video overviews",
|
| 44 |
-
},
|
| 45 |
-
{
|
| 46 |
-
feature: "Publishing",
|
| 47 |
-
notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar",
|
| 48 |
-
notebooklm: "Not a publishing tool",
|
| 49 |
-
},
|
| 50 |
-
{
|
| 51 |
-
feature: "Your voice",
|
| 52 |
-
notestack: "Voice profile learned from your posts, used for every draft",
|
| 53 |
-
notebooklm: "General purpose writing",
|
| 54 |
-
},
|
| 55 |
-
{
|
| 56 |
-
feature: "Seeing your archive",
|
| 57 |
-
notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing",
|
| 58 |
-
notebooklm: "Mind maps per notebook",
|
| 59 |
-
},
|
| 60 |
-
];
|
| 61 |
-
|
| 62 |
-
const FAQ = [
|
| 63 |
-
{
|
| 64 |
-
q: "What is the best NotebookLM alternative?",
|
| 65 |
-
a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.",
|
| 66 |
-
},
|
| 67 |
-
{
|
| 68 |
-
q: "Is Notestack free?",
|
| 69 |
-
a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.",
|
| 70 |
-
},
|
| 71 |
-
{
|
| 72 |
-
q: "Can Notestack make NotebookLM style audio overviews?",
|
| 73 |
-
a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.",
|
| 74 |
-
},
|
| 75 |
-
{
|
| 76 |
-
q: "What if my writing is not on Substack?",
|
| 77 |
-
a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.",
|
| 78 |
-
},
|
| 79 |
-
];
|
| 80 |
|
| 81 |
export default function NotebookLMAlternative() {
|
| 82 |
-
useMeta(
|
| 83 |
-
"The Ultimate NotebookLM Alternative for Writers | Notestack",
|
| 84 |
-
"Notestack is the ultimate NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.",
|
| 85 |
-
);
|
| 86 |
const faqLd = {
|
| 87 |
"@context": "https://schema.org",
|
| 88 |
"@type": "FAQPage",
|
|
@@ -112,7 +39,7 @@ export default function NotebookLMAlternative() {
|
|
| 112 |
<span className="mono tx-label">Incoming query</span>
|
| 113 |
<p className="tx-query">Is there a NotebookLM built for my own writing?</p>
|
| 114 |
</div>
|
| 115 |
-
<h1 className="post-title">
|
| 116 |
<p className="post-lede muted">
|
| 117 |
NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or
|
| 118 |
markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.
|
|
|
|
| 3 |
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 4 |
import { useMeta } from "./Blog";
|
| 5 |
import "../styles/blog.css";
|
| 6 |
+
import { NOTEBOOKLM_ALT_FAQ as FAQ, NOTEBOOKLM_ALT_META, NOTEBOOKLM_ALT_ROWS as ROWS } from "../content/notebooklmAlternative";
|
| 7 |
|
| 8 |
+
/** Comparison landing page: "Notestack vs NotebookLM". The /blogs/notebooklm-alternatives listicle owns the plural
|
| 9 |
+
* "notebooklm alternatives" query (590/mo US, KD 0, Sep 2026) and links here, so the two do not compete. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
export default function NotebookLMAlternative() {
|
| 12 |
+
useMeta(NOTEBOOKLM_ALT_META.title, NOTEBOOKLM_ALT_META.description);
|
|
|
|
|
|
|
|
|
|
| 13 |
const faqLd = {
|
| 14 |
"@context": "https://schema.org",
|
| 15 |
"@type": "FAQPage",
|
|
|
|
| 39 |
<span className="mono tx-label">Incoming query</span>
|
| 40 |
<p className="tx-query">Is there a NotebookLM built for my own writing?</p>
|
| 41 |
</div>
|
| 42 |
+
<h1 className="post-title">Notestack vs NotebookLM: the NotebookLM alternative for writers</h1>
|
| 43 |
<p className="post-lede muted">
|
| 44 |
NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or
|
| 45 |
markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.
|
frontend/src/pages/Tools.tsx
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from "react";
|
| 2 |
+
import { Link, useParams } from "react-router-dom";
|
| 3 |
+
import SkyCanvas from "../components/SkyCanvas";
|
| 4 |
+
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 5 |
+
import { getTool, tools, toolsHub, type ToolDef } from "../content/tools";
|
| 6 |
+
import { useAuth } from "../hooks/useAuth";
|
| 7 |
+
import { buildPodcastScript, scriptToText } from "../lib/podcastScript";
|
| 8 |
+
import { Inline, useMeta } from "./Blog";
|
| 9 |
+
import "../styles/blog.css";
|
| 10 |
+
import "../styles/tools.css";
|
| 11 |
+
|
| 12 |
+
const pathTitle = (path: string) => {
|
| 13 |
+
const tool = tools.find((t) => `/tools/${t.slug}` === path);
|
| 14 |
+
if (tool) return tool.heroTitle;
|
| 15 |
+
if (path === "/notebooklm-alternative") return "Notestack vs NotebookLM";
|
| 16 |
+
return path.replace(/^\/blogs\//, "").replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase());
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export function ToolsHub() {
|
| 20 |
+
useMeta(toolsHub.metaTitle, toolsHub.description);
|
| 21 |
+
return (
|
| 22 |
+
<div className="page">
|
| 23 |
+
<SkyCanvas intensity={0.7} travel />
|
| 24 |
+
<PublicNav />
|
| 25 |
+
<main className="container post mission alt-page">
|
| 26 |
+
<header className="mission-head">
|
| 27 |
+
<p className="eyebrow">Free tools</p>
|
| 28 |
+
<h1 className="post-title">{toolsHub.heroTitle}</h1>
|
| 29 |
+
<p className="post-lede muted">{toolsHub.heroDescription}</p>
|
| 30 |
+
</header>
|
| 31 |
+
<div className="alt-grid">
|
| 32 |
+
{tools.map((t) => (
|
| 33 |
+
<Link key={t.slug} to={`/tools/${t.slug}`} className="card alt-sister">
|
| 34 |
+
<h2 className="tool-card-title">{t.heroTitle}</h2>
|
| 35 |
+
<p className="muted">{t.description}</p>
|
| 36 |
+
</Link>
|
| 37 |
+
))}
|
| 38 |
+
</div>
|
| 39 |
+
</main>
|
| 40 |
+
<PublicFooter />
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Every free tool requires sign in: signed out visitors see the pitch and a sign in link that returns them here. */
|
| 46 |
+
function SignInGate({ tool }: { tool: ToolDef }) {
|
| 47 |
+
const next = encodeURIComponent(`/tools/${tool.slug}`);
|
| 48 |
+
return (
|
| 49 |
+
<div className="card tool-gate">
|
| 50 |
+
<h2>Sign in to use the {tool.eyebrow.toLowerCase()}</h2>
|
| 51 |
+
<p className="muted">It is free. Create an account in seconds and come straight back to this tool.</p>
|
| 52 |
+
<div className="alt-ctas">
|
| 53 |
+
<Link to={`/auth?mode=signup&next=${next}`} className="btn btn-primary">
|
| 54 |
+
Sign up free
|
| 55 |
+
</Link>
|
| 56 |
+
<Link to={`/auth?next=${next}`} className="btn">
|
| 57 |
+
Sign in
|
| 58 |
+
</Link>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function PodcastScriptWidget({ tool }: { tool: ToolDef }) {
|
| 65 |
+
const [text, setText] = useState("");
|
| 66 |
+
const [copied, setCopied] = useState(false);
|
| 67 |
+
const script = useMemo(() => buildPodcastScript(text), [text]);
|
| 68 |
+
const inputWords = text.trim() ? text.trim().split(/\s+/).length : 0;
|
| 69 |
+
|
| 70 |
+
const copy = async () => {
|
| 71 |
+
if (!script) return;
|
| 72 |
+
await navigator.clipboard.writeText(scriptToText(script));
|
| 73 |
+
setCopied(true);
|
| 74 |
+
setTimeout(() => setCopied(false), 1500);
|
| 75 |
+
};
|
| 76 |
+
const download = () => {
|
| 77 |
+
if (!script) return;
|
| 78 |
+
const url = URL.createObjectURL(new Blob([scriptToText(script)], { type: "text/plain" }));
|
| 79 |
+
const a = document.createElement("a");
|
| 80 |
+
a.href = url;
|
| 81 |
+
a.download = `${tool.slug}-script.txt`;
|
| 82 |
+
a.click();
|
| 83 |
+
URL.revokeObjectURL(url);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<div className="tool-widget">
|
| 88 |
+
<label className="field">
|
| 89 |
+
<span>
|
| 90 |
+
{tool.inputLabel} <span className="muted mono small">{inputWords} words</span>
|
| 91 |
+
</span>
|
| 92 |
+
<textarea
|
| 93 |
+
className="input tool-input"
|
| 94 |
+
value={text}
|
| 95 |
+
onChange={(e) => setText(e.target.value)}
|
| 96 |
+
placeholder={tool.inputPlaceholder}
|
| 97 |
+
rows={10}
|
| 98 |
+
/>
|
| 99 |
+
</label>
|
| 100 |
+
{tool.inputHint && (
|
| 101 |
+
<p className="muted small">
|
| 102 |
+
<Inline text={tool.inputHint} />
|
| 103 |
+
</p>
|
| 104 |
+
)}
|
| 105 |
+
{text.trim() && !script && <p className="muted">Paste a little more: the script needs at least three full sentences.</p>}
|
| 106 |
+
{script && (
|
| 107 |
+
<div className="card tool-output">
|
| 108 |
+
<div className="tool-output-head">
|
| 109 |
+
<span className="mono small muted">
|
| 110 |
+
{script.lines.length} lines · about {Math.max(1, Math.round(script.minutes))} min · built from {script.sourceSentences} of your
|
| 111 |
+
sentences
|
| 112 |
+
</span>
|
| 113 |
+
<span className="tool-actions">
|
| 114 |
+
<button type="button" className="btn" onClick={copy}>
|
| 115 |
+
{copied ? "Copied" : "Copy"}
|
| 116 |
+
</button>
|
| 117 |
+
<button type="button" className="btn" onClick={download}>
|
| 118 |
+
Download
|
| 119 |
+
</button>
|
| 120 |
+
</span>
|
| 121 |
+
</div>
|
| 122 |
+
<ol className="tool-script">
|
| 123 |
+
{script.lines.map((l, i) => (
|
| 124 |
+
<li key={i} className={`host-${l.host}`}>
|
| 125 |
+
<span className="mono host-label">Host {l.host}</span>
|
| 126 |
+
<p>{l.text}</p>
|
| 127 |
+
</li>
|
| 128 |
+
))}
|
| 129 |
+
</ol>
|
| 130 |
+
<div className="tool-next">
|
| 131 |
+
<p className="muted">Hear it with two natural voices, grounded in your whole archive.</p>
|
| 132 |
+
<Link to="/app/studio" className="btn btn-primary">
|
| 133 |
+
Voice it in Studio
|
| 134 |
+
</Link>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
)}
|
| 138 |
+
</div>
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
export function ToolPage() {
|
| 143 |
+
const { slug = "" } = useParams();
|
| 144 |
+
const tool = getTool(slug);
|
| 145 |
+
const { user, loading } = useAuth();
|
| 146 |
+
useMeta(tool?.metaTitle ?? "Not found | Notestack", tool?.description ?? "");
|
| 147 |
+
|
| 148 |
+
if (!tool) {
|
| 149 |
+
return (
|
| 150 |
+
<div className="page">
|
| 151 |
+
<PublicNav />
|
| 152 |
+
<main className="container section">
|
| 153 |
+
<h1 className="section-title">Tool not found</h1>
|
| 154 |
+
<Link to="/tools">See all free tools</Link>
|
| 155 |
+
</main>
|
| 156 |
+
</div>
|
| 157 |
+
);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const faqLd = {
|
| 161 |
+
"@context": "https://schema.org",
|
| 162 |
+
"@type": "FAQPage",
|
| 163 |
+
mainEntity: tool.faq.map((f) => ({ "@type": "Question", name: f.question, acceptedAnswer: { "@type": "Answer", text: f.answer } })),
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
return (
|
| 167 |
+
<div className="page">
|
| 168 |
+
<SkyCanvas intensity={0.7} travel />
|
| 169 |
+
<PublicNav />
|
| 170 |
+
<main className="container post mission alt-page">
|
| 171 |
+
<script type="application/ld+json">{JSON.stringify(faqLd)}</script>
|
| 172 |
+
<header className="mission-head">
|
| 173 |
+
<p className="eyebrow">{tool.eyebrow}</p>
|
| 174 |
+
<h1 className="post-title">{tool.heroTitle}</h1>
|
| 175 |
+
<p className="post-lede muted">{tool.heroDescription}</p>
|
| 176 |
+
</header>
|
| 177 |
+
|
| 178 |
+
<section className="alt-section">{loading ? null : user ? <PodcastScriptWidget tool={tool} /> : <SignInGate tool={tool} />}</section>
|
| 179 |
+
|
| 180 |
+
{tool.sections.map((s) => (
|
| 181 |
+
<section key={s.heading} className="alt-section">
|
| 182 |
+
<h2>{s.heading}</h2>
|
| 183 |
+
{s.paragraphs.map((p, i) => (
|
| 184 |
+
<p key={i} className="muted">
|
| 185 |
+
<Inline text={p} />
|
| 186 |
+
</p>
|
| 187 |
+
))}
|
| 188 |
+
{s.bullets && (
|
| 189 |
+
<ul>
|
| 190 |
+
{s.bullets.map((b) => (
|
| 191 |
+
<li key={b}>{b}</li>
|
| 192 |
+
))}
|
| 193 |
+
</ul>
|
| 194 |
+
)}
|
| 195 |
+
</section>
|
| 196 |
+
))}
|
| 197 |
+
|
| 198 |
+
<section className="alt-section debrief">
|
| 199 |
+
<h2>Questions</h2>
|
| 200 |
+
{tool.faq.map((f) => (
|
| 201 |
+
<details key={f.question} className="faq">
|
| 202 |
+
<summary>{f.question}</summary>
|
| 203 |
+
<p className="muted">{f.answer}</p>
|
| 204 |
+
</details>
|
| 205 |
+
))}
|
| 206 |
+
</section>
|
| 207 |
+
|
| 208 |
+
<nav className="alt-section" aria-label="Related">
|
| 209 |
+
<h2>Keep exploring</h2>
|
| 210 |
+
<ul>
|
| 211 |
+
{tool.relatedPaths.map((p) => (
|
| 212 |
+
<li key={p}>
|
| 213 |
+
<Link to={p}>{pathTitle(p)}</Link>
|
| 214 |
+
</li>
|
| 215 |
+
))}
|
| 216 |
+
</ul>
|
| 217 |
+
</nav>
|
| 218 |
+
</main>
|
| 219 |
+
<PublicFooter />
|
| 220 |
+
</div>
|
| 221 |
+
);
|
| 222 |
+
}
|
frontend/src/styles/tools.css
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Free tools (/tools/*). Builds on blog.css (.alt-page, .alt-section, .alt-grid). */
|
| 2 |
+
|
| 3 |
+
.tool-card-title {
|
| 4 |
+
font-size: 1.15rem;
|
| 5 |
+
margin: 0 0 0.5rem;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.tool-gate {
|
| 9 |
+
text-align: center;
|
| 10 |
+
padding: 2rem 1.5rem;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.tool-gate h2 {
|
| 14 |
+
margin-top: 0;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.tool-gate .alt-ctas {
|
| 18 |
+
justify-content: center;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.tool-widget {
|
| 22 |
+
display: grid;
|
| 23 |
+
gap: 1rem;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.tool-input {
|
| 27 |
+
width: 100%;
|
| 28 |
+
min-height: 14rem;
|
| 29 |
+
resize: vertical;
|
| 30 |
+
font: inherit;
|
| 31 |
+
line-height: 1.6;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.tool-output {
|
| 35 |
+
display: grid;
|
| 36 |
+
gap: 1rem;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.tool-output-head {
|
| 40 |
+
display: flex;
|
| 41 |
+
flex-wrap: wrap;
|
| 42 |
+
gap: 0.75rem;
|
| 43 |
+
align-items: center;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.tool-actions {
|
| 48 |
+
display: flex;
|
| 49 |
+
gap: 0.5rem;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.tool-script {
|
| 53 |
+
list-style: none;
|
| 54 |
+
margin: 0;
|
| 55 |
+
padding: 0;
|
| 56 |
+
display: grid;
|
| 57 |
+
gap: 0.85rem;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.tool-script li {
|
| 61 |
+
display: grid;
|
| 62 |
+
grid-template-columns: 5.5rem 1fr;
|
| 63 |
+
gap: 0.75rem;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.tool-script p {
|
| 67 |
+
margin: 0;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.tool-script .host-B p {
|
| 71 |
+
opacity: 0.8;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.host-label {
|
| 75 |
+
font-size: 0.75rem;
|
| 76 |
+
opacity: 0.7;
|
| 77 |
+
padding-top: 0.2rem;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.tool-next {
|
| 81 |
+
display: flex;
|
| 82 |
+
flex-wrap: wrap;
|
| 83 |
+
gap: 1rem;
|
| 84 |
+
align-items: center;
|
| 85 |
+
justify-content: space-between;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
@media (max-width: 560px) {
|
| 89 |
+
.tool-script li {
|
| 90 |
+
grid-template-columns: 1fr;
|
| 91 |
+
gap: 0.2rem;
|
| 92 |
+
}
|
| 93 |
+
}
|
frontend/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/highlight.tsx","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/notebooklmalternative.ts","./src/content/seotypes.ts","./src/content/tools.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/highlight.tsx","./src/lib/podcastscript.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/tools.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|
frontend/vite.config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { defineConfig } from "vite";
|
| 2 |
import react from "@vitejs/plugin-react";
|
|
|
|
| 3 |
import { sitemapPlugin } from "./seo/sitemap";
|
| 4 |
|
| 5 |
// Public origin for sitemap.xml and robots.txt. Set SITE_URL in the deploy environment.
|
|
@@ -9,6 +10,8 @@ export default defineConfig({
|
|
| 9 |
plugins: [
|
| 10 |
react(),
|
| 11 |
sitemapPlugin(SITE_URL),
|
|
|
|
|
|
|
| 12 |
// Link preview tags need absolute URLs: fill %SITE_URL% in index.html.
|
| 13 |
{ name: "site-url", transformIndexHtml: (html) => html.replaceAll("%SITE_URL%", SITE_URL.replace(/\/$/, "")) },
|
| 14 |
],
|
|
|
|
| 1 |
import { defineConfig } from "vite";
|
| 2 |
import react from "@vitejs/plugin-react";
|
| 3 |
+
import { prerenderPlugin } from "./seo/prerender";
|
| 4 |
import { sitemapPlugin } from "./seo/sitemap";
|
| 5 |
|
| 6 |
// Public origin for sitemap.xml and robots.txt. Set SITE_URL in the deploy environment.
|
|
|
|
| 10 |
plugins: [
|
| 11 |
react(),
|
| 12 |
sitemapPlugin(SITE_URL),
|
| 13 |
+
// Static HTML per public route for crawlers that do not run JavaScript. Must run after site-url fills index.html.
|
| 14 |
+
prerenderPlugin(SITE_URL),
|
| 15 |
// Link preview tags need absolute URLs: fill %SITE_URL% in index.html.
|
| 16 |
{ name: "site-url", transformIndexHtml: (html) => html.replaceAll("%SITE_URL%", SITE_URL.replace(/\/$/, "")) },
|
| 17 |
],
|