diff --git a/actions/createPublicationPage.ts b/actions/createPublicationPage.ts
index f03c6c52..044aed11 100644
--- a/actions/createPublicationPage.ts
+++ b/actions/createPublicationPage.ts
@@ -3,6 +3,10 @@ import { generateKeyBetween } from "fractional-indexing";
import { getIdentityData } from "actions/getIdentityData";
import { createNewLeaflet, type DefaultBlockSpec } from "./createNewLeaflet";
import { supabaseServerClient } from "supabase/serverClient";
+import {
+ isExternalLink,
+ normalizeExternalLink,
+} from "src/utils/externalPublicationLink";
export async function createPublicationPage(args: {
publication_uri: string;
@@ -23,6 +27,13 @@ export async function createPublicationPage(args: {
if (!publication || publication.identity_did !== identity.atp_did)
return null;
+ // An external link tab stores a full URL in `path` and has no backing
+ // leaflet document — bail early if the url isn't usable so we never insert a
+ // half-formed link.
+ let external = isExternalLink(args.path);
+ let externalUrl = external ? normalizeExternalLink(args.path) : null;
+ if (external && !externalUrl) return null;
+
let description =
(publication.record as { description?: string } | null)?.description ?? "";
@@ -38,24 +49,27 @@ export async function createPublicationPage(args: {
sort_order = generateKeyBetween(last?.sort_order ?? null, null);
}
- let firstBlocks: DefaultBlockSpec[] = [
- description ? { type: "text" as const, content: description } : "text",
- ...(args.includePostsList ? (["posts-list"] as const) : []),
- ...(args.includeSignup ? (["signup"] as const) : []),
- ];
- let leaflet_src = await createNewLeaflet({
- pageType: "doc",
- redirectUser: false,
- firstBlocks,
- addToHomepage: false,
- });
+ let leaflet_src: string | null = null;
+ if (!external) {
+ let firstBlocks: DefaultBlockSpec[] = [
+ description ? { type: "text" as const, content: description } : "text",
+ ...(args.includePostsList ? (["posts-list"] as const) : []),
+ ...(args.includeSignup ? (["signup"] as const) : []),
+ ];
+ leaflet_src = await createNewLeaflet({
+ pageType: "doc",
+ redirectUser: false,
+ firstBlocks,
+ addToHomepage: false,
+ });
+ }
let { data: page } = await supabaseServerClient
.from("publication_pages")
.insert({
publication: args.publication_uri,
leaflet_src,
- path: args.path,
+ path: externalUrl ?? args.path,
title: args.title ?? "",
sort_order,
})
diff --git a/actions/publishPublicationPages.ts b/actions/publishPublicationPages.ts
index bf641bec..e1b3b2b7 100644
--- a/actions/publishPublicationPages.ts
+++ b/actions/publishPublicationPages.ts
@@ -1,6 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
+import { TID } from "@atproto/common";
+import { AtUri } from "@atproto/syntax";
import { AtpBaseClient } from "lexicons/api";
import { Json } from "supabase/database.types";
import type { Fact } from "src/replicache";
@@ -9,11 +11,9 @@ import { Lock } from "src/utils/lock";
import { OAuthSessionError, restoreOAuthSession } from "src/atproto-oauth";
import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data";
import { getIdentityData } from "actions/getIdentityData";
-import {
- leafletToPublicationPageRecord,
- pathToRkey,
-} from "src/utils/leafletToPublicationPageRecord";
+import { leafletToPublicationPageRecord } from "src/utils/leafletToPublicationPageRecord";
import { supabaseServerClient } from "supabase/serverClient";
+import { isExternalLink } from "src/utils/externalPublicationLink";
type PublishPagesResult =
| { success: true; published: { id: number; uri: string }[] }
@@ -62,7 +62,7 @@ export async function publishPublicationPages({
const { data: pageRows } = await supabaseServerClient
.from("publication_pages")
- .select("id, title, path, leaflet_src")
+ .select("id, title, path, leaflet_src, sort_order, record_uri")
.eq("publication", publication_uri);
if (!pageRows || pageRows.length === 0) {
return { success: true, published: [] };
@@ -72,6 +72,23 @@ export async function publishPublicationPages({
const published: { id: number; uri: string }[] = [];
for (const page of pageRows) {
+ const title = page.title ?? "";
+ const sort_order = page.sort_order;
+
+ // External link tabs have no backing leaflet document — there's nothing to
+ // publish to the network. Just snapshot their nav metadata (the url lives in
+ // `path`) so the tab goes live / picks up draft edits.
+ if (isExternalLink(page.path)) {
+ await supabaseServerClient
+ .from("publication_pages")
+ .update({
+ published_metadata: { path: page.path, title, sort_order },
+ })
+ .eq("id", page.id)
+ .eq("publication", publication_uri);
+ continue;
+ }
+ if (!page.leaflet_src) continue;
const path = page.path ?? "/";
const { result: leafletRes } = await get_leaflet_data.handler(
@@ -122,7 +139,12 @@ export async function publishPublicationPages({
},
});
- const rkey = pathToRkey(path);
+ // Reuse the rkey of an already-published page so republishing updates the
+ // record in place. New pages get a tid — a path-derived rkey would collide
+ // across publications in the same repo (e.g. every pub's "home").
+ const rkey = page.record_uri
+ ? new AtUri(page.record_uri).rkey
+ : TID.nextStr();
const { data: putResult } = await agent.com.atproto.repo.putRecord({
rkey,
repo: credentialSession.did!,
@@ -136,6 +158,9 @@ export async function publishPublicationPages({
.update({
record: record as unknown as Json,
record_uri: putResult.uri,
+ // Snapshot the live nav metadata alongside the content so the public
+ // nav reflects this publish (and not any later draft edits).
+ published_metadata: { path, title, sort_order },
})
.eq("id", page.id)
.eq("publication", publication_uri);
diff --git a/actions/updatePublicationPage.ts b/actions/updatePublicationPage.ts
index 72f62b2d..cb31f7ac 100644
--- a/actions/updatePublicationPage.ts
+++ b/actions/updatePublicationPage.ts
@@ -1,6 +1,10 @@
"use server";
import { getIdentityData } from "actions/getIdentityData";
import { supabaseServerClient } from "supabase/serverClient";
+import {
+ isExternalLink,
+ normalizeExternalLink,
+} from "src/utils/externalPublicationLink";
export async function updatePublicationPage(args: {
publication_uri: string;
@@ -19,9 +23,32 @@ export async function updatePublicationPage(args: {
if (!publication || publication.identity_did !== identity.atp_did)
return { success: false };
+ let { data: page } = await supabaseServerClient
+ .from("publication_pages")
+ .select("id, leaflet_src")
+ .eq("id", args.page_id)
+ .eq("publication", args.publication_uri)
+ .single();
+ if (!page) return { success: false };
+
+ // A tab's type is fixed at creation and discriminated by leaflet_src:
+ // external link tabs have no backing leaflet and must keep a valid full url;
+ // hosted pages must keep a relative path. Rejecting type flips here keeps a
+ // hosted page from orphaning its leaflet (and its published record) and an
+ // external tab from becoming an un-editable, un-publishable hosted page.
+ let external = !page.leaflet_src;
+ let path = args.path;
+ if (external) {
+ let normalized = normalizeExternalLink(path);
+ if (!normalized) return { success: false };
+ path = normalized;
+ } else if (isExternalLink(path)) {
+ return { success: false };
+ }
+
let { error } = await supabaseServerClient
.from("publication_pages")
- .update({ title: args.title, path: args.path })
+ .update({ title: args.title, path })
.eq("id", args.page_id)
.eq("publication", args.publication_uri);
if (error) return { success: false };
diff --git a/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx b/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx
index ce0f90f7..4ceb892f 100644
--- a/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx
+++ b/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx
@@ -9,6 +9,7 @@ import { FontLoader } from "components/FontLoader";
import { SpeedyLink } from "components/SpeedyLink";
import { blobRefToSrc } from "src/utils/blobRefToSrc";
import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL";
+import { publishedNavPages } from "src/utils/publishedPageMetadata";
import {
PublicationPostsList,
type PublicationPostsListPost,
@@ -39,6 +40,7 @@ export const DefaultPublicationHomepage = ({
title: string | null;
record_uri: string | null;
sort_order: string;
+ published_metadata: unknown;
}[];
documents_in_publications: {
documents: {
@@ -56,9 +58,8 @@ export const DefaultPublicationHomepage = ({
fakePosts?: FakePost[];
}) => {
const newsletterMode = !!publication.publication_newsletter_settings?.enabled;
- const navPages = (publication.publication_pages ?? []).filter(
- (p) => p.record_uri,
- );
+ // Public nav renders only published tabs, from their published snapshot.
+ const navPages = publishedNavPages(publication.publication_pages);
const posts: PublicationPostsListPost[] = fakePosts
? []
: publication.documents_in_publications
diff --git a/app/(app)/lish/[did]/[publication]/PublicationNav.tsx b/app/(app)/lish/[did]/[publication]/PublicationNav.tsx
index 2d06c9f8..9b68a392 100644
--- a/app/(app)/lish/[did]/[publication]/PublicationNav.tsx
+++ b/app/(app)/lish/[did]/[publication]/PublicationNav.tsx
@@ -1,17 +1,20 @@
"use client";
-import { SpeedyLink } from "components/SpeedyLink";
+import { isExternalLink } from "src/utils/externalPublicationLink";
import { sortPublicationPages } from "./sortPublicationPages";
import { PublicationNavSubscribe } from "./PublicationNavSubscribe";
+import { PublicationNavTabLink } from "./PublicationNavTabLink";
import {
SubscribeButton,
SubscribeInput,
} from "components/Subscribe/SubscribeButton";
import { useNavBackgroundFade } from "./useNavBackgroundFade";
+// Pages here are published snapshots (publishedNavPages output), where path
+// and title are always present.
export type PublicationNavPage = {
id: number;
- path: string | null;
- title: string | null;
+ path: string;
+ title: string;
sort_order: string;
};
@@ -64,9 +67,13 @@ export function PublicationNav(props: {
>
{tabs.map((page) => {
+ let external = isExternalLink(page.path);
let segment = page.path === "/" ? "" : page.path;
- let href = `${props.publicationUrl}${segment}`;
- let active = props.activePath === page.path;
+ let href = external
+ ? page.path
+ : `${props.publicationUrl}${segment}`;
+ // External links point off-site, so they're never the active tab.
+ let active = !external && props.activePath === page.path;
return (
-
{page.title || page.path}
-
+
);
})}
diff --git a/app/(app)/lish/[did]/[publication]/PublicationNavTabLink.tsx b/app/(app)/lish/[did]/[publication]/PublicationNavTabLink.tsx
new file mode 100644
index 00000000..1a437b5d
--- /dev/null
+++ b/app/(app)/lish/[did]/[publication]/PublicationNavTabLink.tsx
@@ -0,0 +1,32 @@
+import { SpeedyLink } from "components/SpeedyLink";
+
+// A single tab link in a publication nav, shared between the public nav
+// (PublicationNav) and the editor nav (PublicationPagesEditNav) so the two
+// stay visually identical. External link tabs open off-site in a new window;
+// hosted pages navigate client-side.
+export function PublicationNavTabLink(props: {
+ href: string;
+ external: boolean;
+ active: boolean;
+ children: React.ReactNode;
+}) {
+ let className = `block px-1 pt-1 pb-0.5 text-sm font-bold text-inherit no-underline! select-none border-b-3 ${
+ props.active ? "border-accent-contrast" : "border-transparent"
+ }`;
+ if (props.external)
+ return (
+
+ {props.children}
+
+ );
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx
index c57e747d..41d7e4c8 100644
--- a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx
+++ b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx
@@ -24,6 +24,7 @@ import { type PublicationPostsListPost } from "../PublicationPostsList";
import { PublicationHomeLayout } from "../PublicationHomeLayout";
import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL";
import { blobRefToSrc } from "src/utils/blobRefToSrc";
+import { publishedNavPages } from "src/utils/publishedPageMetadata";
import { collectAndFetchBlockResources } from "./collectAndFetchBlockResources";
import { PostContent } from "./PostContent";
@@ -42,6 +43,7 @@ type PublicationRow = {
title: string;
record_uri: string | null;
sort_order: string;
+ published_metadata: unknown;
}[];
documents_in_publications?: {
documents: {
@@ -161,9 +163,9 @@ export async function PublicationPageRenderer({
recommendsCount: 0,
};
- const navPages = (publication.publication_pages ?? []).filter(
- (p) => p.record_uri,
- );
+ // The public nav renders only published tabs, from their published snapshot —
+ // never the live draft columns.
+ const navPages = publishedNavPages(publication.publication_pages);
return (
diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx
index bb72ee44..7a1e7b50 100644
--- a/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx
+++ b/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx
@@ -11,6 +11,7 @@ import {
publicationNameOrUriFilter,
} from "src/utils/uriHelpers";
import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL";
+import { findPublishedPage } from "src/utils/publishedPageMetadata";
export async function generateMetadata(props: {
params: Promise<{ publication: string; did: string; rkey: string }>;
@@ -23,17 +24,18 @@ export async function generateMetadata(props: {
let { data: pubs } = await supabaseServerClient
.from("publications")
- .select("name, publication_pages(path, title, record_uri)")
+ .select("name, publication_pages(record_uri, published_metadata)")
.eq("identity_did", did)
.or(publicationNameOrUriFilter(did, publication_name))
.order("uri", { ascending: false })
.limit(1);
- let matchingPage = pubs?.[0]?.publication_pages?.find(
- (p) => p.path === "/" + rkey && p.record_uri,
- );
- if (matchingPage) {
+ // Match on the published snapshot, the same way the page body does
+ // (tryRenderPublicationPage), so metadata and body never disagree about
+ // which page a URL serves.
+ let match = findPublishedPage(pubs?.[0]?.publication_pages, "/" + rkey);
+ if (match && match.page.record_uri) {
return {
- title: `${matchingPage.title || matchingPage.path} - ${pubs?.[0]?.name}`,
+ title: `${match.metadata.title || match.metadata.path} - ${pubs?.[0]?.name}`,
};
}
@@ -120,7 +122,7 @@ export default async function Post(props: {
.from("publications")
.select(
`uri, name, identity_did, record,
- publication_pages(id, path, title, record, record_uri, sort_order),
+ publication_pages(id, path, title, record, record_uri, sort_order, published_metadata),
documents_in_publications(documents(uri, data,
comments_on_documents(count),
document_mentions_in_bsky(count),
diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesEditNav.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesEditNav.tsx
index 1b4981f3..2ae00fc1 100644
--- a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesEditNav.tsx
+++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesEditNav.tsx
@@ -24,7 +24,6 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { generateKeyBetween } from "fractional-indexing";
-import { SpeedyLink } from "components/SpeedyLink";
import {
ButtonPrimary,
ButtonSecondary,
@@ -45,10 +44,15 @@ import {
} from "../../dashboard/PublicationSWRProvider";
import { sortPublicationPages } from "../../sortPublicationPages";
import { PublicationNavSubscribe } from "../../PublicationNavSubscribe";
+import { PublicationNavTabLink } from "../../PublicationNavTabLink";
import { useNavBackgroundFade } from "../../useNavBackgroundFade";
import { DotLoader } from "components/utils/DotLoader";
import { useToaster } from "components/Toast";
import { Checkbox } from "components/Checkbox";
+import {
+ isExternalLink,
+ normalizeExternalLink,
+} from "src/utils/externalPublicationLink";
// Turn arbitrary user input into a slug that is safe to use as the path
// segment of a URL: lowercase, ascii letters/numbers/dashes only, no spaces
@@ -90,6 +94,34 @@ function useIsPathInUse() {
};
}
+// Validate the user's path/url input for a tab and return the value to store
+// in `path`, or null (after showing an error toast) if it's unusable. External
+// link tabs store a full url; hosted pages store a cleaned relative path that
+// must be unique within the publication.
+function useResolveTabPath() {
+ let toaster = useToaster();
+ let isPathInUse = useIsPathInUse();
+ return (
+ input: string,
+ external: boolean,
+ excludePageId?: number,
+ ): string | null => {
+ if (external) {
+ let url = normalizeExternalLink(input);
+ if (!url) {
+ toaster({ type: "error", content: "enter a valid url (https://…)" });
+ return null;
+ }
+ return url;
+ }
+ if (isPathInUse(input, excludePageId)) {
+ toaster({ type: "error", content: "path already in use!" });
+ return null;
+ }
+ return cleanPath(input) || "";
+ };
+}
+
export function PublicationPagesEditNav(props: {
did: string;
publicationName: string;
@@ -231,7 +263,10 @@ export function PublicationPagesEditNav(props: {
publicationUrl={publicationRecord?.url}
onCreated={async (created) => {
await mutate();
- router.push(hrefForPath(created.path));
+ // External links have no editable page to navigate to — they just
+ // appear in the nav once created.
+ if (!isExternalLink(created.path))
+ router.push(hrefForPath(created.path));
}}
/>
@@ -260,8 +295,7 @@ function AddPageButton(props: {
publicationUrl: string | undefined;
onCreated: (created: { path: string | null }) => void | Promise;
}) {
- let toaster = useToaster();
- let isPathInUse = useIsPathInUse();
+ let resolveTabPath = useResolveTabPath();
let [open, setOpen] = useState(false);
let [creating, setCreating] = useState(false);
let [name, setName] = useState("");
@@ -298,14 +332,17 @@ function AddPageButton(props: {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (creating || !props.publicationUri) return;
- if (isPathInUse(path)) {
- toaster({ type: "error", content: "path already in use!" });
- return;
- }
+
+ let path_to_create = resolveTabPath(
+ isExternal ? externalLink : path,
+ isExternal,
+ );
+ if (path_to_create === null) return;
+
setCreating(true);
let created = await createPublicationPage({
publication_uri: props.publicationUri,
- path: cleanPath(path) || "",
+ path: path_to_create,
title: name.trim() || undefined,
});
setCreating(false);
@@ -396,10 +433,8 @@ function AddPageButton(props: {
type="submit"
disabled={
creating ||
- !path ||
- path.trim() === "" ||
- !name ||
- name.trim() === ""
+ !name.trim() ||
+ (isExternal ? !externalLink.trim() : !path.trim())
}
fullWidth
compact
@@ -432,8 +467,8 @@ function SortableTab(props: {
transition,
isDragging,
} = useSortable({ id: props.page.id });
- let toaster = useToaster();
- let isPathInUse = useIsPathInUse();
+ let resolveTabPath = useResolveTabPath();
+ let external = isExternalLink(props.page.path);
let [popoverOpen, setPopoverOpen] = useState(false);
let [mode, setMode] = useState<"edit" | "confirm">("edit");
let [name, setName] = useState(props.page.title);
@@ -458,10 +493,9 @@ function SortableTab(props: {
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!props.publicationUri || saving) return;
- if (isPathInUse(path, props.page.id)) {
- toaster({ type: "error", content: "path already in use!" });
- return;
- }
+
+ let nextPath = resolveTabPath(path, external, props.page.id);
+ if (nextPath === null) return;
let trimmedTitle = name.trim();
setSaving(true);
@@ -469,12 +503,12 @@ function SortableTab(props: {
publication_uri: props.publicationUri,
page_id: props.page.id,
title: trimmedTitle,
- path: cleanPath(path) || "",
+ path: nextPath,
});
setSaving(false);
if (!result.success) return;
setPopoverOpen(false);
- props.onUpdated({ title: trimmedTitle, path: cleanPath(path) || "" });
+ props.onUpdated({ title: trimmedTitle, path: nextPath });
}
async function handleDelete() {
@@ -502,14 +536,13 @@ function SortableTab(props: {
: "text-tertiary hover:text-secondary"
}`}
>
-
{props.page.title || props.page.path || "/"}
-
+
setName(e.currentTarget.value)}
autoFocus
/>
-
setPath(e.currentTarget.value)}
- placeholder="/about"
- />
-
- Full page link
- {props.publicationUrl?.replace(/^https?:\/\//, "")}
- {cleanPath(path)}
-
+ {external ? (
+ setPath(e.currentTarget.value)}
+ placeholder="https://example.com"
+ />
+ ) : (
+ <>
+ setPath(e.currentTarget.value)}
+ placeholder="/about"
+ />
+
+ Full page link
+ {props.publicationUrl?.replace(/^https?:\/\//, "")}
+ {cleanPath(path)}
+
+ >
+ )}
{saving ? "Saving..." : "Save"}
diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx
index 8f073795..489eb40f 100644
--- a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx
+++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx
@@ -33,7 +33,8 @@ export default async function PublicationEditPage(props: Props) {
if (!publication) return ;
let page = publication.publication_pages?.find((p) => p.path === path);
- if (!page) return ;
+ // External link tabs have no backing leaflet document to edit.
+ if (!page || !page.leaflet_src) return ;
let { result: res } = await get_leaflet_data.handler(
{ token_id: page.leaflet_src },
diff --git a/app/(app)/lish/[did]/[publication]/page.tsx b/app/(app)/lish/[did]/[publication]/page.tsx
index ea25955d..ff569dbd 100644
--- a/app/(app)/lish/[did]/[publication]/page.tsx
+++ b/app/(app)/lish/[did]/[publication]/page.tsx
@@ -30,7 +30,7 @@ export default async function Publication(props: {
`*,
publication_subscriptions(*),
publication_newsletter_settings(enabled),
- publication_pages(id, path, title, record, record_uri, sort_order),
+ publication_pages(id, path, title, record, record_uri, sort_order, published_metadata),
documents_in_publications(documents(
*,
comments_on_documents(count),
diff --git a/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx b/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx
index 90f761dc..b6d587fe 100644
--- a/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx
+++ b/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx
@@ -2,6 +2,7 @@ import {
PublicationPageRenderer,
type PublicationPageRecord,
} from "./[rkey]/PublicationPageRenderer";
+import { findPublishedPage } from "src/utils/publishedPageMetadata";
type RendererProps = Parameters[0];
@@ -16,6 +17,7 @@ type PublicationForRenderer = Omit<
record: unknown;
record_uri: string | null;
sort_order: string;
+ published_metadata: unknown;
}[];
};
@@ -28,19 +30,16 @@ export function tryRenderPublicationPage({
publication: PublicationForRenderer;
path: string;
}) {
- const matchingPage = publication.publication_pages?.find(
- (p) => p.path === path && p.record_uri && p.record,
- );
- if (!matchingPage || !matchingPage.record) return null;
- const pageRecord =
- matchingPage.record as unknown as PublicationPageRecord;
+ const match = findPublishedPage(publication.publication_pages, path);
+ if (!match || !match.page.record_uri || !match.page.record) return null;
+ const pageRecord = match.page.record as unknown as PublicationPageRecord;
return (
({
identity: one(identities, {
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 129a87d8..0ec53ca9 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -77,6 +77,7 @@ export const facts = pgTable("facts", {
updated_at: timestamp("updated_at", { mode: 'string' }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
version: bigint("version", { mode: "number" }).default(0).notNull(),
+ author_did: text("author_did"),
},
(table) => {
return {
@@ -599,7 +600,7 @@ export const publication_pages = pgTable("publication_pages", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint("id", { mode: "number" }).notNull(),
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
- leaflet_src: uuid("leaflet_src").notNull().references(() => permission_tokens.id),
+ leaflet_src: uuid("leaflet_src").references(() => permission_tokens.id),
document: text("document").references(() => documents.uri),
path: text("path"),
publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade", onUpdate: "cascade" } ),
@@ -608,6 +609,7 @@ export const publication_pages = pgTable("publication_pages", {
record: jsonb("record"),
record_uri: text("record_uri"),
sort_order: text("sort_order").notNull(),
+ published_metadata: jsonb("published_metadata"),
},
(table) => {
return {
diff --git a/src/utils/externalPublicationLink.ts b/src/utils/externalPublicationLink.ts
new file mode 100644
index 00000000..f75694e5
--- /dev/null
+++ b/src/utils/externalPublicationLink.ts
@@ -0,0 +1,26 @@
+// Publication nav tabs are either a hosted publication page (stored as a
+// relative path like "/about") or an external link (stored as a full URL like
+// "https://example.com"). We tell them apart by whether the stored `path` is an
+// absolute http(s) URL — there's no separate column, the shape of the path is
+// the discriminator.
+
+export function isExternalLink(path: string | null | undefined): boolean {
+ if (!path) return false;
+ return /^https?:\/\//i.test(path.trim());
+}
+
+// Validate + normalize user input for an external link tab. Returns the
+// canonical URL string, or null if it isn't a usable absolute http(s) URL. Used
+// to enforce that external link tabs always store a full url.
+export function normalizeExternalLink(input: string): string | null {
+ let trimmed = input.trim();
+ if (!trimmed) return null;
+ let url: URL;
+ try {
+ url = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
+ return url.toString();
+}
diff --git a/src/utils/leafletToPublicationPageRecord.ts b/src/utils/leafletToPublicationPageRecord.ts
index e854fcf5..73b21451 100644
--- a/src/utils/leafletToPublicationPageRecord.ts
+++ b/src/utils/leafletToPublicationPageRecord.ts
@@ -54,13 +54,3 @@ export async function leafletToPublicationPageRecord(opts: {
},
};
}
-
-export function pathToRkey(path: string): string {
- let trimmed = path.replace(/^\/+/, "").replace(/\/+$/, "");
- if (trimmed === "") return "home";
- let slug = trimmed
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "");
- return slug || "home";
-}
diff --git a/src/utils/publishedPageMetadata.ts b/src/utils/publishedPageMetadata.ts
new file mode 100644
index 00000000..0e119c07
--- /dev/null
+++ b/src/utils/publishedPageMetadata.ts
@@ -0,0 +1,51 @@
+// A publication page's *published* nav state. Editing happens against the live
+// `path`/`title`/`sort_order` columns on the row (the draft); publishing
+// snapshots those into `published_metadata`, and the public site reads only
+// from this snapshot. That's what keeps draft edits to a nav tab — renaming it,
+// changing its path/url, reordering — out of production until the next publish.
+//
+// For external link tabs `path` holds the full URL (see isExternalLink).
+export type PublishedPageMetadata = {
+ path: string;
+ title: string;
+ sort_order: string;
+};
+
+export function getPublishedPageMetadata(
+ published_metadata: unknown,
+): PublishedPageMetadata | null {
+ let pm = published_metadata as Partial | null;
+ if (!pm || typeof pm.path !== "string" || typeof pm.sort_order !== "string")
+ return null;
+ return { path: pm.path, title: pm.title ?? "", sort_order: pm.sort_order };
+}
+
+// Find the page row whose *published* path matches the requested path. Matching
+// on the snapshot (not the live draft `path` column) is what keeps an
+// unpublished path edit from changing which URL serves the page — every public
+// reader (page body, metadata) must match this way or they'll disagree.
+export function findPublishedPage(
+ pages: T[] | null | undefined,
+ path: string,
+): { page: T; metadata: PublishedPageMetadata } | null {
+ for (let page of pages ?? []) {
+ let metadata = getPublishedPageMetadata(page.published_metadata);
+ if (metadata?.path === path) return { page, metadata };
+ }
+ return null;
+}
+
+// Map raw publication_pages rows to the published nav tabs the public site
+// renders. Rows without a published snapshot (never-published drafts) are
+// dropped. The returned fields are the *published* values, not the live
+// draft columns.
+export function publishedNavPages(
+ pages: { id: number; published_metadata: unknown }[] | null | undefined,
+): { id: number; path: string; title: string; sort_order: string }[] {
+ return (pages ?? []).flatMap((p) => {
+ let pm = getPublishedPageMetadata(p.published_metadata);
+ return pm
+ ? [{ id: p.id, path: pm.path, title: pm.title, sort_order: pm.sort_order }]
+ : [];
+ });
+}
diff --git a/supabase/database.types.ts b/supabase/database.types.ts
index 5c2053b4..b2621b7d 100644
--- a/supabase/database.types.ts
+++ b/supabase/database.types.ts
@@ -1276,10 +1276,11 @@ export type Database = {
created_at: string
document: string | null
id: number
- leaflet_src: string
+ leaflet_src: string | null
metadata: Json
path: string | null
publication: string
+ published_metadata: Json | null
record: Json | null
record_uri: string | null
sort_order: string
@@ -1289,10 +1290,11 @@ export type Database = {
created_at?: string
document?: string | null
id?: number
- leaflet_src: string
+ leaflet_src?: string | null
metadata?: Json
path?: string | null
publication: string
+ published_metadata?: Json | null
record?: Json | null
record_uri?: string | null
sort_order: string
@@ -1302,10 +1304,11 @@ export type Database = {
created_at?: string
document?: string | null
id?: number
- leaflet_src?: string
+ leaflet_src?: string | null
metadata?: Json
path?: string | null
publication?: string
+ published_metadata?: Json | null
record?: Json | null
record_uri?: string | null
sort_order?: string