Keep listed published rules public and guest-claim rules unlisted, with semantic document markup and share chrome that stays clickable.
Guest publishes remain share-by-link but stay out of the catalog and sitemap, and their pages send noindex until claimed. The published-rule view now uses heading and list markup matching Markdown export, and top banners sit below the header so Share is not covered. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
77b0ec8ad8
commit
1445cb7e1e
27 files changed
+524
-86
No files matched your search
+13
-4
@@ -17,11 +17,13 @@ const STATIC_PUBLIC_PATHS = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Public marketing URLs for `/sitemap.xml`. Omits signed-in product routes
|
||||
* (`/create`, `/login`, `/profile`), admin, and `/rules/[id]` (those ids are
|
||||
* not known without the database).
|
||||
* Public marketing URLs for `/sitemap.xml`, plus listed published rules when
|
||||
* the caller supplies them. Omits signed-in product routes (`/create`,
|
||||
* `/login`, `/profile`), admin, and unlisted guest-claim `/rules/[id]`.
|
||||
*/
|
||||
export function buildPublicSitemap(): MetadataRoute.Sitemap {
|
||||
export function buildPublicSitemap(
|
||||
listedRules: ReadonlyArray<{ id: string; updatedAt: Date }> = [],
|
||||
): MetadataRoute.Sitemap {
|
||||
const entries: MetadataRoute.Sitemap = STATIC_PUBLIC_PATHS.map((path) => ({
|
||||
url: sitemapUrl(path),
|
||||
}));
|
||||
@@ -38,5 +40,12 @@ export function buildPublicSitemap(): MetadataRoute.Sitemap {
|
||||
entries.push({ url: sitemapUrl(`/use-cases/${slug}/rule`) });
|
||||
}
|
||||
|
||||
for (const rule of listedRules) {
|
||||
entries.push({
|
||||
url: sitemapUrl(`/rules/${encodeURIComponent(rule.id)}`),
|
||||
lastModified: rule.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Access model for published CommunityRules:
|
||||
*
|
||||
* - **Readable without sign-in:** every published row is share-by-link at
|
||||
* `/rules/[id]` and `GET /api/rules/[id]`.
|
||||
* - **Listed** (public catalog `GET /api/rules`, sitemap, indexable): owned
|
||||
* rows, and ownerless rows that are *not* waiting on a guest claim
|
||||
* (including rules orphaned when an account is deleted).
|
||||
* - **Unlisted** (omitted from the catalog and sitemap, `noindex`): guest
|
||||
* publishes with `userId` null and `claimTokenHash` set, until claimed.
|
||||
*/
|
||||
export function isListedPublishedRule(row: {
|
||||
userId: string | null;
|
||||
claimTokenHash: string | null;
|
||||
}): boolean {
|
||||
return row.userId != null || row.claimTokenHash == null;
|
||||
}
|
||||
|
||||
/** Prisma `where` for the public catalog and sitemap. */
|
||||
export const LISTED_PUBLISHED_RULE_WHERE: Prisma.PublishedRuleWhereInput = {
|
||||
OR: [{ userId: { not: null } }, { claimTokenHash: null }],
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
import {
|
||||
LISTED_PUBLISHED_RULE_WHERE,
|
||||
isListedPublishedRule,
|
||||
} from "../publishedRuleAccess";
|
||||
import { prisma } from "./db";
|
||||
import { isDatabaseConfigured } from "./env";
|
||||
|
||||
/**
|
||||
* Public fields safe to expose via the unauthenticated rule detail surfaces
|
||||
* (`GET /api/rules/[id]` and `/rules/[id]`). `userId` is intentionally omitted.
|
||||
* (`GET /api/rules/[id]` and `/rules/[id]`). `userId` and `claimTokenHash`
|
||||
* stay server-side; callers get {@link PublicPublishedRule.isListed} instead.
|
||||
*/
|
||||
const PUBLISHED_RULE_PUBLIC_SELECT = {
|
||||
id: true,
|
||||
@@ -21,6 +26,8 @@ export type PublicPublishedRule = {
|
||||
document: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
/** False for guest-claim rows; true for owned and orphaned-after-delete rows. */
|
||||
isListed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -39,9 +46,18 @@ export async function getPublicPublishedRuleById(
|
||||
try {
|
||||
const rule = await prisma.publishedRule.findUnique({
|
||||
where: { id },
|
||||
select: PUBLISHED_RULE_PUBLIC_SELECT,
|
||||
select: {
|
||||
...PUBLISHED_RULE_PUBLIC_SELECT,
|
||||
userId: true,
|
||||
claimTokenHash: true,
|
||||
},
|
||||
});
|
||||
return rule;
|
||||
if (!rule) return null;
|
||||
const { userId, claimTokenHash, ...publicFields } = rule;
|
||||
return {
|
||||
...publicFields,
|
||||
isListed: isListedPublishedRule({ userId, claimTokenHash }),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -64,6 +80,61 @@ export type OwnerPublishedRuleListItem = {
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const LISTED_RULE_SITEMAP_SELECT = {
|
||||
id: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
const LISTED_PUBLISHED_RULE_SITEMAP_CAP = 10_000;
|
||||
|
||||
/**
|
||||
* Public catalog of listed published rules (**most recently updated first**,
|
||||
* then stable `id` tie-break). Guest-claim rows are omitted.
|
||||
* Returns `null` when the database is not configured or the query throws.
|
||||
*/
|
||||
export async function listListedPublishedRules(
|
||||
take: number,
|
||||
): Promise<OwnerPublishedRuleListItem[] | null> {
|
||||
if (!isDatabaseConfigured()) return null;
|
||||
const clamped = Math.min(Math.max(0, take), 100);
|
||||
if (clamped === 0) return [];
|
||||
try {
|
||||
return await prisma.publishedRule.findMany({
|
||||
where: LISTED_PUBLISHED_RULE_WHERE,
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||
take: clamped,
|
||||
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listed published-rule ids for `/sitemap.xml`. Empty when the database is
|
||||
* not configured or the query throws (marketing URLs still render).
|
||||
*/
|
||||
export async function listListedPublishedRulesForSitemap(
|
||||
take: number = LISTED_PUBLISHED_RULE_SITEMAP_CAP,
|
||||
): Promise<Array<{ id: string; updatedAt: Date }>> {
|
||||
if (!isDatabaseConfigured()) return [];
|
||||
const clamped = Math.min(
|
||||
Math.max(0, take),
|
||||
LISTED_PUBLISHED_RULE_SITEMAP_CAP,
|
||||
);
|
||||
if (clamped === 0) return [];
|
||||
try {
|
||||
return await prisma.publishedRule.findMany({
|
||||
where: LISTED_PUBLISHED_RULE_WHERE,
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||
take: clamped,
|
||||
select: LISTED_RULE_SITEMAP_SELECT,
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists published rules owned by the given user (**most recently updated first**,
|
||||
* then stable `id` tie-break).
|
||||
|
||||
@@ -12,6 +12,16 @@ const INDEXABLE_HOSTS = new Set([
|
||||
|
||||
export const NO_INDEX_ROBOTS = { index: false, follow: false } as const;
|
||||
|
||||
/** Unlisted (guest-claim) rule pages stay shareable but must not be indexed. */
|
||||
export function robotsForPublishedRule(isListed: boolean):
|
||||
| {
|
||||
index: false;
|
||||
follow: false;
|
||||
}
|
||||
| undefined {
|
||||
return isListed ? undefined : NO_INDEX_ROBOTS;
|
||||
}
|
||||
|
||||
function hostnameFromHostHeader(host: string | null | undefined): string {
|
||||
if (!host) return "";
|
||||
return host.split(",")[0]?.trim().split(":")[0]?.toLowerCase() ?? "";
|
||||
|
||||
Reference in new issue
Block a user