/**
* The pages, rendered on the server: the first paint is the whole vault —
* your notes on the left, the note in the middle, what it links to and
* what links back on the right — and `static/app.js` makes it an editor
* afterwards (autosave, live preview, the quick switcher, the keys).
* Without JavaScript every page still reads, and the editor still saves.
*/
import { isAccount, type Account } from "./accounts.ts";
import { canDelete, type Note } from "./actions.ts";
import { commit, description, env, forgeUrl, publicOrigin, title, where } from "./env.ts";
import { asset, escape } from "./http.ts";
import { folderOf, spaceHref, type Member, type Space } from "./spaces.ts";
import { unsynced } from "./sync.ts";
import { token, tokensHref } from "./tokens.ts";
import {
backlinks,
excerpt,
graph,
headings,
hrefOf,
outgoing,
renderNote,
tagCounts,
tags,
type Hit,
} from "./vault.ts";
import type { Who } from "./who.ts";
/** Data for the browser, inside a page: JSON that can never close its `;
const CHANGES = "note.added note.removed note.changed space.changed space.synced";
/**
* The frame every page shares: the tokens and the look, and what a link
* to the page says when it is shared — a title, a sentence and the
* address, for Open Graph and the card a chat app unfurls.
*/
export function layout(
body: string,
path = "/",
options: { name?: string; kind?: string; scripts?: string[] } = {},
): string {
const name = options.name === undefined ? title : `${options.name} · ${title}`;
return `
${escape(name)}
${(options.scripts ?? []).map((s) => ``).join("\n")}
${body}
`;
}
/** Signed out: what this is, and the ways in. */
export function landing(notice?: string, to = "/"): string {
const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`;
return layout(
`
${glyph}
${escape(title)}
A private vault for your thinking. Markdown notes, linked with [[double brackets]], with backlinks, tags and a graph of how it all connects.
Your notes are yours alone — nobody else who uses this app can read them.
${toast(notice)}
`,
"/",
{ kind: "page landing-page" },
);
}
/** Making an account, or logging in to one. */
export function authPage(input: {
mode: "signup" | "login";
notice?: string | undefined;
email?: string;
name?: string;
needsCode: boolean;
/** Where to land afterwards: a join link, when that is what brought them. */
to?: string;
}): string {
const signup = input.mode === "signup";
const to = input.to ?? "/";
const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`;
return layout(
`
Every note you write here is markdown, saved as you type, and private to you.
`;
return layout(shell(s, center), "/", { kind: "page app-page", scripts: ["app.js"] });
}
/** A link to a note that isn't written yet. */
export function missingPage(s: Shell & { title: string }): string {
const base = baseOf(s);
const center = `${toolbar(s, `${escape(vaultName(s))}/${escape(s.title)}`)}
“${escape(s.title)}” doesn't exist yet
A link points here, but nothing's been written.
`;
return layout(shell(s, center), `${base}/open`, { name: s.title, kind: "page app-page", scripts: ["app.js"] });
}
/** What a search found. */
export function searchPage(s: Shell & { query: string; hits: readonly Hit[] }): string {
const words = s.query
.toLowerCase()
.match(/"[^"]+"|\S+/g)
?.map((w) => w.replace(/^"|"$/g, ""))
.filter((w) => !/^(#|tag:)/.test(w)) ?? [];
const mark = (text: string): string => {
let html = escape(text);
for (const w of words) {
const e = escape(w).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (e !== "") html = html.replace(new RegExp(`(${e})`, "gi"), "$1");
}
return html;
};
const center = `${toolbar(s, `${escape(vaultName(s))}/Search`)}
${s.hits.length} ${s.hits.length === 1 ? "note" : "notes"} for ${escape(s.query)}
`;
return layout(shell(s, center), "/account", { name: "Account", kind: "page app-page", scripts: ["app.js"] });
}
/** A shared note's file: where it is, who wrote last, whether the repository has it yet, and moving it. */
function filepanel(space: Space, note: Note & { path: string }): string {
return `
File
${escape(note.path)}
Last written by ${escape(note.author_name)} · ${unsynced(note) ? "waiting to commit" : "committed"}
`;
}
/** A shared folder with nothing in it yet. */
function emptySpace(s: Shell, space: Space): string {
const base = spaceHref(space.id);
const center = `${toolbar(s, `${escape(space.name)}`)}
${icon.folder}
${escape(space.name)} is empty
Notes written here are committed to ${escape(space.repo)} in ${escape(space.folder || "/")} — and markdown pushed there shows up here.
${space.synced_at === null ? "Not synced yet." : `Synced ${ago(space.synced_at)}${ago(space.synced_at) === "now" ? "" : " ago"}${space.head === null ? "" : ` at ${escape(space.head.slice(0, 7))}`}.`} Edits commit a few seconds after you stop typing; pushes to the repository show up within a minute.
Anyone signed in to notes who opens this joins ${escape(space.name)} — and sees only this folder, never your own notes. Send it to who you're working with, then turn it off.
A folder of a repository on the forge becomes a vault you write in with others. Every note is a markdown file there; edits here are commits, and pushes there show up here. Use a plain repository, not an app's — every save is a commit to the branch.
`;
return layout(shell(s, center), "/spaces/new", { name: "Share a repo folder", kind: "page app-page", scripts: ["app.js"] });
}
/** The graph's data, for any page that draws one. */
export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES);
const words = (body: string): number => body.split(/\s+/).filter((w) => /\w/.test(w)).length;
const long = (iso: string): string =>
new Date(iso).toLocaleDateString("en", { year: "numeric", month: "short", day: "numeric" });
export const ago = (iso: string): string => {
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
if (s < 60) return "now";
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`;
return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" });
};