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
54 changes: 11 additions & 43 deletions app/o/[slug]/d/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import * as schema from "@/lib/schema";
import { accessUrl, requireMember } from "@/lib/access";
import { getDict } from "@/lib/i18n";
import { arg, navScript, type NavLabels } from "@/lib/doc-nav";

// Serves a document's HTML with the pensieve nav (pill + backlinks) injected.
// Serves a document's HTML with the pensieve nav (toolbar + backlinks) injected.
export async function GET(req: Request, ctx: { params: Promise<{ slug: string; path: string[] }> }) {
const embed = new URL(req.url).searchParams.get("embed") === "1";
const { slug, path } = await ctx.params;
Expand All @@ -28,51 +30,17 @@ export async function GET(req: Request, ctx: { params: Promise<{ slug: string; p
eq(schema.document.organizationId, access.org.id), eq(schema.document.path, docPath))).limit(1);
if (!rows.length) return new Response("not found", { status: 404 });

const nav = `<script>(${navScript.toString()})(${JSON.stringify(slug)},${JSON.stringify(docPath)},${embed})</script>`;
const { t } = await getDict();
const ui: NavLabels = {
workspace: access.org.name,
open: t.doc.openInWorkspace,
openHint: t.doc.openInWorkspaceHint,
homeHint: t.doc.workspaceHomeHint,
};
const nav = `<script>(${navScript.toString()})(${arg(slug)},${arg(docPath)},${embed},${arg(ui)})</script>`;
let html = rows[0].html;
// in-workspace links resolve through the org prefix
html = html.replaceAll(/href="(\/[^"#?]+?)(?:\.(?:html|md))?"/g, (_m, p) => `href="/o/${slug}/d${p}"`);
html = html.includes("</body>") ? html.replace("</body>", nav + "</body>") : html + nav;
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
}

function navScript(slug: string, here: string, embed: boolean) {
(async () => {
let g: { nodes: { id: string; title: string }[]; edges: { from: string; to: string }[] };
try { g = await (await fetch(`/api/graph?org=${slug}`)).json(); } catch { return; }
const byId: Record<string, { id: string; title: string }> = {};
g.nodes.forEach((n) => (byId[n.id] = n));
const outs = g.edges.filter((e) => e.from === here && byId[e.to]).map((e) => byId[e.to]);
const ins = g.edges.filter((e) => e.to === here && byId[e.from]).map((e) => byId[e.from]);
const st = document.createElement("style");
st.textContent = ":root{scrollbar-width:thin} ::-webkit-scrollbar{width:8px;height:8px} ::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:rgba(127,138,148,.5);border-radius:8px} .pnsv-pill{position:fixed;top:14px;left:14px;z-index:9999;font:11px/1 ui-monospace,Menlo,monospace;letter-spacing:.1em;text-transform:uppercase;text-decoration:none;padding:7px 12px;border-radius:999px;background:rgba(20,26,27,.82);color:#F1F2F0;opacity:.55;transition:opacity .15s}.pnsv-pill:hover{opacity:1}@media (prefers-color-scheme:dark){.pnsv-pill{background:rgba(228,232,231,.14);color:#E4E8E7}}.pnsv-links{max-width:900px;margin:48px auto 0;padding:20px 24px 8px;border-top:1px solid rgba(127,138,137,.35);font-family:-apple-system,'PingFang TC',sans-serif}.pnsv-links h4{font:10.5px/1 ui-monospace,Menlo,monospace;letter-spacing:.14em;text-transform:uppercase;opacity:.55;margin:14px 0 8px}.pnsv-links a{display:inline-block;margin:0 10px 8px 0;font-size:13.5px;text-decoration:none;color:inherit;opacity:.8;border-bottom:1px solid rgba(127,138,137,.5)}.pnsv-links a:hover{opacity:1}";
document.head.appendChild(st);
if (embed) {
document.addEventListener("click", (ev) => {
const t = ev.target as HTMLElement | null;
const a = t?.closest?.(`a[href^="/o/"]`) as HTMLAnchorElement | null;
// same-origin iframe: address the workspace shell explicitly, never "*"
if (a) { ev.preventDefault(); parent.postMessage({ pnsvOpen: a.getAttribute("href") }, location.origin); }
});
// keystrokes inside the iframe never reach the workspace shell — forward ⌘K
document.addEventListener("keydown", (ev) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") {
ev.preventDefault(); parent.postMessage({ pnsvPalette: true }, location.origin);
}
});
}
if (!embed) {
const pill = document.createElement("a");
pill.className = "pnsv-pill"; pill.href = `/o/${slug}`; pill.textContent = "◉ pensieve";
document.body.appendChild(pill);
}
if (outs.length || ins.length) {
const box = document.createElement("div");
box.className = "pnsv-links";
const link = (n: { id: string; title: string }) => `<a href="/o/${slug}/d${n.id}">${n.title}</a>`;
box.innerHTML = (outs.length ? "<h4>links to</h4>" + outs.map(link).join("") : "")
+ (ins.length ? "<h4>linked from</h4>" + ins.map(link).join("") : "");
document.body.appendChild(box);
}
})();
}
68 changes: 68 additions & 0 deletions lib/doc-nav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* The script injected into a member's view of a document: the workspace
* toolbar when the page is opened on its own, backlinks, and the ⌘K / link
* forwarding when it is embedded in the workspace shell. Serialised with
* toString() into the document, so it must stay self-contained.
*/
export type NavLabels = { workspace: string; open: string; openHint: string; homeHint: string };

