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

@schemastud/blockdoc

v0.1.0

Published

Manifest-driven rich-content documents on Tiptap: TypeScript types for the blockdoc node-manifest format, assemblePMSchema (manifest → ProseMirror Schema via category-derived content expressions, the conformance oracle), createManifestExtensions (the same

Readme

@schemastud/blockdoc

Manifest-driven ProseMirror documents. The /core subpath (React-free) ships:

  • TypeScript types for the blockdoc node-manifest format (BlockdocManifest, NodeManifestEntry, MarkManifestEntry, DocManifest),
  • assemblePMSchema(manifest | manifest[]) — compiles one or more manifests into a ProseMirror Schema (via @tiptap/pm/model),
  • the exported derivation pieces the Tiptap extension generator shares (collectManifestEntries, allBlockCategories, contentExpressionFor, attrsFromSchema, groupsFor),
  • id conventions: generateNodeId() (UUIDv7, matching the server's Str::uuid7()) and NODE_ID_ATTR ('id').

Nothing under src/core imports React or RJSF. The editing half ships as two more subpaths:

  • /react — the BlockdocEditor island on Tiptap (v3, ADR-0072 as amended by splicewire-editor issue 11). createManifestExtensions(manifests, { docAdmits?, nodeViews? }) GENERATES the Tiptap Node/Mark extensions from the manifests at runtime — never hand-authored per node type — reusing the exact derivation pieces src/core/assemble.ts exports (collectManifestEntries, allBlockCategories, contentExpressionFor, attrsFromSchema, groupsFor), so assemblePMSchema stays the conformance oracle (tests/schema-parity.test.ts pins both compilations to the same accept/reject behavior). The generated extensions carry the editing DOM: semantic tags for the base prose set (p, h{level}, blockquote, ul/ol/li, pre>code, hr, br) with data-node-id, generic div[data-node-type] for typed blocks, and marks as strong/em/code/a[href]/ span[data-annotation-id]. The island (useEditor/EditorContent) commits doc.toJSON() through onChange on trailing debounce (default 400ms), blur, and flushCommits() (ref or commitBus); a last-committed guard absorbs the RJSF onChange echo while a genuinely external value rebuilds the document via a commit-suppressed, history-free setContent (selection remapped by node id, undo history effectively fresh) without firing a commit. A free-tier BubbleMenu offers bold/italic/code and prompt-free link set/unset for whichever of those marks the manifests declare. NodeView registry: registerNodeView(name, Component) overrides; unregistered non-base-prose nodes get the generic NodeView (labeled chrome + a SchemaForm over the node's attrs — the drill-down seam); components keep the NodeViewComponentProps contract and ride ReactNodeViewRenderer/NodeViewWrapper through the tiptapNodeView adapter. Collab seams prepared but empty: extraPlugins({ schema }) and the DocSource abstraction (default valueDocSource).

    Guardrails (enforced by tests/no-pro-imports.test.ts): MIT core + free extensions only — no @tiptap-pro/cloud modules (collab is Reverb + y-prosemirror later; comments are our annotation marks; AI is the intent bus). Our integrity plugins (node-id UUIDv7 uniqueness, annotation-mark id integrity) stay RAW ProseMirror plugins registered through addProseMirrorPlugins — one-layer portability in both directions — and every ProseMirror import rides @tiptap/pm/* so exactly one PM instance exists.

  • /rjsfcreateRichContentWidget(nodeViewRegistry, defaults?) producing a component that mounts as an RJSF field (object values) or widget: reads manifest/manifestRef (resolved via formContext.schemaFetcher, falling back to defaults.schemaFetcher), palette, and commit from ui:options; assembles [defaults.baseManifest, profile]; runs advisory client-side validation of the field schema at commit boundaries (server stays authoritative).

import { assemblePMSchema, generateNodeId } from '@schemastud/blockdoc/core';

const schema = assemblePMSchema([baseManifest, contentManifest]);
const doc = schema.nodeFromJSON(json);
doc.check(); // containment enforcement — throws on category violations

Manifest format (v1, frozen 2026-07-10)

Both sides build against this exact JSON shape: the server export command (block-schema:export-manifest) emits it per profile; the client assemblePMSchema consumes it. The base prose set is NOT emitted per profile — it is the hand-authored base manifest vendored client-side (see tests/fixtures/base.manifest.json).

{
  "profile": "content",            // profile key, or "base" for the vendored base set
  "version": 1,                    // manifest format version
  "doc": {
    // What the document node admits, same semantics as node admitsChildCategories.
    "admitsChildCategories": ["section"]
  },
  "nodes": [
    {
      "name": "contentSection",           // PM node type name == server #[NodeType] name
      "description": "…",                  // optional
      "group": "block",                    // from #[NodeType]->group; 'block' | 'inline'
      "category": "section",               // Block::category(); becomes the PM group; null allowed
      "admitsChildCategories": ["prose"],  // null = unconstrained; [] = leaf; list = category union
      "admitsText": false,                 // true → textblock (content 'inline*') when admitsChildCategories is null
      "contentExpression": null,           // explicit escape hatch (base set only); overrides derivation
      "attrsSchema": {                     // JSON Schema object over node attrs; id ALWAYS present
        "type": "object",
        "properties": {
          "id": { "type": ["string", "null"] },
          "heading": { "type": "string" }
        }
      }
    }
  ],
  "marks": [
    { "name": "strong" },
    { "name": "em" },
    { "name": "code" },
    { "name": "link", "attrsSchema": { "type": "object", "properties": { "href": { "type": "string" } } } },
    {
      "name": "annotation",
      "attrsSchema": { "type": "object", "properties": { "id": { "type": ["string", "null"] } } },
      "excludes": ""                        // PM excludes; "" allows overlapping same-type marks
    }
  ]
}

Derivation rules (assemblePMSchema)

  1. contentExpression set → used verbatim.
  2. else admitsChildCategories is a list → content (catA | catB)* (PM group union); empty list → leaf (no content).
  3. else (null) → admitsText: true → content 'inline*'; otherwise UNCONSTRAINED: the union of every block category the composed manifests declare, (cat1 | cat2 | …)* (degrading to a leaf only when no categories exist).
  4. PM group per node = its category (nodes are addressed in content expressions by category). Nodes with manifest group: 'inline' additionally join the PM inline group (and get inline: true) so 'inline*' reaches them.
  5. Node attrs = keys of attrsSchema.properties, each { default: schema.default ?? null }; id is force-present with default null.
  6. The doc node's content derives from doc.admitsChildCategories by rules 2/3 (a doc never admits raw text).
  7. The text node is implicit (PM group inline); manifests never declare it — declaring text or doc throws.
  8. Marks: attrs from attrsSchema.properties same as nodes (no forced id); excludes passed through when present (including '').

Merging (array argument, e.g. [base, profile]): nodes and marks concatenate in order; a later manifest may not redeclare an existing node name — that throws. (Mark redeclaration also throws; that is this implementation's choice, the frozen format only mandates it for nodes.) The last manifest with a non-null doc wins; if no manifest carries a doc, assembly throws.

Known limits of the category → content-expression derivation

Prototyped against the real content profile vocabulary (ContentArticleBlock / ContentOutlineBlock / ContentSectionBlock in splicewire-app, mirrored by hand in tests/fixtures/content-article.manifest.json). These are the semantics that do not compile cleanly:

  1. Null-category nodes are untargetable. A node whose category is null joins no PM group, so no category-derived expression can ever admit it — it can only be the (mapped-away) root or dead vocabulary. This is real, not hypothetical: ContentArticleBlock does not override Block::category(), so the article root has no category. The fixture keeps contentArticle faithful (category null) and instead gives the doc node the article's role (doc.admitsChildCategories: ["section"]); the export command must decide whether root blocks map to doc or get a category.
  2. Only set-union repetition is expressible. Categories compile to (a | b)* — sets with unordered, unbounded repetition. PM content expressions can express sequences, counts and required children (outline sections+, heading paragraph*), but categories cannot. The real grammar's ordering (content_article's Beats drain outline then sections, in declaration order) is lost: the derived (outline | section)* admits an outline anywhere, repeatedly, or never. Ordering / arity constraints need the contentExpression escape hatch (or a richer manifest field later).
  3. Single-category unions are still just repetition. ["list_item"] derives (list_item)* — you cannot say "exactly one" or "at least one" child.
  4. null ("unconstrained") has no exact PM equivalent. Server-side, a null admitsChildCategories means "admits anything" (Block::withContent enforces nothing). PM has no "any" wildcard, so the derivation compiles null to the enumerated union of every block category the composed manifests declare — faithful to the server for every category that exists at assembly time, but a node whose category no manifest declares remains unplaceable. (admitsText: true still takes precedence and compiles to 'inline*'.) The real ContentSectionBlock also carries duplicate prose as a flat body string attr; whether that attr yields to child prose is a per-block server decision the client cannot infer.
  5. Category $ids are not PM-safe tokens. Real categories are URL $ids (e.g. https://app.splicewire.com/json-schemas/block-category/content-section). PM's content-expression tokenizer only accepts word characters, so raw $ids cannot be PM group names. The fixtures use short tokens (section, outline, prose); the export command must emit a stable PM-safe slug per category $id, not the raw id.
  6. Name casing. Server #[NodeType] names are snake_case (content_section) — valid PM names — while the hand-authored fixtures follow PM's camelCase convention (contentSection, matching bullet_list/list_item in the base set). The export command must pick one convention; the client treats names as opaque.

Fixtures

  • tests/fixtures/base.manifest.json — the vendored base prose vocabulary (paragraph, heading, blockquote, lists, code_block, horizontal_rule, hard_break; marks strong/em/code/link/annotation). Carries doc: null — the profile manifest owns the doc.
  • tests/fixtures/content-article.manifest.json — the content profile vocabulary mirrored by hand from the server blocks, with the adaptations documented above.

Development

npm install
npm test        # vitest
npm run typecheck