Skip to content
Open
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
38 changes: 37 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,43 @@ The home page is excluded from this mapping:

#### Custom slugs

Admins can change a page's Page URL from the page browser ellipsis menu.
Admins can change a page's Page URL from the page browser ellipsis menu, and from
the page-actions ellipsis menu in the browsing toolbar for the page they are on.

Both entry points share one implementation: `PageUrlDialog.svelte` owns the dialog
and the `update_page_slug` call, and is rendered once by `Overlays.svelte`. Callers
open it through the `page_url_dialog` context with the target page's
`document_id` and current `page_href`, plus an optional `on_saved` hook for
caller-specific cleanup. The home page has no editable slug, so the entry is
disabled in both menus.

Deleting a page follows the same shape: `PageDeleteDialog.svelte` owns the
confirmation and the `delete_page` call, opened through the `page_delete_dialog`
context. Callers pass `is_current_page`, since deleting the page you are looking
at has to navigate home afterwards rather than just refetching. The home page
cannot be deleted, so that entry is disabled in both menus too.

#### Duplicating a page

Duplicate page navigates to `/new?from=<slug>`. The `/new` load resolves that
slug to the page's own document — `get_page_document_for_duplicate`, admin-only,
deliberately without the shared nav and footer stitched in — and the client builds
the unsaved copy with `create_duplicate_doc`.

Every node in the source page gets a fresh id via `clone_subtree_with_new_ids`,
which walks the same node/node_array/text references as the graph collector and
rewrites them to the copies, so a saved duplicate shares no node ids with its
original. Ids outside the copied set pass through untouched, which is what keeps
the shared `nav` and `footer` as references rather than copies; those roots are
excluded from the walk explicitly, since the stored page document does not
contain their nodes and they would otherwise be remapped to ids that do not
exist. The copy is then re-pointed at the current shared documents.

The home page has no slug row by invariant, so it is addressed as `?from=/` — the
same way the page browser and `page_href` refer to it — and the query resolves
that to the configured home page id. Duplicating the home page produces an
ordinary new page; `home_page_id` is a site setting, so the copy does not become
a second home.

When a user changes a page's slug:

Expand Down
3 changes: 2 additions & 1 deletion IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,8 @@ Required UI behavior:
- link pickers must not expose drafts to unauthenticated users
- toolbar actions that require admin auth must be hidden or disabled when unauthenticated
- authenticated admins get an ellipsis page-actions menu in the browsing toolbar with Duplicate page, Edit URL, Delete page, and Logout entries
- keep the initial page-management entries presentational and retain the existing working Logout action; wire Duplicate page, Edit URL, and Delete page in a follow-up task
- Edit URL and Delete page open the same dialogs as the page browser ellipsis menu, and are disabled on the home page
- Duplicate page navigates to `/new?from=<slug>`, which starts the new page as a copy of that page; the home page has no slug and is addressed as `?from=/`

### Page browser behavior

Expand Down
22 changes: 22 additions & 0 deletions src/lib/api.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,28 @@ export const get_document = query(v.string(), async (slug) => {
};
});

/**
* Get a page's own document for duplication, without the shared nav and footer
* stitched in. The caller rebuilds those references against the current shared
* documents, so returning them here would only invite copying them by mistake.
*/
export const get_page_document_for_duplicate = query(v.string(), async (slug) => {
require_admin_session(getRequestEvent().locals);

// The home page has no slug row by invariant, so it is addressed as `/` —
// the same way the page browser and `page_href` refer to it.
const document_id =
slug === '/' ? get_home_page_id_from_db() : (resolve_slug(slug)?.document_id ?? null);

if (!document_id) {
error(404, `Page not found for slug: ${slug}`);
}

return {
document: get_doc_from_db(document_id)
};
});

/**
* Resolve the configured home page and return its stitched document.
*/
Expand Down
108 changes: 108 additions & 0 deletions src/lib/document_graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { clone_subtree_with_new_ids } from './document_graph.js';

function make_id_generator() {
let counter = 0;
return () => `new_${++counter}`;
}

