refactor(review): address #230 third review — callout dedup, ticket/type tidy
Approve-with-comments follow-ups (no blockers): - callout: unify the GitHub-callout feature ticket on #192 (the callout-paste feature the CHANGELOG already tracks); #218 is the public-share security work. Fixed the code comment and test reference. - export/utils.spec: pin current behavior of a leading-dot name (".gitignore" -> "") — same bug class as #204 but unreachable via the sole caller, so document not change. - share.types: narrow ISharedPage to the actual /shares/page-info allowlist (page -> Pick of id/slugId/title/icon/content; trimmed share; dropped the spurious `extends IShare`). Verified all three consumers (shared-page, link-view, mention-view) read only allowlist fields. - editor-ext: extract shared CALLOUT_TYPES / normalizeCalloutType / renderCalloutHtml into callout-common.marked.ts; both tokenizers (`:::type` and `> [!type]`) now share the renderer + type dict while staying separate. Eliminates the byte-identical renderer + duplicated type list. - share.service: extract named predicate shareIdGrantsAccess(requestedShareId, resolvedShare) for the id-or-key fast path (naming only, no control-flow change); kept narrower than resolveReadableSharePage's id-only gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<IPage, "id" | "slugId" | "title" | "icon" | "content">;
|
||||
share: {
|
||||
id: string;
|
||||
key: string;
|
||||
includeSubPages: boolean;
|
||||
searchIndexing: boolean;
|
||||
level: number;
|
||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||
};
|
||||
|
||||
@@ -253,10 +253,7 @@ export class ShareService {
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
// 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
): string {
|
||||
return `<div data-type="callout" data-callout-type="${type}">${body}</div>`;
|
||||
}
|
||||
@@ -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 `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
|
||||
return renderCalloutHtml(
|
||||
calloutToken.calloutType,
|
||||
marked.parse(calloutToken.text),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 `<blockquote>` 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.
|
||||
|
||||
@@ -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<string, string> = {
|
||||
* Without this, the default blockquote tokenizer wins and the marker renders as
|
||||
* a literal `[!info]` inside a `<blockquote>`. 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 `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
|
||||
return renderCalloutHtml(
|
||||
calloutToken.calloutType,
|
||||
marked.parse(calloutToken.text),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user