npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cp949/geul-react

v0.1.1

Published

Readme

@cp949/geul-react

@cp949/geul-core 위에 구현한 React 바인딩과 UI 컴포넌트다. EditorProvider/EditorContent로 에디터를 렌더링하고, FormattingToolbar/LinkToolbar/MediaToolbar/FilePanel/SlashMenu/EmojiPicker/StaticToolbar 등 보조 UI를 함께 제공한다. FormattingToolbar는 텍스트 선택 시에만 뜨는 반면 StaticToolbar는 선택과 무관하게 항상 렌더되는 옵트인 툴바다 — 위치(예: 상단 고정)는 강제하지 않으므로 className으로 소비 앱이 직접 CSS(position: sticky 등)를 붙인다.

최소 사용 예

import { EditorContent, EditorProvider } from "@cp949/geul-react";
import "@cp949/geul-react/styles.css";
import { createEmptyDocument, createRandomDocumentId } from "@cp949/geul-model";

// 인자는 블록 id를 만드는 함수다.
// createRandomDocumentId는 Chrome75 호환 UUID v4 생성기다.
const initialDocument = createEmptyDocument(createRandomDocumentId);

function Editor() {
  return (
    <EditorProvider initialDocument={initialDocument}>
      <EditorContent />
    </EditorProvider>
  );
}

기능 구성과 업로드

블록 타입 on/off

createEditor()/EditorProvider에 enabledBlockTypes: { mode: "allow" | "deny", types: Block["type"][] }를 넘기면 지정한 블록만 허용하거나 차단한다. 마운트 시점에 고정되고(런타임 토글 불가), SlashMenu·toolbar 등 UI가 비활성 블록 항목을 자동으로 숨긴다.

<EditorProvider
  initialDocument={initialDocument}
  enabledBlockTypes={{ mode: "deny", types: ["table"] }}
>
  <EditorContent />
</EditorProvider>

실제 연결 예는 apps/showcase의 src/examples/16-enabled-block-types를 참고한다.

이미지 업로드

EditorProvider의 uploadFile: (file, signal) => Promise<UploadResult> 콜백이 이미지·비디오·오디오·파일 블록의 업로드를 전부 처리한다 — 성공 시 { status: "success", url }, 실패 시 에러 코드, 취소 시 { status: "cancelled" }를 돌려준다. 실제 연결 예는 apps/showcase의 src/examples/07-media를 참고한다.

<EditorProvider
  initialDocument={initialDocument}
  uploadFile={async (file, signal) => {
    const res = await fetch("/api/upload", { method: "POST", body: file, signal });
    if (!res.ok) return { status: "error", code: String(res.status), message: res.statusText };
    const { url } = await res.json();
    return { status: "success", url };
  }}
>
  <EditorContent />
</EditorProvider>

파일 업로드

이미지와 동일한 uploadFile 콜백을 쓴다 — 별도 콜백은 없다. FilePanel UI 자체는 apps/showcase의 src/examples/06-file-panel, 업로드까지 연결된 예는 07-media에 있다.

<EditorProvider initialDocument={initialDocument} uploadFile={uploadFile}>
  <EditorContent />
  <FilePanel />
</EditorProvider>

코드 구문 강조 연결

geul은 highlight.js·Prism·Shiki 같은 구문 강조 라이브러리를 소유하지 않는다. packages/core가 중립적인 공개 seam 타입 SyntaxHighlighter만 정의하고, ProseMirror 연결(decoration 생성·캐시·비동기 재계산)은 내부적으로 prosemirror-highlight가 처리한다. EditorProvider에 syntaxHighlighter 옵션을 연결하지 않으면 모든 코드 블록은 조용히 plain text로 렌더된다 — 에러도 경고도 없다.

type SyntaxHighlightToken = {
  /** source 문자열 안 시작 오프셋(0-indexed, code unit 기준). */
  from: number;
  /** source 문자열 안 끝 오프셋(exclusive). */
  to: number;
  /** 적용할 CSS class. 색상 자체는 geul이 소유하지 않는다 — 이 class를 정의하는 스타일시트는 소비자(또는 소비자가 고른 하이라이터의 테마)가 공급한다. */
  className?: string;
};

type SyntaxHighlighter = (input: {
  source: string;
  language: string | undefined;
}) => readonly SyntaxHighlightToken[] | Promise<readonly SyntaxHighlightToken[]>;