// A page with a body containing a prose block, a heading whose text carries a
// link mark pointing at a link node, and shared nav/footer references that live
// in other documents.
function make_page_nodes() {
return {
page_1: {
id: 'page_1',
type: 'page',
title: { content: 'Hello', marks: [], annotations: [] },
description: { content: '', marks: [], annotations: [] },
image: 'image_1',
nav: 'nav_doc',
footer: 'footer_doc',
body: { nodes: ['prose_1'], marks: [], annotations: [] }
},
image_1: { id: 'image_1', type: 'image', src: '/a.png' },
prose_1: {
id: 'prose_1',
type: 'prose',
layout: 'narrow-left',
body: { nodes: ['heading_1'], marks: [], annotations: [] }
},
heading_1: {
id: 'heading_1',
type: 'heading_1',
content: {
content: 'Linked',
marks: [{ start_offset: 0, end_offset: 6, node_id: 'link_1' }],
annotations: []
}
},
link_1: { id: 'link_1', type: 'link', href: '/somewhere' }
} as any;
}

const shared_roots = new Set(['nav_doc', 'footer_doc']);

describe('clone_subtree_with_new_ids', () => {
it('gives every copied node a fresh id', () => {
const nodes = make_page_nodes();
const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);

const source_ids = Object.keys(nodes);
const cloned_ids = Object.keys(result.nodes);

expect(cloned_ids).toHaveLength(source_ids.length);
for (const id of cloned_ids) {
expect(source_ids).not.toContain(id);
}
// The stored key and the node's own id agree.
for (const [id, node] of Object.entries(result.nodes)) {
expect((node as any).id).toBe(id);
}
});

it('rewrites node and node_array references to the copies', () => {
const nodes = make_page_nodes();
const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);

const page = result.nodes[result.root_id] as any;
const prose_id = page.body.nodes[0];
const prose = result.nodes[prose_id] as any;

expect(result.nodes[page.image]).toBeDefined();
expect(prose).toBeDefined();
expect(result.nodes[prose.body.nodes[0]]).toBeDefined();
});

it('rewrites mark references inside annotated text', () => {
const nodes = make_page_nodes();
const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);

const page = result.nodes[result.root_id] as any;
const prose = result.nodes[page.body.nodes[0]] as any;
const heading = result.nodes[prose.body.nodes[0]] as any;
const link_id = heading.content.marks[0].node_id;

expect(link_id).not.toBe('link_1');
expect(result.nodes[link_id]).toBeDefined();
expect((result.nodes[link_id] as any).type).toBe('link');
});

it('leaves excluded shared roots pointing at their own documents', () => {
const nodes = make_page_nodes();
const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);

const page = result.nodes[result.root_id] as any;
expect(page.nav).toBe('nav_doc');
expect(page.footer).toBe('footer_doc');
expect(result.nodes.nav_doc).toBeUndefined();
expect(result.nodes.footer_doc).toBeUndefined();
});

it('leaves the source document untouched', () => {
const nodes = make_page_nodes();
const before = structuredClone(nodes);
clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);
expect(nodes).toEqual(before);
});
});
64 changes: 64 additions & 0 deletions src/lib/document_graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,70 @@ function get_attached_ranges(
return [...(value?.marks ?? []), ...(value?.annotations ?? [])];
}

/**
* Deep-copy the subtree rooted at `root_id`, giving every copied node a fresh id
* and rewriting the references between them.
*
* Ids outside the copied set are left untouched, so references that point at
* other documents — a page's shared `nav` and `footer` — keep pointing there.
* Pass those roots in `exclude_roots` so they are not treated as part of the
* subtree; they live in their own documents and must not be duplicated.
*/
export function clone_subtree_with_new_ids(
root_id: string,
nodes: Record<string, DocumentNode>,
generate_id: () => string,
exclude_roots?: Set<string>
): { root_id: string; nodes: Record<string, DocumentNode> } {
const source_ids = collect_node_ids_in_order(root_id, nodes, exclude_roots);

const id_map = new Map<string, string>();
for (const id of source_ids) {
if (nodes[id]) id_map.set(id, generate_id());
}

const map_id = (id: string) => id_map.get(id) ?? id;
const cloned_nodes: Record<string, DocumentNode> = {};

for (const [source_id, new_id] of id_map) {
const node = structuredClone(nodes[source_id]);
node.id = new_id;

const type_schema: NodeSchema | undefined = document_schema[node.type];

for (const [prop_name, prop_def] of Object.entries<PropertyDefinition>(
type_schema?.properties ?? {}
)) {
const value = node[prop_name];
if (value == null) continue;

if (prop_def.type === 'node' && typeof value === 'string') {
node[prop_name] = map_id(value);
} else if (prop_def.type === 'node_array') {
value.nodes = value.nodes.map(map_id);
remap_attached_ranges(value, map_id);
} else if (prop_def.type === 'text') {
remap_attached_ranges(value, map_id);
}
}

cloned_nodes[new_id] = node;
}

return { root_id: map_id(root_id), nodes: cloned_nodes };
}

