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 {
|
// The `/shares/page-info` (anonymous) response. Mirrors the server-side
|
||||||
page: IPage;
|
// PublicSharePayload allowlist (#218): the server trims `page`/`share` to these
|
||||||
share: IShare & {
|
// 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;
|
level: number;
|
||||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -253,10 +253,7 @@ export class ShareService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Fast path: the request names the page's own resolved share.
|
// Fast path: the request names the page's own resolved share.
|
||||||
if (
|
if (this.shareIdGrantsAccess(requestedShareId, resolvedShare)) {
|
||||||
requestedShareId === resolvedShare.id ||
|
|
||||||
requestedShareId.toLowerCase() === resolvedShare.key?.toLowerCase()
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +267,23 @@ export class ShareService {
|
|||||||
return !!ancestor;
|
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) {
|
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
|
// 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
|
const share = await this.db
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ describe('getInternalLinkPageName', () => {
|
|||||||
expect(getInternalLinkPageName('docs/v1.2.md')).toBe('v1.2');
|
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', () => {
|
it('falls back to the raw name without throwing on malformed encoding', () => {
|
||||||
// "%E0%A4" is an incomplete escape; decodeURIComponent throws and the
|
// "%E0%A4" is an incomplete escape; decodeURIComponent throws and the
|
||||||
// helper returns the raw (still-encoded) name.
|
// 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 { Token, marked } from 'marked';
|
||||||
|
import { normalizeCalloutType, renderCalloutHtml } from './callout-common.marked';
|
||||||
|
|
||||||
interface CalloutToken {
|
interface CalloutToken {
|
||||||
type: 'callout';
|
type: 'callout';
|
||||||
@@ -17,16 +18,10 @@ export const calloutExtension = {
|
|||||||
const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/;
|
const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/;
|
||||||
const match = rule.exec(src);
|
const match = rule.exec(src);
|
||||||
|
|
||||||
const validCalloutTypes = ['info', 'success', 'warning', 'danger'];
|
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
let type = match[1];
|
|
||||||
if (!validCalloutTypes.includes(type)) {
|
|
||||||
type = 'info';
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
type: 'callout',
|
type: 'callout',
|
||||||
calloutType: type,
|
calloutType: normalizeCalloutType(match[1]),
|
||||||
raw: match[0],
|
raw: match[0],
|
||||||
text: match[2].trim(),
|
text: match[2].trim(),
|
||||||
};
|
};
|
||||||
@@ -34,8 +29,9 @@ export const calloutExtension = {
|
|||||||
},
|
},
|
||||||
renderer(token: Token) {
|
renderer(token: Token) {
|
||||||
const calloutToken = token as CalloutToken;
|
const calloutToken = token as CalloutToken;
|
||||||
const body = marked.parse(calloutToken.text);
|
return renderCalloutHtml(
|
||||||
|
calloutToken.calloutType,
|
||||||
return `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
|
marked.parse(calloutToken.text),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { markdownToHtml } from "./marked.utils";
|
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
|
* literal `<blockquote>` containing `[!info]` instead of a callout node, because
|
||||||
* only the `:::type` form was tokenized. The editor paste path runs the same
|
* only the `:::type` form was tokenized. The editor paste path runs the same
|
||||||
* `markdownToHtml`, so these assertions pin the conversion at the source.
|
* `markdownToHtml`, so these assertions pin the conversion at the source.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Token, marked } from 'marked';
|
import { Token, marked } from 'marked';
|
||||||
|
import { renderCalloutHtml } from './callout-common.marked';
|
||||||
|
|
||||||
interface GithubCalloutToken {
|
interface GithubCalloutToken {
|
||||||
type: 'githubCallout';
|
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
|
* 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
|
* a literal `[!info]` inside a `<blockquote>`. The editor's paste path runs the
|
||||||
* same `markdownToHtml`, so registering this here also fixes pasting the syntax
|
* 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 = {
|
export const githubCalloutExtension = {
|
||||||
name: 'githubCallout',
|
name: 'githubCallout',
|
||||||
@@ -72,7 +73,9 @@ export const githubCalloutExtension = {
|
|||||||
},
|
},
|
||||||
renderer(token: Token) {
|
renderer(token: Token) {
|
||||||
const calloutToken = token as GithubCalloutToken;
|
const calloutToken = token as GithubCalloutToken;
|
||||||
const body = marked.parse(calloutToken.text);
|
return renderCalloutHtml(
|
||||||
return `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
|
calloutToken.calloutType,
|
||||||
|
marked.parse(calloutToken.text),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user