diff --git a/README.md b/README.md index 2d26f27..d882890 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,31 @@ Most common block types are supported. We happily accept pull requests to add su | Checkbox | ❌ Missing | Supported by [react-notion-x](https://github.com/NotionX/react-notion-x) | | Table Of Contents | ❌ Missing | Supported by [react-notion-x](https://github.com/NotionX/react-notion-x) | +## Unsupported Blocks + +Unsupported blocks render a visible warning by default instead of failing silently. + +```jsx + +``` + +To keep the previous empty-placeholder behavior, disable warnings: + +```jsx + +``` + +You can also customize the fallback: + +```jsx + ( + Unsupported block: {block.value.type} + )} +/> +``` + ## Block Type Specific Caveats When using a code block in your Notion page, `NotionRenderer` will use `prismjs` to detect the language of the code block. diff --git a/src/block.tsx b/src/block.tsx index 919ddf4..013a7a8 100644 --- a/src/block.tsx +++ b/src/block.tsx @@ -7,7 +7,6 @@ import { MapPageUrl, MapImageUrl, CustomBlockComponents, - BlockValueProp, CustomDecoratorComponents, CustomDecoratorComponentProps } from "./types"; @@ -15,6 +14,7 @@ import Asset from "./components/asset"; import Code from "./components/code"; import PageIcon from "./components/page-icon"; import PageHeader from "./components/page-header"; +import UnsupportedBlock from "./components/unsupported-block"; import { classNames, getTextContent, getListNumber } from "./utils"; export const createRenderChildText = ( @@ -94,6 +94,8 @@ interface Block { hideHeader?: boolean; customBlockComponents?: CustomBlockComponents; customDecoratorComponents?: CustomDecoratorComponents; + showUnsupportedBlockErrors?: boolean; + renderUnsupportedBlock?: (block: BlockType) => React.ReactNode; } export const Block: React.FC = props => { @@ -107,13 +109,36 @@ export const Block: React.FC = props => { mapPageUrl, mapImageUrl, customBlockComponents, - customDecoratorComponents + customDecoratorComponents, + showUnsupportedBlockErrors = true, + renderUnsupportedBlock } = props; const blockValue = block?.value; const renderComponent = () => { const renderChildText = createRenderChildText(customDecoratorComponents); + const renderUnsupported = () => { + if (process.env.NODE_ENV !== "production") { + console.warn("Unsupported type " + block?.value?.type); + } + + if (!showUnsupportedBlockErrors) { + return ; + } + + if (renderUnsupportedBlock) { + return <>{renderUnsupportedBlock(block)}>; + } + + return ( + + ); + }; + switch (blockValue?.type) { case "page": if (level === 0) { @@ -361,6 +386,7 @@ export const Block: React.FC = props => { ); case "collection_view": if (!block) return null; + if (!block.collection) return renderUnsupported(); const collectionView = block?.collection?.types[0]; @@ -516,10 +542,7 @@ export const Block: React.FC = props => { ); default: - if (process.env.NODE_ENV !== "production") { - console.log("Unsupported type " + block?.value?.type); - } - return ; + return renderUnsupported(); } return null; }; @@ -531,12 +554,14 @@ export const Block: React.FC = props => { // Do not use custom component for base page block level !== 0 ) { - const CustomComponent = customBlockComponents[blockValue?.type]!; + const CustomComponent = customBlockComponents[blockValue?.type] as React.FC< + any + >; return ( } + blockValue={blockValue} level={level} > {children} diff --git a/src/components/unsupported-block.tsx b/src/components/unsupported-block.tsx new file mode 100644 index 0000000..3c7d15d --- /dev/null +++ b/src/components/unsupported-block.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; + +interface UnsupportedBlockProps { + blockType?: string; + blockId?: string; +} + +const unsupportedBlockLabels: { [blockType: string]: string } = { + checkbox: "Checkbox", + collection_view: "Database", + collection_view_page: "Database", + database: "Database", + equation: "Equation", + synced_block: "Synced Block", + table_of_contents: "Table of Contents" +}; + +export const getUnsupportedBlockMessage = (blockType?: string) => { + const label = + unsupportedBlockLabels[blockType || ""] || blockType || "Unsupported"; + const article = /^[aeiou]/i.test(label) ? "an" : "a"; + + return `This Notion document includes ${article} ${label} block which react-notion cannot render. Please remove it to render this page.`; +}; + +const UnsupportedBlock: React.FC = ({ + blockType, + blockId +}) => ( + + + ! + + + + Unsupported Notion block + + {getUnsupportedBlockMessage(blockType)} + {blockId && ( + Block ID: {blockId} + )} + + +); + +export default UnsupportedBlock; diff --git a/src/index.tsx b/src/index.tsx index 757b744..abe243e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,6 @@ export { NotionRenderer } from "./renderer"; +export { default as UnsupportedBlock } from "./components/unsupported-block"; +export { getUnsupportedBlockMessage } from "./components/unsupported-block"; export * from "./types"; export * from "./utils"; export * from "./block"; diff --git a/src/renderer.tsx b/src/renderer.tsx index 8da93e3..b99b485 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -4,7 +4,8 @@ import { MapPageUrl, MapImageUrl, CustomBlockComponents, - CustomDecoratorComponents + CustomDecoratorComponents, + BlockType } from "./types"; import { Block } from "./block"; import { defaultMapImageUrl, defaultMapPageUrl } from "./utils"; @@ -20,6 +21,8 @@ export interface NotionRendererProps { level?: number; customBlockComponents?: CustomBlockComponents; customDecoratorComponents?: CustomDecoratorComponents; + showUnsupportedBlockErrors?: boolean; + renderUnsupportedBlock?: (block: BlockType) => React.ReactNode; } export const NotionRenderer: React.FC = ({ diff --git a/src/styles.css b/src/styles.css index da0e210..92084aa 100644 --- a/src/styles.css +++ b/src/styles.css @@ -681,3 +681,43 @@ img.notion-nav-icon { margin: 0 2px; color: rgba(55, 53, 47, 0.4); } + +.notion-unsupported-block { + display: flex; + gap: 10px; + align-items: flex-start; + margin: 8px 0; + padding: 12px; + border: 1px solid rgba(235, 87, 87, 0.35); + border-radius: 4px; + background: rgba(235, 87, 87, 0.08); + color: rgb(55, 53, 47); +} + +.notion-unsupported-block-icon { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-top: 2px; + border-radius: 50%; + background: rgb(235, 87, 87); + color: white; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +.notion-unsupported-block-title { + margin-bottom: 2px; + font-weight: 600; +} + +.notion-unsupported-block-id { + margin-top: 6px; + color: rgba(55, 53, 47, 0.65); + font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; +} diff --git a/test/unsupported-block.test.tsx b/test/unsupported-block.test.tsx new file mode 100644 index 0000000..62ecf42 --- /dev/null +++ b/test/unsupported-block.test.tsx @@ -0,0 +1,125 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { NotionRenderer } from "../src/renderer"; +import { BlockMapType } from "../src/types"; + +const baseValue = (id: string, type: string, parentId = "page") => ({ + id, + version: 1, + type, + parent_id: parentId, + parent_table: "block", + alive: true, + created_time: 1, + last_edited_time: 1, + created_by_table: "notion_user", + created_by_id: "user", + last_edited_by_table: "notion_user", + last_edited_by_id: "user" +}); + +const blockMapWith = (child: any): BlockMapType => + ({ + page: { + role: "reader", + value: { + ...baseValue("page", "page", "root"), + properties: { title: [["Root"]] }, + format: {}, + permissions: [], + content: [child.value.id] + } + }, + [child.value.id]: child + } as any); + +describe("unsupported block rendering", () => { + beforeEach(() => { + jest.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + (console.warn as jest.Mock).mockRestore(); + }); + + it("renders a helpful error for unsupported database blocks", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("Unsupported Notion block"); + expect(html).toContain( + "This Notion document includes a Database block which react-notion cannot render." + ); + expect(html).toContain("Block ID: database"); + }); + + it("can hide unsupported block errors for backwards compatibility", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).not.toContain("Unsupported Notion block"); + expect(html).not.toContain("Checkbox block"); + }); + + it("allows unsupported block rendering to be customized", () => { + const html = renderToStaticMarkup( + ( + {block.value.id} + )} + blockMap={blockMapWith({ + role: "reader", + value: baseValue("toc", "table_of_contents") + })} + /> + ); + + expect(html).toContain("custom-error"); + expect(html).toContain("toc"); + expect(html).not.toContain("Unsupported Notion block"); + }); + + it("still renders collection views when collection data is present", () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("Tasks"); + expect(html).toContain("Ship it"); + expect(html).not.toContain("Unsupported Notion block"); + }); +});