function remap_attached_ranges(
value: { marks?: Attachment[]; annotations?: Attachment[] },
map_id: (id: string) => string
) {
for (const range of get_attached_ranges(value)) {
if (range.node_id) {
range.node_id = map_id(range.node_id);
}
}
}

/**
* Collect all node ids reachable from a root node by walking node/node_array
* properties and mark/annotation references, preserving first-seen traversal order.
Expand Down
74 changes: 64 additions & 10 deletions src/lib/new_page.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,70 @@
import nanoid from '../routes/nanoid.js';
import { MEDIA_DEFAULTS } from '$lib/config.js';
import { clone_subtree_with_new_ids } from '$lib/document_graph.js';
import type { Document } from 'svedit';

function get_shared_roots(shared_documents: { nav_document: Document; footer_document: Document }) {
const nav_document = shared_documents?.nav_document;
const footer_document = shared_documents?.footer_document;

if (!nav_document?.document_id || !nav_document?.nodes) {
throw new Error('Missing nav document for new page creation');
}

if (!footer_document?.document_id || !footer_document?.nodes) {
throw new Error('Missing footer document for new page creation');
}

return { nav_document, footer_document };
}

/**
* Create an unsaved copy of an existing page for `/new?from=<slug>`.
*
* Every node belonging to the source page gets a fresh id, so the copy shares no
* ids with the original once saved. The shared nav and footer are referenced,
* not copied — they belong to their own documents — and are re-pointed at the
* current shared documents rather than whatever the source happened to reference.
*/
export function create_duplicate_doc(
source_document: Document,
shared_documents: { nav_document: Document; footer_document: Document }
): Document {
const { nav_document, footer_document } = get_shared_roots(shared_documents);

if (!source_document?.document_id || !source_document?.nodes) {
throw new Error('Missing source document for page duplication');
}

const source_root = source_document.nodes[source_document.document_id];

// The source page's own nodes are stored without the shared subtrees, so the
// nav/footer references would otherwise be collected and remapped to ids that
// do not exist.
const shared_roots = new Set<string>();
if (typeof source_root?.nav === 'string') shared_roots.add(source_root.nav);
if (typeof source_root?.footer === 'string') shared_roots.add(source_root.footer);

const { root_id, nodes } = clone_subtree_with_new_ids(
source_document.document_id,
source_document.nodes,
nanoid,
shared_roots
);

nodes[root_id].nav = nav_document.document_id;
nodes[root_id].footer = footer_document.document_id;

return {
document_id: root_id,
nodes: {
...structuredClone(nav_document.nodes),
...structuredClone(footer_document.nodes),
...nodes
}
};
}

/**
* Create a new unsaved page document for the `/new` route.
*
Expand All @@ -23,16 +86,7 @@ export function create_empty_doc(shared_documents: {
const heading_id = nanoid();
const paragraph_id = nanoid();

const nav_document = shared_documents?.nav_document;
const footer_document = shared_documents?.footer_document;

if (!nav_document?.document_id || !nav_document?.nodes) {
throw new Error('Missing nav document for new page creation');
}

if (!footer_document?.document_id || !footer_document?.nodes) {
throw new Error('Missing footer document for new page creation');
}
const { nav_document, footer_document } = get_shared_roots(shared_documents);

return {
document_id: page_id,
Expand Down
Loading