diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts index d649929e..caba0b1b 100644 --- a/apps/client/src/features/share/types/share.types.ts +++ b/apps/client/src/features/share/types/share.types.ts @@ -35,9 +35,17 @@ export interface ISharedItem extends IShare { }; } -export interface ISharedPage extends IShare { - page: IPage; - share: IShare & { +// The `/shares/page-info` (anonymous) response. Mirrors the server-side +// PublicSharePayload allowlist (#218): the server trims `page`/`share` to these +// fields exactly, so the client type must not over-declare internal metadata it +// will never receive. Keep this in sync with share-public-payload.ts. +export interface ISharedPage { + page: Pick; + share: { + id: string; + key: string; + includeSubPages: boolean; + searchIndexing: boolean; level: number; sharedPage: { id: string; slugId: string; title: string; icon: string }; }; diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index ae5b4025..14477872 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -253,10 +253,7 @@ export class ShareService { workspaceId: string, ): Promise { // Fast path: the request names the page's own resolved share. - if ( - requestedShareId === resolvedShare.id || - requestedShareId.toLowerCase() === resolvedShare.key?.toLowerCase() - ) { + if (this.shareIdGrantsAccess(requestedShareId, resolvedShare)) { return true; } @@ -270,6 +267,23 @@ export class ShareService { return !!ancestor; } + /** + * Does the requested share id/key directly name `resolvedShare` — by id, or + * by key (case-insensitive)? This is the "names the page's OWN share" half of + * the access concept; ancestor includeSubPages shares are matched separately. + * Intentionally narrower than `resolveReadableSharePage`'s id-only gate, which + * keeps its own contract for the callers that pass a shareId there. + */ + private shareIdGrantsAccess( + requestedShareId: string, + resolvedShare: { id: string; key?: string | null }, + ): boolean { + return ( + requestedShareId === resolvedShare.id || + requestedShareId.toLowerCase() === resolvedShare.key?.toLowerCase() + ); + } + async getShareForPage(pageId: string, workspaceId: string) { // here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor const share = await this.db diff --git a/apps/server/src/integrations/export/utils.spec.ts b/apps/server/src/integrations/export/utils.spec.ts index f55ef4a6..625602bf 100644 --- a/apps/server/src/integrations/export/utils.spec.ts +++ b/apps/server/src/integrations/export/utils.spec.ts @@ -159,6 +159,14 @@ describe('getInternalLinkPageName', () => { expect(getInternalLinkPageName('docs/v1.2.md')).toBe('v1.2'); }); + it('documents current behavior: a leading-dot name collapses to empty text', () => { + // ".gitignore" -> base ".gitignore", parts ["", "gitignore"]: the leading + // dot is treated as a (empty) name + extension, so the name drops to "". + // Same bug class as #204, but unreachable via the sole caller (page titles + // never start with a dot), so we only pin the behavior — not fix it. + expect(getInternalLinkPageName('.gitignore')).toBe(''); + }); + it('falls back to the raw name without throwing on malformed encoding', () => { // "%E0%A4" is an incomplete escape; decodeURIComponent throws and the // helper returns the raw (still-encoded) name. diff --git a/packages/editor-ext/src/lib/markdown/utils/callout-common.marked.ts b/packages/editor-ext/src/lib/markdown/utils/callout-common.marked.ts new file mode 100644 index 00000000..2803bc3e --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/callout-common.marked.ts @@ -0,0 +1,33 @@ +/** + * Shared pieces for the two callout tokenizers — `callout.marked.ts` (the + * `:::type` fenced form) and `github-callout.marked.ts` (the `> [!type]` GitHub + * alert form). Both emit the SAME callout node, so the banner type dictionary + * and the HTML renderer live here once instead of drifting apart in two files. + * The tokenizers themselves stay separate (different syntaxes / source matching). + */ + +/** The four callout banner types the editor schema supports. */ +export const CALLOUT_TYPES = ['info', 'success', 'warning', 'danger'] as const; + +export type CalloutType = (typeof CALLOUT_TYPES)[number]; + +/** + * Coerce an arbitrary type name onto a supported banner type, defaulting to + * `info` for anything unrecognized (the shared fallback both tokenizers use). + */ +export function normalizeCalloutType(type: string): CalloutType { + return (CALLOUT_TYPES as readonly string[]).includes(type) + ? (type as CalloutType) + : 'info'; +} + +/** + * Render a callout node to the editor's HTML shape. `body` is the already + * markdown-parsed inner content (marked may hand back a string synchronously). + */ +export function renderCalloutHtml( + type: string, + body: string | Promise, +): string { + return `
${body}
`; +} diff --git a/packages/editor-ext/src/lib/markdown/utils/callout.marked.ts b/packages/editor-ext/src/lib/markdown/utils/callout.marked.ts index 35ce0d69..2c0860cb 100644 --- a/packages/editor-ext/src/lib/markdown/utils/callout.marked.ts +++ b/packages/editor-ext/src/lib/markdown/utils/callout.marked.ts @@ -1,4 +1,5 @@ import { Token, marked } from 'marked'; +import { normalizeCalloutType, renderCalloutHtml } from './callout-common.marked'; interface CalloutToken { type: 'callout'; @@ -17,16 +18,10 @@ export const calloutExtension = { const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/; const match = rule.exec(src); - const validCalloutTypes = ['info', 'success', 'warning', 'danger']; - if (match) { - let type = match[1]; - if (!validCalloutTypes.includes(type)) { - type = 'info'; - } return { type: 'callout', - calloutType: type, + calloutType: normalizeCalloutType(match[1]), raw: match[0], text: match[2].trim(), }; @@ -34,8 +29,9 @@ export const calloutExtension = { }, renderer(token: Token) { const calloutToken = token as CalloutToken; - const body = marked.parse(calloutToken.text); - - return `
${body}
`; + return renderCalloutHtml( + calloutToken.calloutType, + marked.parse(calloutToken.text), + ); }, }; diff --git a/packages/editor-ext/src/lib/markdown/utils/github-callout.marked.test.ts b/packages/editor-ext/src/lib/markdown/utils/github-callout.marked.test.ts index 2a836974..c5abe59b 100644 --- a/packages/editor-ext/src/lib/markdown/utils/github-callout.marked.test.ts +++ b/packages/editor-ext/src/lib/markdown/utils/github-callout.marked.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { markdownToHtml } from "./marked.utils"; /** - * Regression for issue #218: pasting a GitHub-style `> [!type]` alert produced a + * Regression for issue #192: pasting a GitHub-style `> [!type]` alert produced a * literal `
` containing `[!info]` instead of a callout node, because * only the `:::type` form was tokenized. The editor paste path runs the same * `markdownToHtml`, so these assertions pin the conversion at the source. diff --git a/packages/editor-ext/src/lib/markdown/utils/github-callout.marked.ts b/packages/editor-ext/src/lib/markdown/utils/github-callout.marked.ts index 558d3960..f18548ac 100644 --- a/packages/editor-ext/src/lib/markdown/utils/github-callout.marked.ts +++ b/packages/editor-ext/src/lib/markdown/utils/github-callout.marked.ts @@ -1,4 +1,5 @@ import { Token, marked } from 'marked'; +import { renderCalloutHtml } from './callout-common.marked'; interface GithubCalloutToken { type: 'githubCallout'; @@ -36,7 +37,7 @@ const GITHUB_ALERT_TYPE_MAP: Record = { * Without this, the default blockquote tokenizer wins and the marker renders as * a literal `[!info]` inside a `
`. The editor's paste path runs the * same `markdownToHtml`, so registering this here also fixes pasting the syntax - * into the editor (issue #218), not just markdown import. + * into the editor (issue #192), not just markdown import. */ export const githubCalloutExtension = { name: 'githubCallout', @@ -72,7 +73,9 @@ export const githubCalloutExtension = { }, renderer(token: Token) { const calloutToken = token as GithubCalloutToken; - const body = marked.parse(calloutToken.text); - return `
${body}
`; + return renderCalloutHtml( + calloutToken.calloutType, + marked.parse(calloutToken.text), + ); }, };