Add connected pages: hierarchy, backlinks, and live embeds
release gate / check (push) Failing after 7m7s

Implement Notion-like upgrade phase 3: owner-scoped nested pages with cycle rejection and child promotion on delete; Markdown-derived internal links and backlinks; live collection embeds resolved in the content-save transaction. Add migration 008, API/docs, integration coverage, and page/sidebar UI.
This commit is contained in:
Andrew S Erwin
2026-10-19 22:37:21 -05:00
parent b9721ff13f
commit 9db69efe4b
12 changed files with 938 additions and 28 deletions
+196
View File
@@ -0,0 +1,196 @@
package handlers
import (
"context"
"database/sql"
"net/url"
"regexp"
"strings"
"github.com/andrew/go-computer/internal/db"
)
// Connected pages (Notion-like upgrades phase 3).
//
// Page hierarchy, internal links and embedded collection views are all
// expressed in the page's Markdown, which stays the single source of truth:
//
// - [Any label](computer:page/<id>) links to another page. /pages?id=<id>
// and page/<id> are accepted as aliases so hand-written links work too.
// - {{collection:<id>}} embeds a live collection view; an optional
// {{collection:<id>|view=<viewId>}} pins a saved view's filters/sorts.
//
// On every content save the resolved PageLink and PageEmbed rows are rebuilt
// inside the same transaction. Only targets owned by the author are stored:
// backlinks therefore cannot enumerate or reveal titles from another account.
// Embedding displays data through the normal collection endpoints, so the
// underlying bookmark-visibility checks still apply and nothing is published.
var (
markdownLinkRE = regexp.MustCompile(`\[[^\]]*\]\(\s*<?([^)\s>]+)(?:\s+"[^"]*")?\s*>?\)`)
pageEmbedRE = regexp.MustCompile(`\{\{\s*collection:([A-Za-z0-9_-]{1,128})(?:\s*\|\s*view=([A-Za-z0-9_-]{1,128}))?\s*\}\}`)
pageRefRE = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
)
// internalPageID normalizes a Markdown link target to a page id. It returns
// false for ordinary web links and malformed ids.
func internalPageID(href string) (string, bool) {
href = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(href, "<"), ">"))
raw := ""
switch {
case strings.HasPrefix(href, "computer://page/"):
raw = strings.TrimPrefix(href, "computer://page/")
case strings.HasPrefix(href, "computer:page/"):
raw = strings.TrimPrefix(href, "computer:page/")
case strings.HasPrefix(href, "page/"):
raw = strings.TrimPrefix(href, "page/")
case strings.HasPrefix(href, "/pages/"):
raw = strings.TrimPrefix(href, "/pages/")
case strings.HasPrefix(href, "/pages?"):
if u, err := url.Parse(href); err == nil {
raw = u.Query().Get("id")
}
case strings.HasPrefix(href, "computer:page?id="):
if u, err := url.Parse(href); err == nil {
raw = u.Query().Get("id")
}
case strings.HasPrefix(href, "computer://page?id="):
if u, err := url.Parse(href); err == nil {
raw = u.Query().Get("id")
}
}
raw = strings.TrimSuffix(strings.TrimSpace(raw), "/")
if !pageRefRE.MatchString(raw) {
return "", false
}
return raw, true
}
type pageEmbedRef struct {
collectionID string
viewID string
}
// pageGraph extracts the distinct page links and embeds from Markdown.
func pageGraph(content, selfID string) ([]string, []pageEmbedRef) {
links := make([]string, 0)
seenLinks := map[string]bool{}
for _, m := range markdownLinkRE.FindAllStringSubmatchIndex(content, -1) {
// Skip images: ![alt](target) is not an internal link.
if m[0] > 0 && content[m[0]-1] == '!' {
continue
}
id, ok := internalPageID(content[m[2]:m[3]])
if !ok || id == selfID || seenLinks[id] {
continue
}
seenLinks[id] = true
links = append(links, id)
}
embeds := make([]pageEmbedRef, 0)
seenEmbeds := map[string]bool{}
for _, m := range pageEmbedRE.FindAllStringSubmatch(content, -1) {
ref := pageEmbedRef{collectionID: m[1], viewID: m[2]}
if seenEmbeds[ref.collectionID] {
continue
}
seenEmbeds[ref.collectionID] = true
embeds = append(embeds, ref)
}
return links, embeds
}
// syncPageGraph replaces one page's resolved links and embeds. It runs inside
// the content-save transaction, so the derived rows and the Markdown commit
// together (a failed save never leaves stale backlinks).
func syncPageGraph(ctx context.Context, tx *sql.Tx, pageID, uid, content string) error {
links, embeds := pageGraph(content, pageID)
if _, err := tx.ExecContext(ctx, `DELETE FROM "PageLink" WHERE "sourcePageId"=$1`, pageID); err != nil {
return err
}
for _, target := range links {
var visible bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "Page" WHERE id=$1 AND "ownerId"=$2)`, target, uid).Scan(&visible); err != nil {
return err
}
// A foreign/missing target is ignored rather than stored. The link
// text remains in the author's Markdown, but it can never become a
// cross-account backlink.
if !visible {
continue
}
if _, err := tx.ExecContext(ctx, `INSERT INTO "PageLink"("sourcePageId","targetPageId") VALUES($1,$2) ON CONFLICT DO NOTHING`, pageID, target); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM "PageEmbed" WHERE "pageId"=$1`, pageID); err != nil {
return err
}
for i, ref := range embeds {
var visible bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "Collection" WHERE id=$1 AND "ownerId"=$2)`, ref.collectionID, uid).Scan(&visible); err != nil {
return err
}
if !visible {
continue
}
viewID := any(nil)
if ref.viewID != "" {
var valid bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "CollectionView" WHERE id=$1 AND "collectionId"=$2)`, ref.viewID, ref.collectionID).Scan(&valid); err != nil {
return err
}
// An unknown view falls back to the collection's default view
// instead of dropping the embed entirely.
if valid {
viewID = ref.viewID
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO "PageEmbed"("pageId","collectionId","viewId",position) VALUES($1,$2,$3,$4)`, pageID, ref.collectionID, viewID, i); err != nil {
return err
}
}
return nil
}
// pageParent validates a requested parent and rejects cycles. The walk stays
// within the owner's pages and is bounded by the seen set, so a corrupt
// pre-existing cycle cannot loop forever.
func pageParent(ctx context.Context, q db.Queryer, pageID, uid string, raw any) (any, error) {
if raw == nil {
return nil, nil
}
parent, ok := raw.(string)
if !ok {
return nil, bad("parentId must be a page id or null")
}
parent = strings.TrimSpace(parent)
if parent == "" {
return nil, nil
}
if !pageRefRE.MatchString(parent) {
return nil, bad("Invalid parentId")
}
if parent == pageID {
return nil, bad("A page cannot be its own parent")
}
seen := map[string]bool{pageID: true}
for current := parent; current != ""; {
if seen[current] {
return nil, bad("Page hierarchy cannot contain a cycle")
}
seen[current] = true
var next sql.NullString
err := q.QueryRowContext(ctx, `SELECT "parentId" FROM "Page" WHERE id=$1 AND "ownerId"=$2`, current, uid).Scan(&next)
if err == sql.ErrNoRows {
return nil, bad("Parent page not found")
}
if err != nil {
return nil, err
}
current = next.String
}
return parent, nil
}
+69 -5
View File
@@ -18,7 +18,23 @@ import (
func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) {
// Linked bookmarks use the viewer projection (review PRIV-02): a page can
// link bookmarks shared with its owner, and those arrive narrowed.
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object('bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb)) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
//
// Connected pages (phase 3): backlinks and embeds are owner-scoped in the
// query itself, not trusted from the caller. `links` is the resolved
// outgoing edge set (useful for navigation); the Markdown remains the
// source of truth and is rebuilt by syncPageGraph on every content save.
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object(
'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb),
'backlinks',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") ORDER BY s."updatedAt" DESC,s.id) FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=p.id AND s."ownerId"=p."ownerId"),'[]'::jsonb),
'links',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'title',t.title) ORDER BY t.title,t.id) FROM "PageLink" pl JOIN "Page" t ON t.id=pl."targetPageId" WHERE pl."sourcePageId"=p.id AND t."ownerId"=p."ownerId"),'[]'::jsonb),
'embeds',COALESCE((SELECT jsonb_agg(jsonb_build_object(
'collectionId',e."collectionId",'viewId',e."viewId",'position',e.position,
'collection',to_jsonb(c)||jsonb_build_object(
'properties',COALESCE((SELECT jsonb_agg(to_jsonb(pr) ORDER BY pr.position,pr.id) FROM "CollectionProperty" pr WHERE pr."collectionId"=c.id),'[]'::jsonb),
'views',COALESCE((SELECT jsonb_agg(to_jsonb(v) ORDER BY v.position,v.id) FROM "CollectionView" v WHERE v."collectionId"=c.id),'[]'::jsonb)),
'view',(SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=e."viewId"))
ORDER BY e.position,e."collectionId") FROM "PageEmbed" e JOIN "Collection" c ON c.id=e."collectionId" WHERE e."pageId"=p.id AND c."ownerId"=p."ownerId"),'[]'::jsonb)
) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
}
func page(r *http.Request, id string) (object, error) {
return pageView(r.Context(), database(r), id, user(r))
@@ -31,12 +47,15 @@ func HandlePagesList(w http.ResponseWriter, r *http.Request) {
// literal characters, not patterns. The pg_trgm indexes on title/content
// let Postgres use the same ILIKE predicate without scanning every page.
search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%"
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object(
'bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id),
'children',(SELECT count(*) FROM "Page" ch WHERE ch."parentId"=p.id AND ch."ownerId"=p."ownerId"))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
respond(w, 200, v, e)
}
func pageData(b object) (object, error) {
d := fields(b, "title content icon pinned")
if e := stringFields(d, "icon", "title content"); e != nil {
d := fields(b, "title content icon pinned parentId")
// parentId is nullable: an explicit null promotes the page to the root.
if e := stringFields(d, "icon parentId", "title content"); e != nil {
return nil, e
}
if e := boolFields(d, "pinned"); e != nil {
@@ -80,6 +99,13 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
if e := lockUser(r, tx); e != nil {
return e
}
if raw, ok := d["parentId"]; ok {
parent, e := pageParent(r.Context(), tx, d["id"].(string), user(r), raw)
if e != nil {
return e
}
d["parentId"] = parent
}
var n int
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil {
return e
@@ -87,6 +113,17 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
d["position"] = n
var e error
v, e = db.Insert(r.Context(), tx, "Page", d)
if e != nil {
return e
}
if content, ok := d["content"].(string); ok {
if e := syncPageGraph(r.Context(), tx, d["id"].(string), user(r), content); e != nil {
return e
}
}
// Acknowledge the same snapshot the write produced (the update path's
// CR-18 discipline), so the 201 already carries links/embeds.
v, e = pageView(r.Context(), tx, d["id"].(string), user(r))
return e
})
respond(w, 201, v, e)
@@ -162,13 +199,25 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
return conflict("Page was modified elsewhere; reload to see the latest version")
}
}
if raw, ok := d["parentId"]; ok {
parent, e := pageParent(r.Context(), tx, id, user(r), raw)
if e != nil {
return e
}
d["parentId"] = parent
}
if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil {
return e
}
if _, ok := d["content"]; ok {
if content, ok := d["content"].(string); ok {
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil {
return e
}
// Rebuild resolved links/embeds in the same transaction as the
// Markdown they were derived from.
if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil {
return e
}
}
if bid := str(b, "addBookmarkId"); bid != "" {
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
@@ -190,7 +239,22 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
})
respond(w, 200, v, e)
}
// HandlePagesBacklinks returns the pages that link to this one. It is a
// separate read surface for navigation; the same owner-scoped list is
// embedded in pageView for the editor.
func HandlePagesBacklinks(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Page", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=$1 AND s."ownerId"=$2 ORDER BY s."updatedAt" DESC,s.id`, id, user(r))
respond(w, 200, v, e)
}
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
// Children are promoted to the top level by the self-FK's SET NULL; a
// delete never silently takes nested pages with it.
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
}
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
+161
View File
@@ -0,0 +1,161 @@
package http
import (
"testing"
)
// Connected pages (Notion-like upgrades phase 3): a real hierarchy with cycle
// rejection, Markdown-derived internal links/backlinks that never cross
// accounts, and live collection embeds that stay owner-scoped.
func pageVersion(t *testing.T, c apiClient, id string) int {
t.Helper()
v := c.request("GET", "/api/pages/"+id, nil, 200)
version, _ := v["version"].(float64)
return int(version)
}
func TestPageHierarchy(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "pages-tree@computer.test")
bob := newAPIUser(t, srv, "pages-tree-other@computer.test")
root := alice.request("POST", "/api/pages", map[string]any{"title": "Root"}, 201)["id"].(string)
child := alice.request("POST", "/api/pages", map[string]any{"title": "Child", "parentId": root}, 201)["id"].(string)
grand := alice.request("POST", "/api/pages", map[string]any{"title": "Grandchild", "parentId": child}, 201)["id"].(string)
listed := map[string]string{}
for _, p := range alice.getJSON("/api/pages", 200) {
parent, _ := p["parentId"].(string)
listed[p["id"].(string)] = parent
}
if listed[root] != "" || listed[child] != root || listed[grand] != child {
t.Fatalf("parentId not preserved in the list: %v", listed)
}
// A page cannot parent itself, nor may a move create a cycle.
alice.request("PATCH", "/api/pages/"+child, map[string]any{"parentId": child}, 400)
alice.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": grand}, 400)
alice.request("PATCH", "/api/pages/"+child, map[string]any{"parentId": grand}, 400)
// Foreign pages are not valid parents (and are not disclosed as existing).
foreign := bob.request("POST", "/api/pages", map[string]any{"title": "Bob only"}, 201)["id"].(string)
alice.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": foreign}, 400)
alice.request("POST", "/api/pages", map[string]any{"title": "Hijack", "parentId": foreign}, 400)
// Moving a page between parents works and is metadata-only (no version
// requirement, matching title/pin changes).
moved := alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": root}, 200)
if moved["parentId"] != root {
t.Fatalf("parent move not applied: %v", moved["parentId"])
}
// Explicit null promotes to the top level.
promoted := alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": nil}, 200)
if promoted["parentId"] != nil {
t.Fatalf("null parent did not promote page: %v", promoted["parentId"])
}
alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": child}, 200)
// Deleting a parent promotes its children instead of deleting them.
alice.request("DELETE", "/api/pages/"+child, nil, 200)
alice.request("GET", "/api/pages/"+child, nil, 404)
alice.request("GET", "/api/pages/"+grand, nil, 200)
if got := alice.request("GET", "/api/pages/"+grand, nil, 200)["parentId"]; got != nil {
t.Fatalf("orphaned child was not promoted after parent delete: %v", got)
}
// A foreign user can never see or mutate the tree.
bob.request("GET", "/api/pages/"+root, nil, 404)
bob.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": foreign}, 404)
}
func TestPageBacklinksAndEmbeds(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "pages-graph@computer.test")
bob := newAPIUser(t, srv, "pages-graph-other@computer.test")
target := alice.request("POST", "/api/pages", map[string]any{"title": "Target page"}, 201)["id"].(string)
bobTarget := bob.request("POST", "/api/pages", map[string]any{"title": "Private title"}, 201)["id"].(string)
mine := alice.request("POST", "/api/collections", map[string]any{"name": "Reading list"}, 201)
collection := mine["id"].(string)
view := mine["views"].([]any)[0].(map[string]any)["id"].(string)
foreignCollection := bob.request("POST", "/api/collections", map[string]any{"name": "Bob private"}, 201)["id"].(string)
content := "# Connected\n\n" +
"- [Target](computer:page/" + target + ")\n" +
"- [Alias](/pages?id=" + target + ")\n" +
"- [Bob's page](computer:page/" + bobTarget + ")\n\n" +
"{{collection:" + collection + "|view=" + view + "}}\n" +
"{{collection:" + foreignCollection + "}}\n"
source := alice.request("POST", "/api/pages", map[string]any{"title": "Source page", "content": content}, 201)["id"].(string)
// Duplicate aliases resolve to one edge; the foreign page is never stored.
tgt := alice.request("GET", "/api/pages/"+target, nil, 200)
backlinks := tgt["backlinks"].([]any)
if len(backlinks) != 1 || backlinks[0].(map[string]any)["id"] != source || backlinks[0].(map[string]any)["title"] != "Source page" {
t.Fatalf("backlinks did not resolve owner-scoped pages: %v", backlinks)
}
listed := alice.getJSON("/api/pages/"+target+"/backlinks", 200)
if len(listed) != 1 || listed[0]["id"] != source {
t.Fatalf("backlinks endpoint: %v", listed)
}
bob.request("GET", "/api/pages/"+target+"/backlinks", nil, 404)
src := alice.request("GET", "/api/pages/"+source, nil, 200)
links := src["links"].([]any)
if len(links) != 1 || links[0].(map[string]any)["id"] != target {
t.Fatalf("outgoing links should contain only the owned target: %v", links)
}
embeds := src["embeds"].([]any)
if len(embeds) != 1 {
t.Fatalf("foreign collection embed was stored: %v", embeds)
}
embed := embeds[0].(map[string]any)
if embed["collectionId"] != collection || embed["viewId"] != view {
t.Fatalf("embed reference/view not preserved: %v", embed)
}
col := embed["collection"].(map[string]any)
if col["name"] != "Reading list" {
t.Fatalf("live embed is missing its collection projection: %v", col)
}
if props, ok := col["properties"].([]any); !ok || len(props) != 0 {
t.Fatalf("embed projection missing properties array: %v", col["properties"])
}
// Removing the Markdown and re-saving removes both derived rows, proving
// they are not append-only caches.
alice.request("PATCH", "/api/pages/"+source, map[string]any{
"content": "# Disconnected", "version": pageVersion(t, alice, source),
}, 200)
if b := alice.request("GET", "/api/pages/"+target, nil, 200)["backlinks"].([]any); len(b) != 0 {
t.Fatalf("stale backlink survived a content save: %v", b)
}
src = alice.request("GET", "/api/pages/"+source, nil, 200)
if e := src["embeds"].([]any); len(e) != 0 {
t.Fatalf("stale embed survived a content save: %v", e)
}
if l := src["links"].([]any); len(l) != 0 {
t.Fatalf("stale outgoing link survived a content save: %v", l)
}
// Re-linking re-establishes the edge; deleting the target removes it.
alice.request("PATCH", "/api/pages/"+source, map[string]any{
"content": "[Target](computer:page/" + target + ")", "version": pageVersion(t, alice, source),
}, 200)
if b := alice.request("GET", "/api/pages/"+target, nil, 200)["backlinks"].([]any); len(b) != 1 {
t.Fatalf("re-link was not resolved: %v", b)
}
alice.request("DELETE", "/api/pages/"+target, nil, 200)
if l := alice.request("GET", "/api/pages/"+source, nil, 200)["links"].([]any); len(l) != 0 {
t.Fatalf("deleted target left a dangling link: %v", l)
}
// Self-links are ignored (a page is not its own backlink).
selfContent := "[Me](computer:page/" + source + ")"
alice.request("PATCH", "/api/pages/"+source, map[string]any{
"content": selfContent, "version": pageVersion(t, alice, source),
}, 200)
if b := alice.request("GET", "/api/pages/"+source, nil, 200)["backlinks"].([]any); len(b) != 0 {
t.Fatalf("self-link became a backlink: %v", b)
}
}
+1
View File
@@ -88,6 +88,7 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
mux.HandleFunc("GET /api/pages/{id}", authed(handlers.HandlePagesGet))
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete))
mux.HandleFunc("GET /api/pages/{id}/backlinks", authed(handlers.HandlePagesBacklinks))
mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown))
// 4.7 Signals