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
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "readm3-desktop",
"productName": "readm3",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.",
"author": "Profullstack, Inc. <hello@profullstack.com>",
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/readm3",
"version": "0.13.0",
"version": "0.13.1",
"description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -43,7 +43,6 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@profullstack/footer": "latest",
"@profullstack/hqtui": "^0.5.0",
"@profullstack/synconfig": "^0.1.3",
"highlight.js": "^11.12.0",
Expand Down
2 changes: 1 addition & 1 deletion site/assets/account.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ <h2>Your account.</h2>
<noscript>JavaScript is needed to sign in. You can still <a href="/docs">read the docs</a>.</noscript>
</section>
</main>
<footer><span>readm3 / a little room for your words.</span><a href="/docs">Documentation</a></footer>
<footer><span>&copy; readm3</span><a href="/docs">Documentation</a></footer>
</body>
</html>
6 changes: 4 additions & 2 deletions site/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,10 @@ pre.tree { color: var(--fg); }

/* ---------------------------------------------------------------- foot -- */

/* More specific than the footer package's own .pfs-footer rules, which come later in the page. */
footer.pfs-footer { --pfs-footer-muted: var(--page-dim); border-top-color: var(--page-line); }
.site-foot { max-width: 72rem; margin: 0 auto; padding: 1.25rem 1rem; border-top: 1px solid var(--page-line); display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1.25rem; color: var(--page-dim); font-size: 0.85rem; }
.site-foot nav { display: flex; flex-wrap: wrap; gap: 0.4rem 1.1rem; }
.site-foot a { color: var(--page-dim); text-decoration: none; }
.site-foot a:hover { color: var(--page-fg); text-decoration: underline; }

/* --------------------------------------------------------------- narrow -- */

Expand Down
21 changes: 4 additions & 17 deletions site/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { createHash } from "node:crypto";
import { flatten, type Entry } from "../src/tree.ts";
import { VERSION } from "../src/cli.ts";
import { LANGUAGES } from "../src/code.ts";
import { footerHtml } from "@profullstack/footer";

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");
Expand All @@ -31,22 +30,11 @@ const CP_PROJECT = "528372c9-c79a-425f-9f80-f9fe44c5865e";
const CP_SLOT = "b918cc2d-4bd9-45e4-9b9e-52955b4e2e54";

/*
* The shared Profullstack footer (@profullstack/footer): links, copyright and the
* webring. Baked into every page here as the fallback, and re-rendered from the
* package's @latest template on each request by site/server.ts (from the
* options written to dist/pfs-footer.json), so a package release reaches the
* site without a rebuild. Public pages send no CSP, so its inline <style> is fine.
* The site's own footer: one line. The network links (search, ring, publish)
* come from the pit.world global footer, which site/server.ts appends to every
* page at request time.
*/
const FOOTER_OPTIONS = {
site: "https://readm3.com/",
links: [
{ label: "Source", href: REPO },
{ label: "npm", href: NPM },
{ label: "HQTUI", href: HQTUI },
],
tagline: `MIT licensed · v${VERSION}`,
};
const FOOTER = await footerHtml(FOOTER_OPTIONS);
const FOOTER = `<footer class="site-foot"><span>&copy; ${new Date().getFullYear()} readm3 &middot; MIT</span><nav aria-label="Footer"><a href="/docs">Docs</a><a href="/sharing">Sharing &amp; API</a><a href="${REPO}">Source</a><a href="${NPM}">npm</a></nav></footer>`;

/* -------------------------------------------------------------- colors --- */

