diff --git a/app.config.ts b/app.config.ts index 314fe1172..1b2db31c6 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,21 +1,29 @@ import { readFile } from "node:fs/promises"; -import path from "node:path"; import yaml from "@rollup/plugin-yaml"; import { defineConfig } from "@solidjs/start/config"; import vinxiMdxPkg from "@vinxi/plugin-mdx"; +import rehypeSlug from "rehype-slug"; import remarkFrontmatter from "remark-frontmatter"; import unocss from "unocss/vite"; import { imagetools } from "vite-imagetools"; // 현재 Vinxi export 설정 이슈로 파일을 직접 가져와야 함 import type { CustomizableConfig } from "./node_modules/vinxi/dist/types/lib/vite-dev"; +import { indexFilesMapping } from "./src/misc/contentIndex"; const { default: vinxiMdx } = vinxiMdxPkg; export default defineConfig({ server: { preset: "vercel", + prerender: { + routes: [ + ...Object.keys(indexFilesMapping).map( + (fileName) => `/content-index/${fileName}.json`, + ), + ], + }, }, extensions: ["ts", "tsx", "mdx"], vite: () => @@ -34,6 +42,7 @@ export default defineConfig({ jsxImportSource: "solid-js", providerImportSource: "solid-mdx", remarkPlugins: [remarkFrontmatter], + rehypePlugins: [rehypeSlug], }), imagetools({ defaultDirectives: (url) => { diff --git a/package.json b/package.json index c65784bbd..cb3cf57bc 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,13 @@ "lint": "NODE_OPTIONS=\"$NODE_OPTIONS --loader ts-node/esm\" eslint .", "eslint": "NODE_OPTIONS=\"$NODE_OPTIONS --loader ts-node/esm\" eslint" }, - "imports": { - "#content": "./src/content/__generated__/index.ts" - }, + "imports": { + "#content": "./src/content/__generated__/index.ts", + "#server-only": { + "browser": "./src/misc/server-only/browserError.ts", + "default": "./src/misc/server-only/serverNoop.ts" + } + }, "dependencies": { "@eslint/js": "^8.57.0", "@iconify-json/ic": "^1.1.17", @@ -72,6 +76,7 @@ "monaco-editor": "^0.47.0", "pretendard": "^1.3.9", "prettier": "^3.2.5", + "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 693112a90..0d0b1940b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: prettier: specifier: ^3.2.5 version: 3.2.5 + rehype-slug: + specifier: ^6.0.0 + version: 6.0.0 rehype-stringify: specifier: ^10.0.0 version: 10.0.0 @@ -3162,6 +3165,9 @@ packages: resolution: {integrity: sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==} hasBin: true + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3266,6 +3272,9 @@ packages: hast-util-from-parse5@8.0.1: resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==} + hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -4538,6 +4547,9 @@ packages: resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} + rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + rehype-stringify@10.0.0: resolution: {integrity: sha512-1TX1i048LooI9QoecrXy7nGFFbFSufxVRAfc6Y9YMRAi56l+oB0zP51mLSV312uRuvVLPV1opSlJmslozR1XHQ==} @@ -8705,6 +8717,8 @@ snapshots: pathe: 1.1.2 tar: 6.2.1 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8837,6 +8851,10 @@ snapshots: vfile-location: 5.0.2 web-namespaces: 2.0.1 + hast-util-heading-rank@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -10697,6 +10715,14 @@ snapshots: es-errors: 1.3.0 set-function-name: 2.0.2 + rehype-slug@6.0.0: + dependencies: + '@types/hast': 3.0.4 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.0 + unist-util-visit: 5.0.0 + rehype-stringify@10.0.0: dependencies: '@types/hast': 3.0.4 diff --git a/src/app.tsx b/src/app.tsx index c01c6b265..c6ddf4756 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -3,6 +3,7 @@ import { Router } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; import { Suspense } from "solid-js"; +import { NotFoundBoundary } from "./components/404"; import Trackers from "./layouts/trackers/Trackers"; export default function App() { @@ -15,7 +16,9 @@ export default function App() { - {props.children} + + {props.children} + )} > diff --git a/src/components/404.tsx b/src/components/404.tsx new file mode 100644 index 000000000..24af3b9cc --- /dev/null +++ b/src/components/404.tsx @@ -0,0 +1,58 @@ +import { HttpStatusCode } from "@solidjs/start"; +import { ErrorBoundary, type JSXElement } from "solid-js"; + +import portoneGradientBg from "~/assets/portone-gradient-bg.png?imagetools"; +import portoneLogoWhite from "~/assets/portone-logo-white.png?imagetools"; + +import Picture from "./Picture"; + +export class NotFoundError extends Error { + constructor() { + super("Not Found"); + this.name = "NotFoundError"; + } +} + +export function NotFoundBoundary(props: { children: JSXElement }) { + return ( + { + if (err instanceof Error && err.name === "NotFoundError") { + return ; + } + throw err; + }} + > + {props.children} + + ); +} + +function NotFoundPage() { + return ( + <> + + +
+
+ + 404 + + 페이지를 찾을 수 없습니다 + +
+ +
+ + ); +} diff --git a/src/components/gitbook/ContentRef.astro b/src/components/gitbook/ContentRef.astro deleted file mode 100644 index 6145f502d..000000000 --- a/src/components/gitbook/ContentRef.astro +++ /dev/null @@ -1,24 +0,0 @@ ---- -import * as path from "node:path"; - -import { getEntry } from "astro:content"; - -interface Props { - slug: string; -} -const { slug } = Astro.props; -const entry = await getEntry("docs", slug.slice(1)); -const frontmatter = entry?.data; -const title = frontmatter?.title; ---- - -{ - title && ( - -
- {title} - -
-
- ) -} diff --git a/src/components/gitbook/ContentRef.tsx b/src/components/gitbook/ContentRef.tsx new file mode 100644 index 000000000..4c13da683 --- /dev/null +++ b/src/components/gitbook/ContentRef.tsx @@ -0,0 +1,31 @@ +import { cache, createAsync } from "@solidjs/router"; +import { createMemo, Show } from "solid-js"; + +interface Props { + slug: string; +} + +const getEntryData = cache(async (slug: string) => { + "use server"; + + const { docs } = await import("#content"); + return (docs as Record)[slug] + ?.frontmatter; +}, "docs/entry"); + +export default function ContentRef(props: Props) { + const slug = createMemo(() => props.slug.slice(1)); + const entryData = createAsync(() => getEntryData(slug())); + const title = createMemo(() => entryData()?.title); + + return ( + + +
+ {title()} + +
+
+
+ ); +} diff --git a/src/components/gitbook/VersionGate.astro b/src/components/gitbook/VersionGate.astro deleted file mode 100644 index 3810603eb..000000000 --- a/src/components/gitbook/VersionGate.astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -import { readServerSystemVersion } from "~/state/system-version/server"; - -import Impl from "./VersionGate"; - -interface Props { - default: "v1" | "v2"; -} - -const { default: version } = Astro.props; -const serverSystemVersion = readServerSystemVersion(); ---- - - - {Astro.slots.has("v1") ? : null} - {Astro.slots.has("v2") ? : null} - - diff --git a/src/components/gitbook/VersionGate.tsx b/src/components/gitbook/VersionGate.tsx index 182f7822d..367c95ff2 100644 --- a/src/components/gitbook/VersionGate.tsx +++ b/src/components/gitbook/VersionGate.tsx @@ -1,48 +1,14 @@ -import type React from "react"; +import { type JSXElement, Show } from "solid-js"; -import { useSystemVersion } from "#state/system-version"; -import { useServerFallback } from "~/misc/useServerFallback"; -import type { SystemVersion } from "~/type"; +import { useSystemVersion } from "~/state/system-version"; interface Props { - serverSystemVersion: SystemVersion; - default?: SystemVersion; - v1?: React.ReactNode; - v2?: React.ReactNode; - children?: React.ReactNode; + v: "v1" | "v2"; + children?: JSXElement; } -const isEmptyStaticHtml = (node: React.ReactNode) => { - if (!(node && typeof node === "object" && "props" in node)) return false; - const props = node.props as unknown; - - return Boolean( - props && - typeof props === "object" && - "value" in props && - !JSON.parse(JSON.stringify(props.value)), - ); -}; - export default function VersionGate(props: Props) { - const systemVersion = useServerFallback( - useSystemVersion(), - props.serverSystemVersion, - ); - - const hasV1 = !isEmptyStaticHtml(props.v1); - const hasV2 = !isEmptyStaticHtml(props.v2); - const v1 = hasV1 ? props.v1 : null; - const v2 = hasV2 ? props.v2 : null; + const { systemVersion } = useSystemVersion(); - return ( - <> - { - { - v1: props.default === "v1" ? v1 ?? props.children : v1, - v2: props.default === "v2" ? v2 ?? props.children : v2, - }[systemVersion] - } - - ); + return {props.children}; } diff --git a/src/content/docs/ko/readme.mdx b/src/content/docs/ko/readme.mdx index cf89a019e..3201ccb0b 100644 --- a/src/content/docs/ko/readme.mdx +++ b/src/content/docs/ko/readme.mdx @@ -4,20 +4,18 @@ description: 포트원 결제 연동 가이드입니다. 빠른 시간 안에 targetVersions: ["v1", "v2"] --- -import EasyGuideLink from "~/components/EasyGuideLink.tsx"; -import ContentRef from "~/components/gitbook/ContentRef.astro"; -import VersionGate from "~/components/gitbook/VersionGate.astro"; +import EasyGuideLink from "~/components/EasyGuideLink"; +import ContentRef from "~/components/gitbook/ContentRef"; +import VersionGate from "~/components/gitbook/VersionGate"; - + + ## 포트원 V2 신모듈 알아보기 -## 포트원 V2 신모듈 알아보기 - -새로워진 포트원 V2 신모듈에 대해 소개합니다. - - + 새로워진 포트원 V2 신모듈에 대해 소개합니다. + ## 연동 준비하기 @@ -26,96 +24,90 @@ import VersionGate from "~/components/gitbook/VersionGate.astro"; - - - -## 결제창 연동하기 - -해당 가이드를 통해 결제창을 손쉽게 연동할 수 있습니다. - - + + ## 결제창 연동하기 - + 해당 가이드를 통해 결제창을 손쉽게 연동할 수 있습니다. - + - - + -## 인증결제 연동하기 + + -해당 가이드를 통해 결제창(SDK) 결제를 손쉽게 연동할 수 있습니다. + + ## 인증결제 연동하기 - + 해당 가이드를 통해 결제창(SDK) 결제를 손쉽게 연동할 수 있습니다. -## 수기(키인)결제 연동하기 + -해당 가이드를 통해 API 결제를 손쉽게 연동할 수 있습니다. + ## 수기(키인)결제 연동하기 - + 해당 가이드를 통해 API 결제를 손쉽게 연동할 수 있습니다. -## 빌링키 결제 연동하기 + -해당 가이드를 통해 빌링키 결제를 손쉽게 연동할 수 있습니다. + ## 빌링키 결제 연동하기 - + 해당 가이드를 통해 빌링키 결제를 손쉽게 연동할 수 있습니다. - + ## 결제 결과 누락 없이 수신받기 해당 가이드를 통해 안정적으로 결제 결과를 수신받을 수 있습니다. - - - + + - - -## 본인인증 연동하기 + + + -해당 가이드를 통해 본인인증을 손쉽게 연동할 수 있습니다. + + ## 본인인증 연동하기 - + 해당 가이드를 통해 본인인증을 손쉽게 연동할 수 있습니다. + - - -## 기타 서비스 연동하기 + + ## 기타 서비스 연동하기 -해당 가이드를 통해 부가적인 서비스 연동을 손쉽게 처리할 수 있습니다. + 해당 가이드를 통해 부가적인 서비스 연동을 손쉽게 처리할 수 있습니다. - + - + - + - + - + -## TIP + ## TIP -결제창 연동 시 꼭 확인해 보세요. + 결제창 연동 시 꼭 확인해 보세요. - + - + - + - + - + - - - + + ## 관리자 콘솔 사용하기 @@ -128,8 +120,11 @@ import VersionGate from "~/components/gitbook/VersionGate.astro"; 포트원에서 제공하는 API 명세를 확인할 수 있습니다. - - + + + + + @@ -137,31 +132,28 @@ import VersionGate from "~/components/gitbook/VersionGate.astro"; 결제 연동 JS SDK 명세를 확인할 수 있습니다. - - - + + - - -## FAQ + + + - + + ## FAQ + ## PG사별 결제 연동 가이드 각 PG사별 결제 연동 가이드를 안내합니다. - - - - - - - - + + + - + + diff --git a/src/layouts/gnb/VersionSwitch.tsx b/src/layouts/gnb/VersionSwitch.tsx index 8a48886c1..5d33adbce 100644 --- a/src/layouts/gnb/VersionSwitch.tsx +++ b/src/layouts/gnb/VersionSwitch.tsx @@ -1,6 +1,6 @@ import { useLocation, useNavigate } from "@solidjs/router"; import clsx from "clsx"; -import { createEffect, createSignal } from "solid-js"; +import { createEffect, createSignal, startTransition } from "solid-js"; import { useSystemVersion } from "~/state/system-version"; import type { SystemVersion } from "~/type"; @@ -75,7 +75,9 @@ export function VersionSwitch(props: VersionSwitchProps) { style={{ transition: "margin 0.1s" }} onClick={() => { const newVersion = systemVersion() !== "v1" ? "v1" : "v2"; - setSystemVersion(newVersion); + void startTransition(() => { + setSystemVersion(newVersion); + }); setShowPopover(false); const mappedPath = diff --git a/src/layouts/rest-api/nav-menu/NavMenu.astro b/src/layouts/rest-api/nav-menu/NavMenu.astro index 679acf3ab..1a2390338 100644 --- a/src/layouts/rest-api/nav-menu/NavMenu.astro +++ b/src/layouts/rest-api/nav-menu/NavMenu.astro @@ -1,5 +1,5 @@ --- -import LeftSidebar from "~/layouts/sidebar/LeftSidebar.astro"; +import LeftSidebar from "~/layouts/sidebar/LeftSidebar"; import NavMenuLink from "./NavMenuLink.astro"; diff --git a/src/layouts/sidebar/DocsNavMenu.astro b/src/layouts/sidebar/DocsNavMenu.astro deleted file mode 100644 index 91c5fa4b4..000000000 --- a/src/layouts/sidebar/DocsNavMenu.astro +++ /dev/null @@ -1,125 +0,0 @@ ---- -import { navOpenStatesSignal, slugSignal } from "~/state/nav"; -import { calcNavMenuAncestors, navMenu } from "~/state/server-only/nav"; -import { readServerSystemVersion } from "~/state/system-version/server"; -import type { Lang } from "~/type"; - -import DropdownLink from "./DropdownLink"; -import LeftSidebar from "./LeftSidebar.astro"; -import LeftSidebarItem from "./LeftSidebarItem"; -import { SearchButton } from "./search"; - -interface Props { - lang: Lang; - slug: string; -} -const { lang, slug } = Astro.props; - -const navMenuItems = navMenu[lang] || []; -const navMenuAncestors = calcNavMenuAncestors(navMenuItems); -const navOpenStates = (navOpenStatesSignal.value = Object.fromEntries([ - ...(navMenuAncestors[slug]?.map((ancestor) => [ancestor, true]) || []), - [slug, true], -])); -slugSignal.value = slug; - -const serverSystemVersion = readServerSystemVersion(); ---- - - - - -
-
- -
-
-
-
- -
- -
diff --git a/src/layouts/sidebar/DocsNavMenu.tsx b/src/layouts/sidebar/DocsNavMenu.tsx new file mode 100644 index 000000000..0c449f861 --- /dev/null +++ b/src/layouts/sidebar/DocsNavMenu.tsx @@ -0,0 +1,154 @@ +import { cache, createAsync, useLocation } from "@solidjs/router"; +import { + createContext, + createEffect, + createMemo, + createRenderEffect, + createSignal, + For, + onMount, + Show, + useContext, +} from "solid-js"; + +import { calcNavMenuAncestors } from "~/state/nav"; +import { useSystemVersion } from "~/state/system-version"; +import type { Lang } from "~/type"; + +import DropdownLink from "./DropdownLink"; +import LeftSidebar from "./LeftSidebar"; +import LeftSidebarItem from "./LeftSidebarItem"; +import { SearchButton } from "./search"; + +interface Props { + lang: Lang; + slug: string; +} + +const getNavMenuItems = cache(async (lang: Lang) => { + "use server"; + + const { navMenu } = await import("~/state/server-only/nav"); + return navMenu[lang] || []; +}, "nav/menu-items"); + +const navOpenStates = createContext({ + openNavs: (): Set => new Set(), + toggleNav: (_: string) => {}, +}); + +export const useNavOpenStates = () => useContext(navOpenStates); + +export default function DocsNavMenu(props: Props) { + const { systemVersion } = useSystemVersion(); + const location = useLocation(); + const navMenuItems = createAsync(() => getNavMenuItems(props.lang)); + const navMenuAncestors = createMemo(() => { + const items = navMenuItems(); + if (!items) return null; + return calcNavMenuAncestors(items); + }); + const [openNavs, setOpenNavs] = createSignal>(new Set()); + + createRenderEffect(() => { + const ancestors = navMenuAncestors(); + if (!ancestors) return; + const slug = `/${props.lang}/${props.slug}`; + setOpenNavs(new Set([...(ancestors[slug] ?? []), slug])); + }); + + onMount(() => { + const prevOpenNavs = JSON.parse( + globalThis.sessionStorage?.getItem("openNavs") || "[]", + ) as string[]; + setOpenNavs((openNavs) => new Set([...prevOpenNavs, ...openNavs])); + }); + + createEffect(() => { + const openNavsStr = JSON.stringify([...openNavs()]); + globalThis.sessionStorage.setItem("openNavs", openNavsStr); + }); + + const toggleNav = (slug: string) => { + setOpenNavs((openNavs) => { + const newOpenNavs = new Set(openNavs); + if (newOpenNavs.has(slug)) newOpenNavs.delete(slug); + else newOpenNavs.add(slug); + return newOpenNavs; + }); + }; + + return ( + + +
+
+ +
+
+
+
+ +
+ +
+
+ ); +} diff --git a/src/layouts/sidebar/DropdownLink.tsx b/src/layouts/sidebar/DropdownLink.tsx index 1355800d4..6603b6c53 100644 --- a/src/layouts/sidebar/DropdownLink.tsx +++ b/src/layouts/sidebar/DropdownLink.tsx @@ -1,57 +1,57 @@ -import { useSignal } from "@preact/signals"; import clsx from "clsx"; -import type React from "preact/compat"; +import { createSignal, For, type JSXElement, Show } from "solid-js"; export interface DropdownLinkProps { items: DropdownItem[]; pathname: string; } export interface DropdownItem { - label: React.ReactNode; + label: JSXElement; link: string; } -export default function DropdownLink({ items, pathname }: DropdownLinkProps) { - const showItemsSignal = useSignal(false); +export default function DropdownLink(props: DropdownLinkProps) { + const [showItems, setShowItems] = createSignal(false); + return (
); diff --git a/src/layouts/sidebar/LeftSidebar.astro b/src/layouts/sidebar/LeftSidebar.astro deleted file mode 100644 index 41cf00744..000000000 --- a/src/layouts/sidebar/LeftSidebar.astro +++ /dev/null @@ -1,23 +0,0 @@ - - diff --git a/src/layouts/sidebar/LeftSidebar.tsx b/src/layouts/sidebar/LeftSidebar.tsx new file mode 100644 index 000000000..a1db8ab85 --- /dev/null +++ b/src/layouts/sidebar/LeftSidebar.tsx @@ -0,0 +1,20 @@ +import type { JSXElement } from "solid-js"; + +import { useSidebarContext } from "./context"; + +export default function LeftSidebar(props: { children: JSXElement }) { + const sidebarOpen = useSidebarContext(); + + return ( + + ); +} diff --git a/src/layouts/sidebar/LeftSidebarItem.tsx b/src/layouts/sidebar/LeftSidebarItem.tsx index 101de8105..3a17b5831 100644 --- a/src/layouts/sidebar/LeftSidebarItem.tsx +++ b/src/layouts/sidebar/LeftSidebarItem.tsx @@ -1,82 +1,98 @@ -import { useComputed } from "@preact/signals"; +import { createMemo, For, onMount, Show } from "solid-js"; -import { useSystemVersion } from "#state/system-version"; -import { navOpenStatesSignal, slugSignal } from "~/state/nav"; -import type { NavMenuPage } from "~/state/server-only/nav"; +import { type NavMenuPage } from "~/state/nav"; +import { useSystemVersion } from "~/state/system-version"; import type { SystemVersion } from "~/type"; -function LeftSidebarItem(props: NavMenuPage) { - const systemVersion = useSystemVersion(); - if (props.items.length > 0) return ; - const { title, path } = props; - const pageSlug = slugSignal.value; - const isActive = pageSlug === path; - const [href, isExternal] = (() => { +import { useNavOpenStates } from "./DocsNavMenu"; + +function LeftSidebarItem(props: NavMenuPage & { pageSlug: string }) { + const { systemVersion } = useSystemVersion(); + const isActive = createMemo(() => props.pageSlug === props.path); + const path = createMemo(() => { try { - return [new URL(path).toString(), true]; + return { href: new URL(props.path).toString(), isExternal: true }; } catch (e) { - return [`/docs${path}`, false]; + return { href: `/docs${props.path}`, isExternal: false }; } - })(); + }); return ( - + } + > + + ); } export default LeftSidebarItem; -function FolderLink({ title, path, items, systemVersion }: NavMenuPage) { - const openSignal = useComputed(() => !!navOpenStatesSignal.value[path]); - const open = openSignal.value; - const pageSlug = slugSignal.value; - const isActive = pageSlug === path; +function FolderLink( + props: NavMenuPage & { pageSlug: string; isActive: boolean }, +) { + const { systemVersion } = useSystemVersion(); + const { openNavs, toggleNav } = useNavOpenStates(); + const isOpen = createMemo(() => openNavs().has(props.path)); + let anchorRef: HTMLDivElement | undefined; + + onMount(() => { + if (props.isActive && anchorRef) { + const scrollArea = document.getElementById("nav-menu")!; + scrollArea.scrollTop = + anchorRef.getBoundingClientRect().top - + scrollArea.getBoundingClientRect().top - + 50; + } + }); + return ( -
-
- - - - -
-
-
    - {items.map((item) => ( -
  • - -
  • - ))} -
+ +
+
+ + + + +
+
+
    + + {(item) => ( + + + + )} + +
+
-
+ ); } @@ -91,25 +107,26 @@ export interface JustLinkProps { props: object; }; } -export function JustLink({ - title, - href, - isExternal, - isActive, - systemVersion, - event, -}: JustLinkProps) { +export function JustLink(props: JustLinkProps) { + const { systemVersion } = useSystemVersion(); + return ( - event && trackEvent(event.name, event.props)} - target={isExternal ? "_blank" : "_self"} - > - - + + + props.event && trackEvent(props.event.name, props.event.props) + } + target={props.isExternal ? "_blank" : "_self"} + > + + + ); } @@ -125,13 +142,20 @@ interface LinkTitleProps { title: string; isExternal?: boolean | undefined; } -export function LinkTitle({ title, isExternal }: LinkTitleProps) { +export function LinkTitle(props: LinkTitleProps) { return ( - {title || (unknown page)} - {isExternal && ( + + (unknown page)} + > + {props.title} + + + - )} + ); } diff --git a/src/layouts/sidebar/RightSidebar.tsx b/src/layouts/sidebar/RightSidebar.tsx index 400b35595..e9f68cd14 100644 --- a/src/layouts/sidebar/RightSidebar.tsx +++ b/src/layouts/sidebar/RightSidebar.tsx @@ -1,7 +1,13 @@ -import type React from "preact/compat"; -import { useEffect, useState } from "react"; +import { + createEffect, + createSignal, + For, + type JSXElement, + mergeProps, + Show, +} from "solid-js"; -import { useSystemVersion } from "#state/system-version"; +import { useSystemVersion } from "~/state/system-version"; export interface RightSidebarProps { lang: string; @@ -14,52 +20,52 @@ export interface TocItem { text: string; children: TocItem[]; } -function RightSidebar({ - lang, - slug, - editThisPagePrefix = "https://github.com/portone-io/developers.portone.io/blob/main/src/content/docs", -}: RightSidebarProps) { - const [toc, setToc] = useState(null); - const systemVersion = useSystemVersion(); +function RightSidebar(_props: RightSidebarProps) { + const props = mergeProps(_props, { + editThisPagePrefix: + "https://github.com/portone-io/developers.portone.io/blob/main/src/content/docs", + }); - useEffect(() => { - setToc(headingsToToc(lang)); - }, [systemVersion]); + const [toc, setToc] = createSignal(null); + const { systemVersion } = useSystemVersion(); + + createEffect(() => { + void systemVersion(); + setToc(headingsToToc(props.lang)); + }); return ( ); } @@ -69,32 +75,32 @@ export default RightSidebar; interface LinkProps { href: string; icon?: string; - label: React.ReactNode; - children?: React.ReactNode; + label: JSXElement; + children?: JSXElement; } -function SidebarItem({ href, icon, label, children }: LinkProps) { +function SidebarItem(props: LinkProps) { return (
  • { - if (!href.startsWith("#")) return; + if (!props.href.startsWith("#")) return; e.preventDefault(); - const slug = href.slice(1); - history.replaceState(null, "", href); + const slug = props.href.slice(1); + history.replaceState(null, "", props.href); document.getElementById(slug)?.scrollIntoView({ behavior: "smooth" }); }} >
    - {icon && ( + <> - {" "} + {" "} - )} - {label} + + {props.label}
    - {children} + {props.children}
  • ); } diff --git a/src/layouts/sidebar/search.tsx b/src/layouts/sidebar/search.tsx index ae5945dbc..a1fbd687a 100644 --- a/src/layouts/sidebar/search.tsx +++ b/src/layouts/sidebar/search.tsx @@ -1,19 +1,48 @@ -import { computed, signal } from "@preact/signals"; import Fuse from "fuse.js"; -import * as React from "react"; +import { + createContext, + createMemo, + createResource, + createSignal, + For, + type JSXElement, + Match, + Show, + Switch, + useContext, +} from "solid-js"; -import { useSystemVersion } from "#state/system-version"; import { lazy } from "~/misc/async"; -import type { NavMenuSystemVersions } from "~/state/server-only/nav"; +import type { NavMenuSystemVersions } from "~/state/nav"; +import { useSystemVersion } from "~/state/system-version"; + +const SearchContext = createContext({ + open: (): boolean => false, + setOpen: (_: boolean): void => {}, +}); + +export function SearchProvider(props: { children: JSXElement }) { + const [open, setOpen] = createSignal(false); + + return ( + + {props.children} + + ); +} + +export const useSearchContext = () => useContext(SearchContext); export interface SearchButtonProps { lang: string; } export function SearchButton({ lang }: SearchButtonProps) { + const { setOpen } = useSearchContext(); + return (
    - {searchResult.length ? ( - - ) : fuse ? ( - - ) : ( - - )} + }> + 0}> + + + + + +
    @@ -166,8 +188,8 @@ function Waiting() { cy="15" r="12" stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" + stroke-width="2" + stroke-linecap="round" fill="transparent" /> diff --git a/src/misc/contentIndex.ts b/src/misc/contentIndex.ts new file mode 100644 index 000000000..998a21624 --- /dev/null +++ b/src/misc/contentIndex.ts @@ -0,0 +1,7 @@ +export const indexFilesMapping = { + blog: "blog/", + "docs-en": "docs/en/", + "docs-ko": "docs/ko/", + "release-notes": "release-notes/", +} as const satisfies Record; +export type IndexFileName = keyof typeof indexFilesMapping; diff --git a/src/misc/server-only/browserError.ts b/src/misc/server-only/browserError.ts new file mode 100644 index 000000000..f99004f94 --- /dev/null +++ b/src/misc/server-only/browserError.ts @@ -0,0 +1 @@ +throw new Error("#server-only imported on browser"); diff --git a/src/misc/server-only/serverNoop.ts b/src/misc/server-only/serverNoop.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/pages/api/rest-v2-legacy/[...slug].astro b/src/pages/api/rest-v2-legacy/[...slug].astro index be99e7777..e066c4a6b 100644 --- a/src/pages/api/rest-v2-legacy/[...slug].astro +++ b/src/pages/api/rest-v2-legacy/[...slug].astro @@ -5,7 +5,7 @@ import * as prose from "~/components/prose"; import ArticleStyle from "~/layouts/ArticleStyle.astro"; import Logo from "~/layouts/gnb/Logo.astro"; import MobileMenuButton from "~/layouts/gnb/MobileMenuButton"; -import LeftSidebar from "~/layouts/sidebar/LeftSidebar.astro"; +import LeftSidebar from "~/layouts/sidebar/LeftSidebar"; import { JustLink } from "~/layouts/sidebar/LeftSidebarItem"; import RightSidebar from "~/layouts/sidebar/RightSidebar"; import Trackers from "~/layouts/trackers/Trackers.astro"; diff --git a/src/routes/(root)/docs/[lang]/[...slug].tsx b/src/routes/(root)/docs/[lang]/[...slug].tsx new file mode 100644 index 000000000..fddcc9ee3 --- /dev/null +++ b/src/routes/(root)/docs/[lang]/[...slug].tsx @@ -0,0 +1,110 @@ +import { + cache, + createAsync, + redirect, + type RouteDefinition, + useParams, +} from "@solidjs/router"; +import { createMemo, lazy, Show, Suspense } from "solid-js"; + +import { docs } from "#content"; +import { NotFoundError } from "~/components/404"; +import * as prose from "~/components/prose"; +import DocsNavMenu from "~/layouts/sidebar/DocsNavMenu"; +import RightSidebar from "~/layouts/sidebar/RightSidebar"; +import { SearchProvider, SearchScreen } from "~/layouts/sidebar/search"; +import { calcNavMenuSystemVersions } from "~/state/nav"; +import { isLang, Lang } from "~/type"; + +const loadRedirection = cache(async (slug: string) => { + "use server"; + + const { default: redirYaml } = await import("~/content/docs/_redir.yaml"); + const redir = redirYaml.find(({ old }) => old === slug); + if (!redir) return; + return redir.new.startsWith("/") ? `/docs${redir.new}` : redir.new; +}, "docs/redirection"); + +const loadDocsSlug = cache(async (params: Record) => { + const lang = params.lang as Lang; + const slug = params.slug as string; + const fullSlug = `${lang}/${slug}`; + + const redirection = await loadRedirection(`/${fullSlug}`); + if (redirection) throw redirect(redirection, 301); + if (!(fullSlug in docs)) throw new NotFoundError(); + + return fullSlug as keyof typeof docs; +}, "docs/content"); + +const loadNavMenuSystemVersions = cache(async (lang: Lang) => { + "use server"; + + const { navMenu } = await import("~/state/server-only/nav"); + return calcNavMenuSystemVersions(navMenu[lang] || []); +}, "docs/nav-menu-system-versions"); + +export const route = { + matchFilters: { + lang: isLang, + }, + load: ({ params }) => { + void loadDocsSlug(params); + void loadNavMenuSystemVersions(params.lang as Lang); + }, +} satisfies RouteDefinition; + +export default function Docs() { + const params = useParams<{ + lang: Lang; + slug: string; + }>(); + const docSlug = createAsync(() => loadDocsSlug(params), { + deferStream: true, + }); + const doc = createMemo(() => { + const slug = docSlug(); + if (!slug) return null; + return docs[slug]; + }); + const content = createMemo(() => { + const load = doc()?.load; + if (!load) return null; + const Content = lazy(load); + return ; + }); + const navMenuSystemVersions = createAsync(() => + loadNavMenuSystemVersions(params.lang), + ); + + return ( + +
    + +
    +
    +
    + {doc()?.frontmatter.title} + {doc()?.frontmatter.description && ( +

    {doc()?.frontmatter.description}

    + )} +
    + {content()} +
    + + +
    +
    + + + {(versions) => ( + + )} + + +
    + ); +} diff --git a/src/routes/[...404].tsx b/src/routes/[...404].tsx index 40dd87d45..5e2f81e16 100644 --- a/src/routes/[...404].tsx +++ b/src/routes/[...404].tsx @@ -1,31 +1,5 @@ -import portoneGradientBg from "~/assets/portone-gradient-bg.png?imagetools"; -import portoneLogoWhite from "~/assets/portone-logo-white.png?imagetools"; -import Picture from "~/components/Picture"; +import { NotFoundError } from "~/components/404"; export default function NotFound() { - return ( - <> - -
    -
    - - 404 - - 페이지를 찾을 수 없습니다 - -
    - -
    - - ); + throw new NotFoundError(); } diff --git a/src/routes/content-index/[fileName].ts b/src/routes/content-index/[fileName].ts new file mode 100644 index 000000000..6e43e4da8 --- /dev/null +++ b/src/routes/content-index/[fileName].ts @@ -0,0 +1,69 @@ +import type { APIEvent } from "@solidjs/start/server"; +import * as yaml from "js-yaml"; + +import { type IndexFileName, indexFilesMapping } from "~/misc/contentIndex"; +import { toPlainText } from "~/misc/mdx"; + +export async function GET({ params }: APIEvent) { + const fileName = params.fileName?.replace(".json", ""); + const slug = + fileName && + fileName in indexFilesMapping && + indexFilesMapping[fileName as IndexFileName]; + if (!slug) return new Response(null, { status: 404 }); + const entryMap = import.meta.glob("../../content/**/*.mdx", { + query: "?raw", + }); + const mdxTable = Object.fromEntries( + ( + await Promise.all( + Object.entries(entryMap).map(async ([path, importEntry]) => { + const match = path.match(/\/content\/(.+)\.mdx$/); + if (!match || !match[1]?.startsWith(slug)) return; + const entry = await importEntry(); + if ( + !entry || + typeof entry !== "object" || + !("default" in entry) || + typeof entry.default !== "string" + ) + return; + + const { frontmatter, md } = cutFrontmatter(entry.default); + if (!frontmatter || typeof frontmatter !== "object") return; + const entrySlug = String( + "slug" in frontmatter ? frontmatter.slug : match[1], + ); + return [ + entrySlug, + { ...frontmatter, slug: entrySlug, text: toPlainText(md) }, + ] as const; + }), + ) + ).filter(Boolean), + ); + return new Response(JSON.stringify(Object.values(mdxTable)), { + headers: { + "Content-Type": "application/json", + }, + }); +} + +interface CutFrontmatterResult { + frontmatter: unknown; + md: string; +} +function cutFrontmatter(md: string): CutFrontmatterResult { + const match = md.match( + /^---\r?\n((?:.|\r|\n)*?)\r?\n---\r?\n((?:.|\r|\n)*)$/, + ); + if (!match) return { frontmatter: {}, md }; + try { + const fm = match[1] || ""; + const md = match[2] || ""; + const frontmatter = yaml.load(fm); + return { frontmatter, md }; + } catch { + return { frontmatter: {}, md }; + } +} diff --git a/src/state/nav.ts b/src/state/nav.ts index 6d8e4a1fa..db3e770c3 100644 --- a/src/state/nav.ts +++ b/src/state/nav.ts @@ -1,21 +1,73 @@ -import { effect, signal } from "@preact/signals"; +import type { SystemVersion } from "~/type"; -const isClient = Boolean(globalThis.sessionStorage); +export interface NavMenu { + [lang: string]: NavMenuItem[]; +} + +export type NavMenuItem = NavMenuPage | NavMenuGroup; -export interface NavOpenStates { - [slug: string]: boolean; // true: open, false: close +export interface NavMenuPage { + type: "page"; + path: string; + title: string; + items: NavMenuPage[]; + systemVersion?: SystemVersion | undefined; } -export const navOpenStatesSignal = signal( - JSON.parse( - globalThis.sessionStorage?.getItem("navOpenStates") || "{}", - ) as NavOpenStates, -); -if (isClient) { - effect(() => { - const navOpenStates = navOpenStatesSignal.value; - const navOpenStatesString = JSON.stringify(navOpenStates); - globalThis.sessionStorage.setItem("navOpenStates", navOpenStatesString); - }); + +export interface NavMenuGroup { + type: "group"; + label: string; + items: NavMenuPage[]; + systemVersion?: SystemVersion | undefined; } -export const slugSignal = signal(""); +export interface NavMenuSystemVersions { + [path: string]: SystemVersion; +} +export function calcNavMenuSystemVersions( + navMenuItems: NavMenuItem[], +): NavMenuSystemVersions { + const result: NavMenuSystemVersions = {}; + for (const item of iterNavMenuItems(navMenuItems)) { + if (!("path" in item)) continue; + if (item.systemVersion) result[item.path] = item.systemVersion; + } + return result; +} + +function* iterNavMenuItems(items: NavMenuItem[]): Generator { + for (const item of items) { + yield item; + if ("items" in item) yield* iterNavMenuItems(item.items); + } +} + +export interface NavMenuAncestors { + [slug: string]: string[]; +} + +export function calcNavMenuAncestors( + navMenuItems: NavMenuItem[], +): NavMenuAncestors { + const navMenuAncestors: NavMenuAncestors = {}; + for (const { slug, ancestors } of iterNavMenuAncestors(navMenuItems)) { + if (navMenuAncestors[slug]) navMenuAncestors[slug]!.push(...ancestors); + else navMenuAncestors[slug] = ancestors; + } + return navMenuAncestors; +} + +function* iterNavMenuAncestors( + navMenuItems: NavMenuItem[], + ancestors: string[] = [], +): Generator<{ slug: string; ancestors: string[] }> { + for (const item of navMenuItems) { + if (item.type === "group") { + yield* iterNavMenuAncestors(item.items, ancestors); + } else if (item.type === "page") { + const { path: slug, items } = item; + yield { slug, ancestors }; + yield* iterNavMenuAncestors(items, [...ancestors, slug]); + } + } +} diff --git a/src/state/server-only/nav.ts b/src/state/server-only/nav.ts index 6608b3179..4c00ad661 100644 --- a/src/state/server-only/nav.ts +++ b/src/state/server-only/nav.ts @@ -1,9 +1,11 @@ -import * as path from "node:path"; +import "#server-only"; -import { getCollection } from "astro:content"; +import * as path from "node:path"; +import { docs } from "#content"; import navYamlEn from "~/content/docs/en/_nav.yaml"; import navYamlKo from "~/content/docs/ko/_nav.yaml"; +import type { NavMenuItem, NavMenuPage } from "~/state/nav"; import type { SystemVersion, YamlNavMenuToplevelItem } from "~/type"; type Frontmatter = { @@ -11,10 +13,10 @@ type Frontmatter = { }; type Frontmatters = Record; -const frontmatters: Frontmatters = (await getCollection("docs")) +const frontmatters: Frontmatters = Object.values(docs) .map((entry) => { const absSlug = path.posix.join("/", entry.slug); - const frontmatter = entry.data || {}; + const frontmatter = entry.frontmatter || {}; return { absSlug, frontmatter }; }) .reduce((acc, { absSlug, frontmatter }) => { @@ -22,23 +24,6 @@ const frontmatters: Frontmatters = (await getCollection("docs")) return acc; }, {} as Frontmatters); -export interface NavMenu { - [lang: string]: NavMenuItem[]; -} -export type NavMenuItem = NavMenuPage | NavMenuGroup; -export interface NavMenuPage { - type: "page"; - path: string; - title: string; - items: NavMenuPage[]; - systemVersion?: SystemVersion | undefined; -} -export interface NavMenuGroup { - type: "group"; - label: string; - items: NavMenuPage[]; - systemVersion?: SystemVersion | undefined; -} export const navMenuItemsEn = toNavMenuItems( navYamlEn as YamlNavMenuToplevelItem[], frontmatters, @@ -49,55 +34,6 @@ export const navMenuItemsKo = toNavMenuItems( ); export const navMenu = { en: navMenuItemsEn, ko: navMenuItemsKo }; -export interface NavMenuSystemVersions { - [path: string]: SystemVersion; -} -export function calcNavMenuSystemVersions( - navMenuItems: NavMenuItem[], -): NavMenuSystemVersions { - const result: NavMenuSystemVersions = {}; - for (const item of iterNavMenuItems(navMenuItems)) { - if (!("path" in item)) continue; - if (item.systemVersion) result[item.path] = item.systemVersion; - } - return result; -} - -export interface NavMenuAncestors { - [slug: string]: string[]; -} -export function calcNavMenuAncestors( - navMenuItems: NavMenuItem[], -): NavMenuAncestors { - const navMenuAncestors: NavMenuAncestors = {}; - for (const { slug, ancestors } of iterNavMenuAncestors(navMenuItems)) { - if (navMenuAncestors[slug]) navMenuAncestors[slug]!.push(...ancestors); - else navMenuAncestors[slug] = ancestors; - } - return navMenuAncestors; -} -function* iterNavMenuAncestors( - navMenuItems: NavMenuItem[], - ancestors: string[] = [], -): Generator<{ slug: string; ancestors: string[] }> { - for (const item of navMenuItems) { - if (item.type === "group") { - yield* iterNavMenuAncestors(item.items, ancestors); - } else if (item.type === "page") { - const { path: slug, items } = item; - yield { slug, ancestors }; - yield* iterNavMenuAncestors(items, [...ancestors, slug]); - } - } -} - -function* iterNavMenuItems(items: NavMenuItem[]): Generator { - for (const item of items) { - yield item; - if ("items" in item) yield* iterNavMenuItems(item.items); - } -} - function toNavMenuItems( yaml: YamlNavMenuToplevelItem[], frontmatters: Frontmatters, diff --git a/src/state/sidebar.ts b/src/state/sidebar.ts deleted file mode 100644 index 980892f43..000000000 --- a/src/state/sidebar.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { signal } from "@preact/signals"; - -export const sidebarOpenSignal = signal(false);