/** JSON for an inline <script>: a workspace or path containing "</script>" must not end it early. */
export const arg = (v: unknown) => JSON.stringify(v).replaceAll("<", String.raw`\u003c`);

export function navScript(slug: string, here: string, embed: boolean, ui: NavLabels) {
(async () => {
const st = document.createElement("style");
st.textContent = ":root{scrollbar-width:thin} ::-webkit-scrollbar{width:8px;height:8px} ::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:rgba(127,138,148,.5);border-radius:8px} .pnsv-bar{position:fixed;bottom:16px;left:16px;z-index:9999;display:flex;align-items:stretch;max-width:calc(100vw - 32px);font:12.5px/1 -apple-system,BlinkMacSystemFont,'PingFang TC','Noto Sans TC',sans-serif;border-radius:999px;background:rgba(20,26,27,.88);color:#F1F2F0;box-shadow:0 2px 12px rgba(0,0,0,.18);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);overflow:hidden}.pnsv-bar a{color:inherit;text-decoration:none;display:flex;align-items:center;gap:8px;padding:8px 13px;min-width:0;transition:background .15s}.pnsv-bar a:hover{background:rgba(255,255,255,.12)}.pnsv-bar a:focus-visible{outline:2px solid #7FA8F0;outline-offset:-2px}.pnsv-bar .pnsv-dot{width:7px;height:7px;border-radius:50%;background:#7FA8F0;box-shadow:0 0 0 3px rgba(127,168,240,.22);flex:none}.pnsv-bar .pnsv-ws{font:11px/1 ui-monospace,Menlo,monospace;letter-spacing:.06em;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pnsv-bar .pnsv-sep{width:1px;background:rgba(255,255,255,.18);flex:none}.pnsv-bar svg{width:14px;height:14px;flex:none}@media (prefers-color-scheme:dark){.pnsv-bar{background:rgba(40,48,52,.9);color:#E4E8E7}}@media (max-width:560px){.pnsv-bar .pnsv-ws{display:none}}@media print{.pnsv-bar{display:none}} .pnsv-links{max-width:900px;margin:48px auto 0;padding:20px 24px 8px;border-top:1px solid rgba(127,138,137,.35);font-family:-apple-system,'PingFang TC',sans-serif}.pnsv-links h4{font:10.5px/1 ui-monospace,Menlo,monospace;letter-spacing:.14em;text-transform:uppercase;opacity:.55;margin:14px 0 8px}.pnsv-links a{display:inline-block;margin:0 10px 8px 0;font-size:13.5px;text-decoration:none;color:inherit;opacity:.8;border-bottom:1px solid rgba(127,138,137,.5)}.pnsv-links a:hover{opacity:1}";
document.head.appendChild(st);
// Opened on its own (a shared link, a bookmark): give the reader a way into
// the workspace — the shell deep-links a doc through the #/path hash. Built
// before the graph fetch so it shows even when that request fails. Bottom-left
// because every doc starts with a masthead in the top padding.
if (!embed) {
const bar = document.createElement("nav");
bar.className = "pnsv-bar";
bar.setAttribute("aria-label", "Pensieve");
const home = document.createElement("a");
home.href = `/o/${slug}`; home.title = ui.homeHint;
const dot = document.createElement("span"); dot.className = "pnsv-dot";
const ws = document.createElement("span"); ws.className = "pnsv-ws"; ws.textContent = ui.workspace;
home.append(dot, ws);
const sep = document.createElement("span"); sep.className = "pnsv-sep";
const open = document.createElement("a");
open.href = `/o/${slug}#${here}`; open.title = ui.openHint;
// lucide "panel-left", inline so the doc page needs nothing from the app bundle
open.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>';
open.append(document.createTextNode(ui.open));
bar.append(home, sep, open);
document.body.appendChild(bar);
}
let g: { nodes: { id: string; title: string }[]; edges: { from: string; to: string }[] };
try { g = await (await fetch(`/api/graph?org=${slug}`)).json(); } catch { return; }
const byId: Record<string, { id: string; title: string }> = {};
g.nodes.forEach((n) => (byId[n.id] = n));
const outs = g.edges.filter((e) => e.from === here && byId[e.to]).map((e) => byId[e.to]);
const ins = g.edges.filter((e) => e.to === here && byId[e.from]).map((e) => byId[e.from]);
if (embed) {
document.addEventListener("click", (ev) => {
const t = ev.target as HTMLElement | null;
const a = t?.closest?.(`a[href^="/o/"]`) as HTMLAnchorElement | null;
// same-origin iframe: address the workspace shell explicitly, never "*"
if (a) { ev.preventDefault(); parent.postMessage({ pnsvOpen: a.getAttribute("href") }, location.origin); }
});
// keystrokes inside the iframe never reach the workspace shell — forward ⌘K
document.addEventListener("keydown", (ev) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") {
ev.preventDefault(); parent.postMessage({ pnsvPalette: true }, location.origin);
}
});
}
if (outs.length || ins.length) {
const box = document.createElement("div");
box.className = "pnsv-links";
const link = (n: { id: string; title: string }) => `<a href="/o/${slug}/d${n.id}">${n.title}</a>`;
box.innerHTML = (outs.length ? "<h4>links to</h4>" + outs.map(link).join("") : "")
+ (ins.length ? "<h4>linked from</h4>" + ins.map(link).join("") : "");
document.body.appendChild(box);
}
})();
}
10 changes: 10 additions & 0 deletions lib/i18n-dict.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const zh = {
methods: {
google: "使用 Google 登入",
},
doc: {
openInWorkspace: "在 workspace 中開啟",
openInWorkspaceHint: "帶著側欄與關聯圖瀏覽這份文件",
workspaceHomeHint: "回到 workspace 首頁",
},
home: {
metaTitle: "登入 · Pensieve",
ctx: "登入",
Expand Down Expand Up @@ -113,6 +118,11 @@ const en: Dict = {
methods: {
google: "Sign in with Google",
},
doc: {
openInWorkspace: "Open in workspace",
openInWorkspaceHint: "Browse this document with the sidebar and graph",
workspaceHomeHint: "Back to the workspace home",
},
home: {
metaTitle: "Sign in · Pensieve",
ctx: "Sign in",
Expand Down
Loading