Expand Down Expand Up @@ -497,7 +485,6 @@ write("robots.txt", robots);
write("sitemap.xml", sitemap);
write("llms.txt", llms);
write("favicon.svg", favicon);
write("pfs-footer.json", JSON.stringify(FOOTER_OPTIONS));
write(".well-known/openwebring.json", JSON.stringify({
openwebring: "0.1",
site: { url: "https://readm3.com/", name: "readm3" },
Expand Down
60 changes: 37 additions & 23 deletions site/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Public pages stay static. Account APIs use verified email identities and
* sessions persisted on the mounted database volume.
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { existsSync, statSync } from "node:fs";
import { dirname, join, normalize, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { Accounts } from "../server/accounts.ts";
Expand All @@ -15,7 +15,6 @@ import { anthropicSummarizer, Billing, DEFAULT_MODEL, type Summarize } from "../
import { Payments, paymentOptions, type PaymentOptions } from "../server/payments.ts";
import { CoinPay } from "../server/coinpay.ts";
import { createSyncApi } from "../server/sync-api.ts";
import { footerHtml } from "@profullstack/footer";

const dist = join(dirname(fileURLToPath(import.meta.url)), "dist");
const port = Number(process.env.PORT ?? 3000);
Expand All @@ -40,30 +39,43 @@ function resolve(pathname: string): string | null {
}

/*
* The shared Profullstack footer (@profullstack/footer). site/build.ts bakes it
* into each page and writes its options to dist/pfs-footer.json; here the baked
* copy is swapped for one rendered from the package's @latest template (cached
* an hour inside the package), so a package release reaches readm3.com without
* a rebuild. Any failure serves the baked page unchanged.
* The pit.world global footer (site name, search, ring, publish), appended as
* the last thing in <body> of every site page. Fetched from pit.world with a 3s
* timeout and cached an hour at module level; a failed refresh keeps serving the
* last good copy, and with no copy the page is served unchanged.
*/
const footerOptionsFile = join(dist, "pfs-footer.json");
const footerOptions = existsSync(footerOptionsFile)
? (JSON.parse(readFileSync(footerOptionsFile, "utf8")) as Parameters<typeof footerHtml>[0])
: null;
const BAKED_FOOTER = /<footer class="pfs-footer"[\s\S]*?<\/footer>/;
const PIT_FOOTER_URL = "https://pit.world/snippets/footer.html?site=readm3.com";
const PIT_FOOTER_TTL = 60 * 60 * 1000;
const pitFooter = { html: "", fetchedAt: 0, inflight: null as Promise<void> | null };

function refreshPitFooter(): Promise<void> {
pitFooter.inflight ??= fetch(PIT_FOOTER_URL, { signal: AbortSignal.timeout(3000) })
.then(async (res) => {
const text = res.ok ? await res.text() : "";
if (!text.includes("data-pit-footer")) throw new Error(`pit footer: HTTP ${res.status}`);
pitFooter.html = text;
pitFooter.fetchedAt = Date.now();
})
.catch(() => { pitFooter.fetchedAt = Date.now() - PIT_FOOTER_TTL + 60_000; }) // retry in a minute
.finally(() => { pitFooter.inflight = null; });
return pitFooter.inflight;
}

async function pitFooterHtml(): Promise<string> {
if (!pitFooter.html) await refreshPitFooter();
else if (Date.now() - pitFooter.fetchedAt > PIT_FOOTER_TTL) void refreshPitFooter();
return pitFooter.html;
}

async function pageBody(file: string): Promise<{ body: BodyInit; type?: string }> {
async function pageBody(file: string, withPitFooter: boolean): Promise<{ body: BodyInit; type?: string }> {
// The curl installer reads as text in a browser instead of downloading.
if (file.endsWith(".sh")) return { body: Bun.file(file), type: "text/plain; charset=utf-8" };
if (!footerOptions || !file.endsWith(".html")) return { body: Bun.file(file) };
if (!withPitFooter || !file.endsWith(".html")) return { body: Bun.file(file) };
const footer = await pitFooterHtml();
const html = await Bun.file(file).text();
if (!BAKED_FOOTER.test(html)) return { body: Bun.file(file) };
try {
const footer = await footerHtml(footerOptions);
return { body: html.replace(BAKED_FOOTER, () => footer), type: "text/html; charset=utf-8" };
} catch {
return { body: Bun.file(file) };
}
const at = html.lastIndexOf("</body>");
if (!footer || at < 0 || html.includes("data-pit-footer")) return { body: Bun.file(file) };
return { body: html.slice(0, at) + footer + html.slice(at), type: "text/html; charset=utf-8" };
}

function cacheFor(path: string): string {
Expand Down Expand Up @@ -148,7 +160,9 @@ export function serveSite(options: { accounts?: Accounts; coinpay?: ConstructorP

const accountPage = file === join(dist, "account", "index.html") || url.pathname === "/admin";
const privatePage = accountPage || sharedPage || url.pathname === "/viewer";
const page = privatePage ? { body: Bun.file(file) } : await pageBody(file);
// The editor, workspaces and shared documents are full-screen app shells; every other page gets the global footer.
const appShell = sharedPage || url.pathname === "/viewer" || url.pathname === "/admin";
const page = await pageBody(file, !appShell);
return new Response(page.body, {
headers: {
...(page.type ? { "content-type": page.type } : {}),
Expand All @@ -157,7 +171,7 @@ export function serveSite(options: { accounts?: Accounts; coinpay?: ConstructorP
"referrer-policy": privatePage ? "no-referrer" : "strict-origin-when-cross-origin",
...(sharedPage ? { "x-robots-tag": "noindex, nofollow" } : {}),
...(accountPage ? {
"content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
"content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
"x-frame-options": "DENY",
} : {}),
},
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { run, type ViewerOptions } from "./viewer.ts";
import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts";
import { isDesktopCommand, desktopMain, DESKTOP_USAGE } from "./desktop-cli.ts";

export const VERSION = "0.13.0";
export const VERSION = "0.13.1";

/** The help text, exported so a test can hold it to the flags it documents. */
export const USAGE = `readm3 — a terminal markdown reader and editor
Expand Down
Loading