syntaxHighlighter는 initialDocument와 같은 마운트 시점 옵션이다 — 런타임에 껐다 켰다 할 수 없다. 아래는 lowlight(highlight.js 어댑터)로 만든 최소 실행 가능 예제다 — lowlight는 hast(hypertext AST) 트리를 돌려줄 뿐 SyntaxHighlighter 시그니처를 모르므로, 트리를 순회해 source 오프셋을 직접 계산하는 얇은 어댑터가 필요하다.

import { EditorContent, EditorProvider } from "@cp949/geul-react";
import { createEmptyDocument, createRandomDocumentId } from "@cp949/geul-model";
import "highlight.js/styles/github.css";
import { common, createLowlight } from "lowlight";

const lowlight = createLowlight(common);

type HastNode = ReturnType<typeof lowlight.highlight>["children"][number];
type Token = { from: number; to: number; className?: string };

// hast 트리를 source 오프셋 기준 token 목록으로 평탄화한다.
function flattenHastToTokens(
  nodes: readonly HastNode[],
  offset: number,
  tokens: Token[],
): number {
  let cursor = offset;
  for (const node of nodes) {
    if (node.type === "text") {
      cursor += node.value.length;
      continue;
    }
    if (node.type === "element") {
      const from = cursor;
      cursor = flattenHastToTokens(node.children, cursor, tokens);
      const classNameProp = node.properties?.className;
      const className = Array.isArray(classNameProp)
        ? classNameProp.join(" ")
        : typeof classNameProp === "string"
          ? classNameProp
          : undefined;
      tokens.push({ from, to: cursor, ...(className && { className }) });
    }
  }
  return cursor;
}

// 미지원/빈 language는 geul이 관여하지 않는다 — 빈 배열을 돌려주면
// 그 블록은 plain text로 남는다.
function lowlightSyntaxHighlighter({
  source,
  language,
}: {
  source: string;
  language: string | undefined;
}) {
  if (language === undefined || !lowlight.registered(language)) return [];
  const tree = lowlight.highlight(language, source);
  const tokens: Token[] = [];
  flattenHastToTokens(tree.children, 0, tokens);
  return tokens;
}

// 인자는 블록 id를 만드는 함수다.
// createRandomDocumentId는 Chrome75 호환 UUID v4 생성기다.
const initialDocument = createEmptyDocument(createRandomDocumentId);

function Editor() {
  return (
    <EditorProvider
      initialDocument={initialDocument}
      syntaxHighlighter={lowlightSyntaxHighlighter}
    >
      <EditorContent />
    </EditorProvider>
  );
}

EditorProvider는 언어 선택 콤보박스의 후보 목록을 바꾸는 codeBlockLanguages 옵션도 받는다(주지 않으면 javascript·typescript·html·css·json·bash·python·java·kotlin·sql·markdown 11개 기본값). 이 옵션은 initialDocument와 달리 매 렌더 반영되는 reactive 옵션이고, 콤보박스가 제안하는 후보만 바꿀 뿐 자유 입력 자체를 막지는 않는다.

<EditorProvider
  initialDocument={initialDocument}
  syntaxHighlighter={lowlightSyntaxHighlighter}
  codeBlockLanguages={[
    { id: "javascript", label: "JavaScript", aliases: ["js"] },
    { id: "python", label: "Python", aliases: ["py"] },
  ]}
>
  <EditorContent />
</EditorProvider>

highlight.js/lowlight 외 나머지 4개 라이브러리(Prism/refractor, Shiki, CodeMirror/lezer, sugar-high)로 만든 동작 예제는 apps/showcase의 src/examples/10-syntax-highlighting-lowlight~14-syntax-highlighting-sugar-high 5개 폴더가 각각 자기완결적으로 담고 있다 — 라이브러리마다 hast 트리(lowlight·refractor), 콜백 기반 flat 구간(lezer), 오프셋 없는 줄→토큰 트리(sugar-high), 이미 오프셋을 가진 토큰(Shiki)처럼 출력 모양이 달라 어댑터 구현이 서로 다르다.

알려진 제약

  • EditorProvider/EditorContent는 서버 렌더 환경에서 null을 렌더하고, 실제 편집기 생성(createEditor(), @cp949/geul-core)은 useEffect 안에서만 호출한다. createEditor()를 이 경로 없이 직접 호출하는 저수준 사용은 서버 환경에서도 크래시하지 않지만(EXT-013), 반환된 controller의 문서는 로드 시점 정규화가 실제 client mount 시점까지 지연된 상태일 수 있다.
  • StaticToolbar는 아직 안정성이 부족하다. 보완 예정이다.