@codepress/blog 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -2
- package/dist/blog.css +227 -0
- package/dist/chunks/BlogEditor-VF3UAJB6.js +2527 -0
- package/dist/chunks/chunk-3GCZFCUK.js +51 -0
- package/dist/chunks/chunk-3WRTL63O.js +106 -0
- package/dist/chunks/chunk-AEQDRBA3.js +128 -0
- package/dist/chunks/chunk-AJVVTSKZ.js +90 -0
- package/dist/chunks/chunk-EICE5ONB.js +197 -0
- package/dist/chunks/chunk-HWR3R5MU.js +48 -0
- package/dist/chunks/chunk-QX6276YA.js +0 -0
- package/dist/chunks/chunk-TYX2HJZC.js +68 -0
- package/dist/chunks/chunk-UA243KAQ.js +104 -0
- package/dist/chunks/chunk-WJVZD4UI.js +114 -0
- package/dist/chunks/chunk-XLJBCIXI.js +93 -0
- package/dist/cli.js +440 -0
- package/dist/content-source.d.ts +31 -0
- package/dist/editor/BlogEditor.d.ts +37 -0
- package/dist/editor/BlogEditorMount.d.ts +64 -0
- package/dist/editor/DocumentActions.d.ts +39 -0
- package/dist/editor/EditDesignLink.d.ts +30 -0
- package/dist/editor/NewPostForm.d.ts +10 -0
- package/dist/editor/SlashMenu.d.ts +5 -0
- package/dist/editor/Toolbar.d.ts +39 -0
- package/dist/editor/blocks.d.ts +5 -0
- package/dist/editor/client.d.ts +88 -0
- package/dist/editor/client.js +6 -0
- package/dist/editor/component-node.d.ts +14 -0
- package/dist/editor/config.d.ts +24 -0
- package/dist/editor/document.d.ts +47 -0
- package/dist/editor/extensions.d.ts +19 -0
- package/dist/editor/header.d.ts +20 -0
- package/dist/editor/index.d.ts +11 -0
- package/dist/editor/index.js +209 -0
- package/dist/editor/paste-guard.d.ts +14 -0
- package/dist/editor/primitives.d.ts +17 -0
- package/dist/editor/props-form.d.ts +17 -0
- package/dist/editor/serialize.d.ts +34 -0
- package/dist/editor/slash-command.d.ts +21 -0
- package/dist/feed.d.ts +4 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +72 -0
- package/dist/next/app.d.ts +34 -0
- package/dist/next/config.d.ts +44 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +94 -0
- package/dist/next/pages.d.ts +33 -0
- package/dist/next/pages.js +167 -0
- package/dist/next/routes.d.ts +14 -0
- package/dist/next/routes.js +37 -0
- package/dist/next/views.d.ts +18 -0
- package/dist/react/components.d.ts +18 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +15 -0
- package/dist/react/render.d.ts +7 -0
- package/dist/registry.d.ts +95 -0
- package/dist/sanitize.d.ts +33 -0
- package/dist/schema.d.ts +24 -0
- package/dist/seo.d.ts +63 -0
- package/dist/sitemap.d.ts +4 -0
- package/dist/xml.d.ts +1 -0
- package/package.json +92 -4
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Editor } from "@tiptap/react";
|
|
2
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
3
|
+
import { type BlogEditorHeader } from "./header.js";
|
|
4
|
+
/** A 32px control: icon buttons, menu triggers and the document actions share it. */
|
|
5
|
+
export declare const toolClass = "inline-flex h-8 min-w-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-[#4A4740] transition-colors hover:bg-[#F1EFE9] hover:text-[#111110] focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40 data-[active=true]:bg-[#E8E5DC] data-[active=true]:text-[#111110] data-[state=open]:bg-[#F1EFE9] data-[state=open]:text-[#111110]";
|
|
6
|
+
/** The menus match the site header's dropdown. */
|
|
7
|
+
export declare const menuClass = "rounded-xl border-[#E6E3DA] bg-white p-1.5 text-[#111110] shadow-[0_8px_24px_-8px_rgba(10,10,10,0.16)]";
|
|
8
|
+
export declare const menuItemClass = "flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] focus:bg-[#F1EFE9] focus:text-[#111110]";
|
|
9
|
+
export declare function ToolbarSeparator({ className }: {
|
|
10
|
+
className?: string;
|
|
11
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
/** A hover and focus label, with the keyboard shortcut when there is one. */
|
|
13
|
+
export declare function Tip({ label, shortcut, children, }: {
|
|
14
|
+
label: string;
|
|
15
|
+
shortcut?: string;
|
|
16
|
+
children: ReactElement;
|
|
17
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
/** `Mod-Shift-8` as the platform writes it: `⌘⇧8` on Apple devices, `Ctrl+Shift+8` elsewhere. */
|
|
19
|
+
export declare function formatShortcut(combo: string, mac: boolean): string;
|
|
20
|
+
export interface ToolbarProps {
|
|
21
|
+
editor: Editor;
|
|
22
|
+
/** Publishing or discarding: nothing may change the post. */
|
|
23
|
+
frozen: boolean;
|
|
24
|
+
/** Focus is in the title or the metadata, which are plain text. */
|
|
25
|
+
formattingOff: boolean;
|
|
26
|
+
linkOpen: boolean;
|
|
27
|
+
onLinkOpenChange: (open: boolean) => void;
|
|
28
|
+
/** The document actions (save status, Ask CodePress, Publish…), at the end of the row. */
|
|
29
|
+
actions?: ReactNode;
|
|
30
|
+
/** The host page's header the toolbar pins below. */
|
|
31
|
+
header?: BlogEditorHeader;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The editor's one toolbar. It spans the page directly under the site header and
|
|
35
|
+
* stays pinned there: its `top` is the header's measured height, so it can never
|
|
36
|
+
* slide under it. Formatting sits over the reading column; the document actions
|
|
37
|
+
* line up with the header's own right edge.
|
|
38
|
+
*/
|
|
39
|
+
export declare function Toolbar({ editor, frozen, formattingOff, linkOpen, onLinkOpenChange, actions, header, }: ToolbarProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SlashItem } from "./slash-command.js";
|
|
2
|
+
/** Every block the `/` menu offers. */
|
|
3
|
+
export declare const SLASH_ITEMS: SlashItem[];
|
|
4
|
+
/** The toolbar's Insert menu: the same items as the `/` menu, minus the text blocks. */
|
|
5
|
+
export declare const INSERT_ITEMS: SlashItem[];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The draft API client, with no editor code in it: a host can read what the
|
|
3
|
+
* caller may do, or publish, without downloading Tiptap.
|
|
4
|
+
*/
|
|
5
|
+
/** Supplies the caller's CodePress token; the client never reads storage itself. */
|
|
6
|
+
export interface BlogEditorAuth {
|
|
7
|
+
getToken(): Promise<string | null>;
|
|
8
|
+
/** Sees every response, for a host that refreshes its token from a response header. */
|
|
9
|
+
onResponse?(response: Response): void;
|
|
10
|
+
}
|
|
11
|
+
/** The draft API's shapes; see `apps/backend/apps/content_sites/schemas.py`. */
|
|
12
|
+
export interface DraftFile {
|
|
13
|
+
slug: string;
|
|
14
|
+
/** `draft` (the branch's file), `published` (the default branch's) or `new`. */
|
|
15
|
+
source: "draft" | "published" | "new";
|
|
16
|
+
html: string;
|
|
17
|
+
/** A git site's file blob sha; a database site's revision id. */
|
|
18
|
+
base_sha: string | null;
|
|
19
|
+
branch: string;
|
|
20
|
+
head_sha: string | null;
|
|
21
|
+
pr_number: number | null;
|
|
22
|
+
pr_url: string | null;
|
|
23
|
+
/** Where the site keeps its posts; absent (an older backend) means git. */
|
|
24
|
+
storage?: "git" | "db";
|
|
25
|
+
/** A database site's per-post revision count, from 1; null on a git site. */
|
|
26
|
+
revision?: number | null;
|
|
27
|
+
}
|
|
28
|
+
export interface Violation {
|
|
29
|
+
kind: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
tag?: string | null;
|
|
32
|
+
attribute?: string | null;
|
|
33
|
+
prop?: string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface Capabilities {
|
|
36
|
+
can_edit: boolean;
|
|
37
|
+
can_publish: boolean;
|
|
38
|
+
/** The site's organization; present when the caller can edit. */
|
|
39
|
+
organization_id?: string | null;
|
|
40
|
+
/** The org that owns the site and the repo its posts live in; set for editors only. */
|
|
41
|
+
organization_slug?: string | null;
|
|
42
|
+
repository_full_name?: string | null;
|
|
43
|
+
}
|
|
44
|
+
export type SaveResult = {
|
|
45
|
+
kind: "saved";
|
|
46
|
+
draft: DraftFile;
|
|
47
|
+
} | {
|
|
48
|
+
kind: "conflict";
|
|
49
|
+
current: DraftFile;
|
|
50
|
+
} | {
|
|
51
|
+
kind: "rejected";
|
|
52
|
+
detail: string;
|
|
53
|
+
violations: Violation[];
|
|
54
|
+
} | {
|
|
55
|
+
kind: "error";
|
|
56
|
+
message: string;
|
|
57
|
+
};
|
|
58
|
+
export type ActionResult = {
|
|
59
|
+
kind: "ok";
|
|
60
|
+
} | {
|
|
61
|
+
kind: "rejected";
|
|
62
|
+
detail: string;
|
|
63
|
+
violations: Violation[];
|
|
64
|
+
} | {
|
|
65
|
+
kind: "error";
|
|
66
|
+
message: string;
|
|
67
|
+
status?: number;
|
|
68
|
+
};
|
|
69
|
+
export interface DraftClient {
|
|
70
|
+
getCapabilities(site: string): Promise<Capabilities | null>;
|
|
71
|
+
getDraft(site: string, slug: string): Promise<{
|
|
72
|
+
draft: DraftFile;
|
|
73
|
+
} | {
|
|
74
|
+
error: string;
|
|
75
|
+
}>;
|
|
76
|
+
saveDraft(site: string, slug: string, html: string, baseSha: string | null): Promise<SaveResult>;
|
|
77
|
+
publishDraft(site: string, slug: string): Promise<ActionResult>;
|
|
78
|
+
discardDraft(site: string, slug: string): Promise<ActionResult>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A client for the content-site draft API at `apiBase` (the backend's `/v1`
|
|
82
|
+
* root). Every request carries `auth.getToken()` as its bearer token, and only
|
|
83
|
+
* that one.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createDraftClient({ apiBase, auth, }: {
|
|
86
|
+
apiBase: string;
|
|
87
|
+
auth: BlogEditorAuth;
|
|
88
|
+
}): DraftClient;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Node } from "@tiptap/react";
|
|
2
|
+
import { type ComponentTag } from "../index.js";
|
|
3
|
+
/** Props a component starts with when inserted from the slash menu. */
|
|
4
|
+
export declare const DEFAULT_PROPS: Record<ComponentTag, Record<string, unknown>>;
|
|
5
|
+
/** `cp-callout` -> `cpCallout`. */
|
|
6
|
+
export declare function componentNodeName(tag: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* One editor node per registry component: parsed from and rendered to the
|
|
9
|
+
* same `<cp-*>` tag the post file uses, with the props JSON kept in one
|
|
10
|
+
* attribute and edited through a form generated from the zod schema.
|
|
11
|
+
*/
|
|
12
|
+
export declare function componentNode(tag: ComponentTag): Node<{
|
|
13
|
+
tag: "cp-callout" | "cp-figure";
|
|
14
|
+
}, any>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor timings and layout defaults. Every timing has a production default
|
|
3
|
+
* and a prop override (`timings`), so a host or QA can compress the clock.
|
|
4
|
+
*/
|
|
5
|
+
export interface BlogEditorTimings {
|
|
6
|
+
/** Quiet time after the last keystroke before the draft is saved. */
|
|
7
|
+
autosaveMs: number;
|
|
8
|
+
/** How often an open editor checks whether the draft changed elsewhere. */
|
|
9
|
+
draftPollMs: number;
|
|
10
|
+
/** How long the "published" notice shows before `onPublished` runs. */
|
|
11
|
+
publishReloadMs: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const DEFAULT_TIMINGS: BlogEditorTimings;
|
|
14
|
+
/** The defaults, with each override that is a positive number. */
|
|
15
|
+
export declare function resolveTimings(timings: Partial<BlogEditorTimings> | undefined): BlogEditorTimings;
|
|
16
|
+
/** The reading column the editor lays its title and body out in. */
|
|
17
|
+
export declare const DEFAULT_COLUMN = "mx-auto w-full max-w-[720px] px-5 sm:px-8";
|
|
18
|
+
/** Where the CodePress app lives: Ask CodePress, Edit design and History open there. */
|
|
19
|
+
export declare const DEFAULT_APP_URL = "https://codepress.dev";
|
|
20
|
+
/**
|
|
21
|
+
* CSS variable the toolbar sets on the document root while it is mounted: the
|
|
22
|
+
* viewport y of its bottom edge. Floating panels (Ask CodePress) keep below it.
|
|
23
|
+
*/
|
|
24
|
+
export declare const BLOG_TOOLBAR_BOTTOM_VAR = "--blog-toolbar-bottom";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The post file as text, split so an edit only rewrites the blocks that
|
|
3
|
+
* changed. Everything the author wrote around an unchanged block (its own
|
|
4
|
+
* line wrapping, the blank lines between blocks) is kept byte for byte, so a
|
|
5
|
+
* draft PR's diff shows the edited blocks and nothing else.
|
|
6
|
+
*/
|
|
7
|
+
export interface PostSource {
|
|
8
|
+
/** Text before the metadata block (normally empty). */
|
|
9
|
+
prefix: string;
|
|
10
|
+
/** The metadata block exactly as written, or `""` when there is none. */
|
|
11
|
+
metaText: string;
|
|
12
|
+
meta: Record<string, unknown>;
|
|
13
|
+
blocks: SourceBlock[];
|
|
14
|
+
/** `gaps[i]` is the text before `blocks[i]`; the last entry trails the file. */
|
|
15
|
+
gaps: string[];
|
|
16
|
+
/** Reasons this file cannot be edited in place; empty when it can. */
|
|
17
|
+
problems: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface SourceBlock {
|
|
20
|
+
id: string | null;
|
|
21
|
+
tag: string;
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
/** Splits a post file into its metadata block and top-level body blocks. */
|
|
25
|
+
export declare function parsePostSource(source: string): PostSource;
|
|
26
|
+
/** The metadata block in the layout the seed post uses. */
|
|
27
|
+
export declare function serializeMeta(meta: Record<string, unknown>): string;
|
|
28
|
+
/**
|
|
29
|
+
* A body block after editing. `sourceIndex` is set when it is unchanged;
|
|
30
|
+
* `replaces` is the source block an edited block came from (unset for a new one).
|
|
31
|
+
*/
|
|
32
|
+
export interface EditedBlock {
|
|
33
|
+
html: string;
|
|
34
|
+
sourceIndex: number | null;
|
|
35
|
+
replaces?: number | null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Writes the post file. Unchanged blocks and the metadata block (when its
|
|
39
|
+
* values did not change) keep their original text and spacing.
|
|
40
|
+
*/
|
|
41
|
+
export declare function assemblePostFile(source: PostSource | null, meta: Record<string, unknown>, blocks: EditedBlock[]): string;
|
|
42
|
+
/**
|
|
43
|
+
* A comparison form of a block: the same for two spellings of the same
|
|
44
|
+
* content (whitespace, attribute order, quoting, entity form), and ignoring
|
|
45
|
+
* `data-cp-id`, which the editor assigns to blocks that have none.
|
|
46
|
+
*/
|
|
47
|
+
export declare function canonicalBlock(html: string): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Extensions } from "@tiptap/react";
|
|
2
|
+
import { type SlashItem, type SlashState } from "./slash-command.js";
|
|
3
|
+
export declare function generateBlockId(): string;
|
|
4
|
+
/** UniqueID names the node attribute `cp-id` and writes it to HTML as `data-cp-id`. */
|
|
5
|
+
export declare const BLOCK_ID_NODE_ATTRIBUTE: string;
|
|
6
|
+
export interface EditorHooks {
|
|
7
|
+
isFrozen?: () => boolean;
|
|
8
|
+
onSlash?: (state: SlashState | null) => void;
|
|
9
|
+
onPasteRejected?: (reasons: string[]) => void;
|
|
10
|
+
slashItems?: SlashItem[];
|
|
11
|
+
onLinkShortcut?: () => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Everything a post may contain and nothing else: the elements the registry
|
|
15
|
+
* allows, one node per registered component, and a stable `data-cp-id` on each
|
|
16
|
+
* block. Marks and nodes outside the schema (strike, underline, tables) are
|
|
17
|
+
* turned off so the editor cannot produce content the backend would reject.
|
|
18
|
+
*/
|
|
19
|
+
export declare function editorExtensions(hooks?: EditorHooks): Extensions;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
/** The host page's header, which the toolbar pins directly below. */
|
|
3
|
+
export interface BlogEditorHeader {
|
|
4
|
+
/** The header element; defaults to `[data-blog-header]`, then the page's first `header`. */
|
|
5
|
+
selector?: string;
|
|
6
|
+
/** A fixed distance from the top of the viewport, used instead of measuring a header. */
|
|
7
|
+
offset?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_HEADER_SELECTOR = "[data-blog-header]";
|
|
10
|
+
/**
|
|
11
|
+
* The host's header: the selector's match, else the first `header` element
|
|
12
|
+
* outside the editor itself (its title block is a `header` too), else none.
|
|
13
|
+
*/
|
|
14
|
+
export declare function findHostHeader(selector: string | undefined, editorRoot: Element | null): HTMLElement | null;
|
|
15
|
+
/**
|
|
16
|
+
* How far from the top the toolbar pins: the host header's height, measured
|
|
17
|
+
* and followed as it changes (a mobile menu opening), or `offset` when given,
|
|
18
|
+
* or 0 when the page has no header.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useHeaderOffset(header: BlogEditorHeader | undefined, own: RefObject<HTMLElement | null>): number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@codepress/blog/editor`: the in-place editor. Client-only; the Tiptap
|
|
3
|
+
* editor itself is a lazy chunk that loads when someone who can edit clicks
|
|
4
|
+
* Edit, so importing this entry downloads no ProseMirror code.
|
|
5
|
+
*/
|
|
6
|
+
export { BlogEditorMount, appPageUrl, type AskPanelProps, type BlogEditorMountProps, type DesignLinkContext, } from "./BlogEditorMount.js";
|
|
7
|
+
export { NewPostForm, isValidNewSlug, type NewPostFormProps, } from "./NewPostForm.js";
|
|
8
|
+
export { BLOG_TOOLBAR_BOTTOM_VAR, DEFAULT_TIMINGS, type BlogEditorTimings, } from "./config.js";
|
|
9
|
+
export { DEFAULT_HEADER_SELECTOR, type BlogEditorHeader } from "./header.js";
|
|
10
|
+
export { designUrl, type DesignTarget } from "./EditDesignLink.js";
|
|
11
|
+
export * from "./client.js";
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDraftClient
|
|
3
|
+
} from "../chunks/chunk-3WRTL63O.js";
|
|
4
|
+
import {
|
|
5
|
+
BLOG_TOOLBAR_BOTTOM_VAR,
|
|
6
|
+
DEFAULT_APP_URL,
|
|
7
|
+
DEFAULT_COLUMN,
|
|
8
|
+
DEFAULT_HEADER_SELECTOR,
|
|
9
|
+
DEFAULT_TIMINGS,
|
|
10
|
+
designTargetOf,
|
|
11
|
+
designUrl
|
|
12
|
+
} from "../chunks/chunk-UA243KAQ.js";
|
|
13
|
+
import {
|
|
14
|
+
SLUG_PATTERN
|
|
15
|
+
} from "../chunks/chunk-XLJBCIXI.js";
|
|
16
|
+
|
|
17
|
+
// src/editor/BlogEditorMount.tsx
|
|
18
|
+
import {
|
|
19
|
+
Suspense,
|
|
20
|
+
lazy,
|
|
21
|
+
useEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useState
|
|
24
|
+
} from "react";
|
|
25
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
26
|
+
var BlogEditor = lazy(() => import("../chunks/BlogEditor-VF3UAJB6.js"));
|
|
27
|
+
function appPageUrl(appUrl, organizationSlug, site, slug, tab) {
|
|
28
|
+
if (!organizationSlug) return `${appUrl}/dashboard`;
|
|
29
|
+
const query = new URLSearchParams({ site, post: slug });
|
|
30
|
+
if (tab) query.set("tab", tab);
|
|
31
|
+
return `${appUrl}/dashboard/${encodeURIComponent(organizationSlug)}/apps?${query.toString()}`;
|
|
32
|
+
}
|
|
33
|
+
function BlogEditorMount({
|
|
34
|
+
site,
|
|
35
|
+
slug,
|
|
36
|
+
apiBase,
|
|
37
|
+
auth,
|
|
38
|
+
isNew = false,
|
|
39
|
+
header,
|
|
40
|
+
column = DEFAULT_COLUMN,
|
|
41
|
+
appUrl = DEFAULT_APP_URL,
|
|
42
|
+
timings,
|
|
43
|
+
onEditingChange,
|
|
44
|
+
onPublished,
|
|
45
|
+
onLeave,
|
|
46
|
+
slots,
|
|
47
|
+
actions
|
|
48
|
+
}) {
|
|
49
|
+
const client = useMemo(
|
|
50
|
+
() => createDraftClient({ apiBase, auth }),
|
|
51
|
+
[apiBase, auth]
|
|
52
|
+
);
|
|
53
|
+
const [capabilities, setCapabilities] = useState(null);
|
|
54
|
+
const [checked, setChecked] = useState(false);
|
|
55
|
+
const [editing, setEditing] = useState(isNew);
|
|
56
|
+
const [ask, setAsk] = useState("closed");
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
void client.getCapabilities(site).then((result) => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
setCapabilities(result);
|
|
62
|
+
setChecked(true);
|
|
63
|
+
});
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
};
|
|
67
|
+
}, [client, site]);
|
|
68
|
+
const changeEditing = (next) => {
|
|
69
|
+
setEditing(next);
|
|
70
|
+
onEditingChange?.(next);
|
|
71
|
+
};
|
|
72
|
+
if (!checked) return null;
|
|
73
|
+
if (!capabilities?.can_edit) {
|
|
74
|
+
return isNew ? /* @__PURE__ */ jsx("p", { "data-testid": "blog-no-access", children: "You do not have access to write blog posts." }) : null;
|
|
75
|
+
}
|
|
76
|
+
const organizationId = capabilities.organization_id;
|
|
77
|
+
const organizationSlug = capabilities.organization_slug;
|
|
78
|
+
const askPanel = ask !== "closed" && organizationId && slots?.askPanel ? slots.askPanel({
|
|
79
|
+
site,
|
|
80
|
+
slug,
|
|
81
|
+
organizationId,
|
|
82
|
+
open: ask === "open",
|
|
83
|
+
onClose: () => setAsk("hidden")
|
|
84
|
+
}) : null;
|
|
85
|
+
const target = designTargetOf(capabilities);
|
|
86
|
+
const designHref = target ? slots?.designLink?.({ slug, ...target }) ?? designUrl(target, slug, appUrl) : null;
|
|
87
|
+
if (!editing) {
|
|
88
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
89
|
+
/* @__PURE__ */ jsx(
|
|
90
|
+
"button",
|
|
91
|
+
{
|
|
92
|
+
type: "button",
|
|
93
|
+
className: "fixed right-6 bottom-6 z-40 rounded-full bg-[#111110] px-5 py-2.5 text-sm font-medium text-white shadow-lg",
|
|
94
|
+
"data-testid": "blog-edit-button",
|
|
95
|
+
onClick: () => changeEditing(true),
|
|
96
|
+
children: "Edit"
|
|
97
|
+
}
|
|
98
|
+
),
|
|
99
|
+
askPanel
|
|
100
|
+
] });
|
|
101
|
+
}
|
|
102
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
103
|
+
/* @__PURE__ */ jsx(
|
|
104
|
+
Suspense,
|
|
105
|
+
{
|
|
106
|
+
fallback: /* @__PURE__ */ jsx(
|
|
107
|
+
"p",
|
|
108
|
+
{
|
|
109
|
+
className: `${column} pt-10 pb-20 sm:pt-14`,
|
|
110
|
+
"data-testid": "blog-editor-loading",
|
|
111
|
+
children: "Loading the editor\u2026"
|
|
112
|
+
}
|
|
113
|
+
),
|
|
114
|
+
children: /* @__PURE__ */ jsx(
|
|
115
|
+
BlogEditor,
|
|
116
|
+
{
|
|
117
|
+
site,
|
|
118
|
+
slug,
|
|
119
|
+
client,
|
|
120
|
+
timings,
|
|
121
|
+
column,
|
|
122
|
+
header,
|
|
123
|
+
canPublish: capabilities.can_publish,
|
|
124
|
+
designHref,
|
|
125
|
+
historyHref: appPageUrl(
|
|
126
|
+
appUrl,
|
|
127
|
+
organizationSlug,
|
|
128
|
+
site,
|
|
129
|
+
slug,
|
|
130
|
+
"history"
|
|
131
|
+
),
|
|
132
|
+
onAsk: organizationId && slots?.askPanel ? () => setAsk((current) => current === "open" ? "hidden" : "open") : void 0,
|
|
133
|
+
askHref: actions?.askCodePress?.href ?? appPageUrl(appUrl, organizationSlug, site, slug),
|
|
134
|
+
askIconSrc: `${appUrl}/logo-mark-square.png`,
|
|
135
|
+
askOpen: ask === "open",
|
|
136
|
+
onExit: () => isNew ? onLeave?.() : changeEditing(false),
|
|
137
|
+
onPublished: () => onPublished ? onPublished(slug) : window.location.reload(),
|
|
138
|
+
onDiscarded: () => isNew ? onLeave?.() : window.location.reload()
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
),
|
|
143
|
+
askPanel
|
|
144
|
+
] });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/editor/NewPostForm.tsx
|
|
148
|
+
import { useState as useState2 } from "react";
|
|
149
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
150
|
+
var RESERVED_SLUGS = /* @__PURE__ */ new Set(["new"]);
|
|
151
|
+
function isValidNewSlug(slug) {
|
|
152
|
+
return SLUG_PATTERN.test(slug) && !RESERVED_SLUGS.has(slug);
|
|
153
|
+
}
|
|
154
|
+
function NewPostForm({ onStart, prefix = "/blog/" }) {
|
|
155
|
+
const [draftSlug, setDraftSlug] = useState2("");
|
|
156
|
+
const valid = isValidNewSlug(draftSlug);
|
|
157
|
+
return /* @__PURE__ */ jsxs2(
|
|
158
|
+
"form",
|
|
159
|
+
{
|
|
160
|
+
"data-testid": "blog-new-form",
|
|
161
|
+
onSubmit: (event) => {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
if (valid) onStart(draftSlug);
|
|
164
|
+
},
|
|
165
|
+
children: [
|
|
166
|
+
/* @__PURE__ */ jsx2("h1", { className: "text-[34px] font-medium tracking-[-0.03em] text-[#111110]", children: "New post" }),
|
|
167
|
+
/* @__PURE__ */ jsxs2("label", { className: "mt-6 block text-sm text-[#6B6760]", children: [
|
|
168
|
+
"Address",
|
|
169
|
+
/* @__PURE__ */ jsxs2("div", { className: "mt-1 flex items-center gap-1 text-[#111110]", children: [
|
|
170
|
+
/* @__PURE__ */ jsx2("span", { children: prefix }),
|
|
171
|
+
/* @__PURE__ */ jsx2(
|
|
172
|
+
"input",
|
|
173
|
+
{
|
|
174
|
+
className: "w-full rounded-md border border-[#D9D5CC] px-2 py-1",
|
|
175
|
+
placeholder: "my-first-post",
|
|
176
|
+
"aria-label": "Post address",
|
|
177
|
+
"data-testid": "blog-new-slug",
|
|
178
|
+
value: draftSlug,
|
|
179
|
+
onChange: (event) => setDraftSlug(event.target.value)
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
] })
|
|
183
|
+
] }),
|
|
184
|
+
draftSlug && !valid ? /* @__PURE__ */ jsx2("p", { className: "mt-2 text-sm text-red-600", children: "Use lowercase letters, numbers and single hyphens." }) : null,
|
|
185
|
+
/* @__PURE__ */ jsx2(
|
|
186
|
+
"button",
|
|
187
|
+
{
|
|
188
|
+
type: "submit",
|
|
189
|
+
className: "mt-4 rounded-md bg-[#111110] px-4 py-2 text-sm text-white disabled:opacity-50",
|
|
190
|
+
"data-testid": "blog-new-start",
|
|
191
|
+
disabled: !valid,
|
|
192
|
+
children: "Start writing"
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
export {
|
|
200
|
+
BLOG_TOOLBAR_BOTTOM_VAR,
|
|
201
|
+
BlogEditorMount,
|
|
202
|
+
DEFAULT_HEADER_SELECTOR,
|
|
203
|
+
DEFAULT_TIMINGS,
|
|
204
|
+
NewPostForm,
|
|
205
|
+
appPageUrl,
|
|
206
|
+
createDraftClient,
|
|
207
|
+
designUrl,
|
|
208
|
+
isValidNewSlug
|
|
209
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/react";
|
|
2
|
+
/**
|
|
3
|
+
* What in a pasted fragment the editor refuses rather than flattens. Ordinary
|
|
4
|
+
* web markup (`div`, `span`, styles) is normalized by the schema like any
|
|
5
|
+
* paste; scripts, event handlers, unsafe URLs, invalid component props and
|
|
6
|
+
* unregistered `cp-*` tags are rejected out loud so nobody wonders where they
|
|
7
|
+
* went.
|
|
8
|
+
*/
|
|
9
|
+
export declare function pasteRejections(html: string): string[];
|
|
10
|
+
interface PasteGuardOptions {
|
|
11
|
+
onReject: (reasons: string[]) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare const PasteGuard: Extension<PasteGuardOptions, any>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The menu and tooltip primitives the toolbar uses: Radix, with the classes
|
|
3
|
+
* and markup of the CodePress UI kit's own wrappers, so the package needs no
|
|
4
|
+
* private dependency and the toolbar renders exactly as it does in the app.
|
|
5
|
+
*/
|
|
6
|
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
7
|
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
8
|
+
import type { ComponentProps } from "react";
|
|
9
|
+
export declare function DropdownMenu(props: ComponentProps<typeof DropdownMenuPrimitive.Root>): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export declare function DropdownMenuTrigger(props: ComponentProps<typeof DropdownMenuPrimitive.Trigger>): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export declare function DropdownMenuContent({ className, sideOffset, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Content>): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export declare function DropdownMenuItem({ className, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Item>): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export declare function DropdownMenuSeparator({ className, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Separator>): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
/** Each tooltip carries its own provider, so a host needs no provider around the editor. */
|
|
15
|
+
export declare function Tooltip(props: ComponentProps<typeof TooltipPrimitive.Root>): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export declare function TooltipTrigger(props: ComponentProps<typeof TooltipPrimitive.Trigger>): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
export declare function TooltipContent({ className, sideOffset, children, ...props }: ComponentProps<typeof TooltipPrimitive.Content>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
interface Field {
|
|
3
|
+
name: string;
|
|
4
|
+
kind: "enum" | "text" | "number" | "boolean";
|
|
5
|
+
options: string[];
|
|
6
|
+
required: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** The editable fields of a component's props, read from its zod schema. */
|
|
9
|
+
export declare function fieldsFor(schema: z.ZodType): Field[];
|
|
10
|
+
interface PropsFormProps {
|
|
11
|
+
schema: z.ZodType;
|
|
12
|
+
value: Record<string, unknown>;
|
|
13
|
+
onChange: (next: Record<string, unknown>) => void;
|
|
14
|
+
}
|
|
15
|
+
/** A form generated from a props schema; it edits the props object in place. */
|
|
16
|
+
export declare function PropsForm({ schema, value, onChange }: PropsFormProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model";
|
|
2
|
+
import { type EditedBlock, type PostSource } from "./document.js";
|
|
3
|
+
/** The ProseMirror schema the editor uses; built once. */
|
|
4
|
+
export declare function postSchema(): Schema;
|
|
5
|
+
/** The HTML of one top-level editor node, in the file's own conventions. */
|
|
6
|
+
export declare function nodeToHtml(schema: Schema, node: ProseMirrorNode): string;
|
|
7
|
+
/**
|
|
8
|
+
* Why a source file cannot be edited without loss: structural problems from
|
|
9
|
+
* parsing, and each block that would come out different after the editor
|
|
10
|
+
* loads and writes it back (unknown markup, unsupported elements).
|
|
11
|
+
*/
|
|
12
|
+
export declare function editabilityProblems(source: PostSource): string[];
|
|
13
|
+
/**
|
|
14
|
+
* The source body as the editor's initial content, with an id on every block.
|
|
15
|
+
* Blocks the author left without a `data-cp-id` get one here, so each loaded
|
|
16
|
+
* block maps to its source block by id from the first keystroke; the injected
|
|
17
|
+
* id only reaches the file if that block is edited.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sourceBodyHtml(source: PostSource): {
|
|
20
|
+
html: string;
|
|
21
|
+
indexById: Map<string, number>;
|
|
22
|
+
};
|
|
23
|
+
/** Maps each block's `data-cp-id` in the loaded document to its source block. */
|
|
24
|
+
export declare function blockIndexById(doc: ProseMirrorNode): Map<string, number>;
|
|
25
|
+
/** The current document as blocks, reusing source text for the unchanged ones. */
|
|
26
|
+
export declare function editedBlocks(doc: ProseMirrorNode, source: PostSource | null, indexById: Map<string, number>): EditedBlock[];
|
|
27
|
+
export interface LocalProblem {
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What the backend would reject, found before saving: the same registry
|
|
32
|
+
* checks the sanitizer applies, plus the metadata shape.
|
|
33
|
+
*/
|
|
34
|
+
export declare function localProblems(meta: Record<string, unknown>, blocks: EditedBlock[]): LocalProblem[];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Extension, type Editor, type Range } from "@tiptap/react";
|
|
2
|
+
export interface SlashItem {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
description: string;
|
|
6
|
+
run: (editor: Editor, range: Range) => void;
|
|
7
|
+
}
|
|
8
|
+
/** What the menu component needs to draw itself and pick an item. */
|
|
9
|
+
export interface SlashState {
|
|
10
|
+
items: SlashItem[];
|
|
11
|
+
index: number;
|
|
12
|
+
rect: DOMRect | null;
|
|
13
|
+
choose: (index: number) => void;
|
|
14
|
+
}
|
|
15
|
+
interface SlashOptions {
|
|
16
|
+
items: SlashItem[];
|
|
17
|
+
onState: (state: SlashState | null) => void;
|
|
18
|
+
}
|
|
19
|
+
/** Typing `/` at the start of a block opens a menu of blocks to insert. */
|
|
20
|
+
export declare const SlashCommand: Extension<SlashOptions, any>;
|
|
21
|
+
export {};
|
package/dist/feed.d.ts
ADDED
package/dist/index.d.ts
ADDED