diff --git a/next.config.js b/next.config.js index 3d9663bb..c7c69dc6 100644 --- a/next.config.js +++ b/next.config.js @@ -44,5 +44,10 @@ module.exports = withBundleAnalyzer( ipfsGateway: { gatewayPath: "/_ipfs/:cid/:pathToResource*", }, + + images: { + dangerouslyAllowSVG: true, + domains: ["4everland.xyz"], + }, }), ) diff --git a/package.json b/package.json index b7bd77b0..d1bea4fc 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "refractor": "^4.8.0", "rehype-prism-plus": "^1.5.0", "rehype-raw": "^6.1.1", + "rehype-react": "^7.1.1", "rehype-sanitize": "^5.0.1", "rehype-stringify": "^9.0.3", "remark-frontmatter": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e1fa184..3dbf650b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,7 @@ specifiers: refractor: ^4.8.0 rehype-prism-plus: ^1.5.0 rehype-raw: ^6.1.1 + rehype-react: ^7.1.1 rehype-sanitize: ^5.0.1 rehype-stringify: ^9.0.3 remark-frontmatter: ^4.0.1 @@ -176,6 +177,7 @@ dependencies: refractor: 4.8.0 rehype-prism-plus: 1.5.0 rehype-raw: 6.1.1 + rehype-react: 7.1.1_@types+react@18.0.21 rehype-sanitize: 5.0.1 rehype-stringify: 9.0.3 remark-frontmatter: 4.0.1 @@ -1563,6 +1565,13 @@ packages: '@lezer/highlight': 1.1.0 dev: false + /@mapbox/hast-util-table-cell-style/0.2.0: + resolution: {integrity: sha512-gqaTIGC8My3LVSnU38IwjHVKJC94HSonjvFHDk8/aSrApL8v4uWgm8zJkK7MJIIbHuNOr/+Mv2KkQKcxs6LEZA==} + engines: {node: '>=12'} + dependencies: + unist-util-visit: 1.4.1 + dev: false + /@metamask/safe-event-emitter/2.0.0: resolution: {integrity: sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q==} dev: false @@ -8669,6 +8678,19 @@ packages: unified: 10.1.2 dev: false + /rehype-react/7.1.1_@types+react@18.0.21: + resolution: {integrity: sha512-6yaitxM95JFsuTA74OH54OyvTAeRqR4/A6f45S2sEk2FEG04iGgsObcmEIFQRsjK9pfAhc74lR4iGf/W2G/sBw==} + peerDependencies: + '@types/react': '>=17' + dependencies: + '@mapbox/hast-util-table-cell-style': 0.2.0 + '@types/hast': 2.3.4 + '@types/react': 18.0.21 + hast-to-hyperscript: 10.0.1 + hast-util-whitespace: 2.0.0 + unified: 10.1.2 + dev: false + /rehype-sanitize/5.0.1: resolution: {integrity: sha512-da/jIOjq8eYt/1r9GN6GwxIR3gde7OZ+WV8pheu1tL8K0D9KxM2AyMh+UEfke+FfdM3PvGHeYJU0Td5OWa7L5A==} dependencies: @@ -9692,6 +9714,10 @@ packages: resolution: {integrity: sha512-TiWE6DVtVe7Ye2QxOVW9kqybs6cZexNwTwSMVgkfjEReqy/xwGpAXb99OxktoWwmL+Z+Epb0Dn8/GNDYP1wnUw==} dev: false + /unist-util-is/3.0.0: + resolution: {integrity: sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A==} + dev: false + /unist-util-is/5.1.1: resolution: {integrity: sha512-F5CZ68eYzuSvJjGhCLPL3cYx45IxkqXSetCcRgUXtbcm50X2L9oOWQlfUfDdAf+6Pd27YDblBfdtmsThXmwpbQ==} dev: false @@ -9708,6 +9734,12 @@ packages: '@types/unist': 2.0.6 dev: false + /unist-util-visit-parents/2.1.2: + resolution: {integrity: sha512-DyN5vD4NE3aSeB+PXYNKxzGsfocxp6asDc2XXE3b0ekO2BaRUpBicbbUygfSvYfUz1IkmjFR1YF7dPklraMZ2g==} + dependencies: + unist-util-is: 3.0.0 + dev: false + /unist-util-visit-parents/5.1.0: resolution: {integrity: sha512-y+QVLcY5eR/YVpqDsLf/xh9R3Q2Y4HxkZTp7ViLDU6WtJCEcPmRzW1gpdWDCDIqIlhuPDXOgttqPlykrHYDekg==} dependencies: @@ -9722,6 +9754,12 @@ packages: unist-util-is: 5.1.1 dev: false + /unist-util-visit/1.4.1: + resolution: {integrity: sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw==} + dependencies: + unist-util-visit-parents: 2.1.2 + dev: false + /unist-util-visit/4.1.1: resolution: {integrity: sha512-n9KN3WV9k4h1DxYR1LoajgN93wpEi/7ZplVe02IoB4gH5ctI1AaF2670BLHQYbwj+pY83gFtyeySFiyMHJklrg==} dependencies: diff --git a/src/components/common/PageContent.tsx b/src/components/common/PageContent.tsx index f9c78ccc..ad7d9715 100644 --- a/src/components/common/PageContent.tsx +++ b/src/components/common/PageContent.tsx @@ -1,16 +1,27 @@ import clsx from "clsx" import { useCodeCopy } from "~/hooks/useCodeCopy" +import { renderPageContent } from "~/markdown" export const PageContent: React.FC<{ - contentHTML: string + content?: string className?: string -}> = ({ contentHTML, className }) => { +}> = ({ className, content }) => { useCodeCopy() + // TODO + // const [element, setElement] = useState() + // const renderMarkdown = useCallback((doc: string) => { + // setElement(renderPageContent(doc).element) + // }, []) + // useEffect(() => { + // if (content) { + // renderMarkdown(content) + // } + // }, [content, renderMarkdown]) + return ( -
+
+ {content && renderPageContent(content).element} +
) } diff --git a/src/components/dashboard/PagesManager.tsx b/src/components/dashboard/PagesManager.tsx index 17d69390..76d8b681 100644 --- a/src/components/dashboard/PagesManager.tsx +++ b/src/components/dashboard/PagesManager.tsx @@ -279,9 +279,9 @@ export const PagesManager: React.FC<{ const file = e.target?.files?.[0] const reader = new FileReader() reader.readAsText(file, "UTF-8") - reader.onload = async (evt) => { + reader.onload = (evt) => { if (evt.target?.result) { - const pageContent = await renderPageContent( + const pageContent = renderPageContent( evt.target.result as string, ) diff --git a/src/components/site/SiteHeader.tsx b/src/components/site/SiteHeader.tsx index 25301275..79b93cda 100644 --- a/src/components/site/SiteHeader.tsx +++ b/src/components/site/SiteHeader.tsx @@ -25,6 +25,7 @@ import type { Link } from "unidata.js" import { getSiteSubscriptions } from "~/models/site.model" import { CharacterList } from "~/components/common/CharacterList" import type { Links } from "unidata.js" +import { Image } from "~/components/ui/Image" export type HeaderLinkType = { icon?: React.ReactNode @@ -184,8 +185,7 @@ export const SiteHeader: React.FC<{
{site?.banners?.[0]?.mime_type.split("/")[0] === "image" && ( - /* eslint-disable-next-line @next/next/no-img-element */ - banner - +

{post.title} @@ -56,16 +57,16 @@ export const SiteHome: React.FC<{ {excerpt && "..."}

-
- {post.cover && ( - /* eslint-disable-next-line @next/next/no-img-element */ - + cover - )} -
+ > + + )}
) diff --git a/src/components/site/SiteLayout.tsx b/src/components/site/SiteLayout.tsx index eb302cb9..d245edb3 100644 --- a/src/components/site/SiteLayout.tsx +++ b/src/components/site/SiteLayout.tsx @@ -38,7 +38,7 @@ export const SiteLayout: React.FC = ({ children, title }) => { title={title || tag || page.data?.title || ""} siteName={site.data?.name || ""} description={ - (page.data?.summary?.content || page.data?.body?.content) ?? + page.data?.summary?.content ?? site.data?.description?.replace(/<[^>]*>/g, "") } image={page.data?.cover || getUserContentsUrl(site.data?.avatars?.[0])} diff --git a/src/components/site/SitePage.tsx b/src/components/site/SitePage.tsx index 3dbb8055..a8d27036 100644 --- a/src/components/site/SitePage.tsx +++ b/src/components/site/SitePage.tsx @@ -18,7 +18,10 @@ export const SitePage: React.FC<{ )} {page?.tags?.includes("post") && } - + ) diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx index 623b35eb..10a009bc 100644 --- a/src/components/ui/Avatar.tsx +++ b/src/components/ui/Avatar.tsx @@ -1,6 +1,7 @@ import clsx from "clsx" import React, { useMemo } from "react" import { toGateway } from "~/lib/ipfs-parser" +import { Image } from "~/components/ui/Image" export const Avatar: React.FC< { @@ -61,8 +62,7 @@ export const Avatar: React.FC< height: `${size}px`, }} > - {/* eslint-disable-next-line @next/next/no-img-element */} - = ({ - className, - content, -}) => { - const [html, setHtml] = useState("") - const renderMarkdown = useCallback(async (doc: string) => { - const pageContent = await renderPageContent(doc) - setHtml(pageContent.contentHTML) - }, []) - useEffect(() => { - renderMarkdown(content) - }, [content, renderMarkdown]) - return -} diff --git a/src/components/ui/Image.tsx b/src/components/ui/Image.tsx new file mode 100644 index 00000000..6a6eb07c --- /dev/null +++ b/src/components/ui/Image.tsx @@ -0,0 +1,63 @@ +import React from "react" +import { default as NextImage, ImageProps } from "next/image" +import { toGateway, toIPFS } from "~/lib/ipfs-parser" +import { isIpfsUrl } from "@crossbell/ipfs-gateway" +import clsx from "clsx" + +export const Image: React.FC< + { + className?: string + src?: string + width?: number | string + height?: number | string + } & React.HTMLAttributes & + ImageProps +> = ({ layout, className, alt, src, width, height, ...props }) => { + src = toIPFS(src) + const [paddingTop, setPaddingTop] = React.useState("0") + + return ( + <> + {isIpfsUrl(src) ? ( + + { + if (!width && !width && !layout) { + const { naturalWidth, naturalHeight } = + target as HTMLImageElement + setPaddingTop( + `calc(100% / (${naturalWidth} / ${naturalHeight})`, + ) + } + }} + /> + + ) : ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + )} + + ) +} diff --git a/src/lib/ipfs-parser.ts b/src/lib/ipfs-parser.ts index 0b2a9f72..c3e496aa 100644 --- a/src/lib/ipfs-parser.ts +++ b/src/lib/ipfs-parser.ts @@ -7,13 +7,17 @@ const IPFS_PREFIX = "ipfs://" export type ToGatewayConfig = { needRequestAtServerSide?: boolean + forceFallback?: boolean } export const toGateway = (url: string, config?: ToGatewayConfig) => { const ipfsUrl = toIPFS(url) if (isIpfsUrl(ipfsUrl)) { - if (config?.needRequestAtServerSide && typeof window === "undefined") { + if ( + (config?.needRequestAtServerSide && typeof window === "undefined") || + config?.forceFallback + ) { return ipfsGateway.getFallbackWeb2Url(ipfsUrl) } else { return ipfsGateway.getSwWeb2Url(ipfsUrl) @@ -31,4 +35,5 @@ export const toIPFS = (url: string) => { .replace("https://ipfs.io/ipfs/", IPFS_PREFIX) .replace("https://cf-ipfs.com/ipfs/", IPFS_PREFIX) .replace("https://ipfs.4everland.xyz/ipfs/", IPFS_PREFIX) + .replace("https://rss3.mypinata.cloud/ipfs/", IPFS_PREFIX) } diff --git a/src/lib/types.ts b/src/lib/types.ts index 95735525..34abe7d1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,5 @@ import type { Note as UniNote, Profile as UniProfile } from "unidata.js" +import { ReactElement } from "react" export type Site = { id: string @@ -75,6 +76,12 @@ export type Note = UniNote & { slug?: string character?: Profile cover?: string + body?: { + content?: string + address?: string + mime_type?: string + element?: ReactElement + } } export type Notes = { diff --git a/src/markdown/index.ts b/src/markdown/index.ts index f66b737a..b72120d9 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -15,6 +15,9 @@ import { allowedBlockquoteAttrs, remarkCallout } from "./remark-callout" import { rehypeExternalLink } from "./rehyper-external-link" import { rehypeWrapCode } from "./rehype-wrap-code" import jsYaml from "js-yaml" +import rehypeReact from "rehype-react" +import { createElement, ReactElement } from "react" +import { Image } from "~/components/ui/Image" export type MarkdownEnv = { excerpt: string @@ -25,6 +28,7 @@ export type MarkdownEnv = { export type Rendered = { contentHTML: string + element?: ReactElement excerpt: string frontMatter: Record cover: string @@ -34,7 +38,10 @@ refractor.alias("html", ["svelte", "vue"]) const rehypePrism = rehypePrismGenerator(refractor) -export const renderPageContent = async (content: string): Promise => { +export const renderPageContent = ( + content: string, + html?: boolean, +): Rendered => { const env: MarkdownEnv = { excerpt: "", __internal: {}, @@ -42,7 +49,7 @@ export const renderPageContent = async (content: string): Promise => { cover: "", } - const result = await unified() + const result = unified() .use(remarkParse) .use(rehypeStringify) .use(remarkFrontmatter, ["yaml"]) @@ -98,12 +105,19 @@ export const renderPageContent = async (content: string): Promise => { .use(rehypeTable) .use(rehypeExternalLink) .use(rehypeWrapCode) - .process(content) + .use(html ? () => (tree: any) => {} : rehypeReact, { + createElement: createElement, + components: { + img: Image as any, + }, + }) + .processSync(content) const contentHTML = result.toString() return { contentHTML, + element: result.result, excerpt: env.excerpt, frontMatter: env.frontMatter, cover: env.cover, diff --git a/src/models/page.model.ts b/src/models/page.model.ts index a00cb4be..762ac4d6 100644 --- a/src/models/page.model.ts +++ b/src/models/page.model.ts @@ -91,16 +91,16 @@ export async function createOrUpdatePage(input: { ) } -const expandPage = async (page: Note, render: boolean) => { +const expandPage = (page: Note, render: boolean) => { if ( page.body?.content && page.body?.mime_type === "text/markdown" && render ) { - const rendered = await renderPageContent(page.body.content) + const rendered = renderPageContent(page.body.content, true) page.body = { - content: rendered.contentHTML, - mime_type: "text/html", + content: page.body.content, + mime_type: "text/markdown", } if (!page.summary) { page.summary = { @@ -237,12 +237,10 @@ export async function getPagesBySite(input: { .sort((a, b) => +new Date(b.date_published) - +new Date(a.date_published)) pages.total = pages.list.length - pages.list = await Promise.all( - pages?.list.map(async (page) => { - await expandPage(page, input.render || false) - return page - }), - ) + pages?.list.map((page) => { + expandPage(page, input.render || false) + return page + }) } return pages diff --git a/src/models/site.model.ts b/src/models/site.model.ts index ce31b775..7fb88d65 100644 --- a/src/models/site.model.ts +++ b/src/models/site.model.ts @@ -37,7 +37,7 @@ const expandSite = async (site: Profile) => { (a: any) => a.trait_type === "xlog_custom_domain", )?.value || "" site.name = site.name || site.username - site.description = (await renderPageContent(site.bio || "")).contentHTML + site.description = renderPageContent(site.bio || "", true).contentHTML if (site.avatars) { site.avatars = site.avatars.map((avatar) => toGateway(avatar)) diff --git a/src/pages/_site/[site]/feed.tsx b/src/pages/_site/[site]/feed.tsx index 99fc8fc7..d2ba894e 100644 --- a/src/pages/_site/[site]/feed.tsx +++ b/src/pages/_site/[site]/feed.tsx @@ -4,6 +4,7 @@ import { fetchGetPagesBySite } from "~/queries/page.server" import { PageVisibilityEnum } from "~/lib/types" import { getSiteLink } from "~/lib/helpers" import { QueryClient } from "@tanstack/react-query" +import { renderPageContent } from "~/markdown" export const getServerSideProps: GetServerSideProps = async (ctx) => { const queryClient = new QueryClient() @@ -36,7 +37,9 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { items: pages.list?.map((page) => ({ id: page.id, title: page.title, - content_html: page.body?.content, + content_html: + page.body?.content && + renderPageContent(page.body?.content, true).contentHTML, summary: page.summary?.content, url: `${link}/${page.slug || page.id}`, image: page.cover, diff --git a/src/pages/dashboard/[subdomain]/editor.tsx b/src/pages/dashboard/[subdomain]/editor.tsx index 2b37e724..7c08aac7 100644 --- a/src/pages/dashboard/[subdomain]/editor.tsx +++ b/src/pages/dashboard/[subdomain]/editor.tsx @@ -9,7 +9,6 @@ import { DashboardLayout } from "~/components/dashboard/DashboardLayout" import { DashboardMain } from "~/components/dashboard/DashboardMain" import { PublishButton } from "~/components/dashboard/PublishButton" import { useEditor } from "~/components/ui/Editor" -import { EditorPreview } from "~/components/ui/EditorPreview" import { EditorToolbar } from "~/components/ui/EditorToolbar" import { Input } from "~/components/ui/Input" import { UniLink } from "~/components/ui/UniLink" @@ -23,6 +22,7 @@ import { useGetSite } from "~/queries/site" import { getStorage, setStorage, delStorage } from "~/lib/storage" import { nanoid } from "nanoid" import { useQueryClient } from "@tanstack/react-query" +import { PageContent } from "~/components/common/PageContent" const getInputDatetimeValue = (date: Date | string) => { const str = dayjs(date).format() @@ -265,10 +265,10 @@ export default function SubdomainEditor() { className="px-5 h-full border-r w-1/2 overflow-scroll" ref={editorRef} > - + > diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 78e7f2fb..6ee25d9b 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -5,7 +5,6 @@ import { MainLayout } from "~/components/main/MainLayout" import { UniLink } from "~/components/ui/UniLink" import { FLY_REGION } from "~/lib/env.server" import { useAccount } from "wagmi" -import Image from "next/image" import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid" import { LoveIcon } from "~/components/icons/LoveIcon" import { BlockchainIcon } from "~/components/icons/BlockchainIcon" @@ -15,6 +14,7 @@ import { useRouter } from "next/router" import { GITHUB_LINK, APP_NAME, CSB_SCAN } from "~/lib/env" import { getSiteLink } from "~/lib/helpers" import { Link, Element } from "react-scroll" +import { Image } from "~/components/ui/Image" export const getServerSideProps: GetServerSideProps = async (ctx) => { return { @@ -234,12 +234,13 @@ export default function Home({ region }: { region: string | null }) { subdomain: item.username, })} > - {/* eslint-disable-next-line @next/next/no-img-element */} - {item.username} + width="56" + height="56" + > ))}