Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool

Building a JSON Formatter Plugin Ecosystem

JSON (JavaScript Object Notation) is the de facto standard for data interchange on the web and in countless applications. While native tools and browser developer consoles offer basic JSON formatting, real-world scenarios often demand more sophisticated capabilities: handling massive datasets, visualizing specific data types, integrating with other tools, or providing custom user experiences.

Building a JSON formatter with a robust plugin ecosystem addresses these needs by allowing developers to extend and customize the core functionality. This article explores the concepts, architecture, and implementation ideas behind creating such an ecosystem.

The Core JSON Formatter

At its heart, a JSON formatter takes a raw JSON string and presents it in a human-readable, structured format. Basic features typically include:

  • Parsing: Turning the JSON string into a usable data structure (like a JavaScript object or array).
  • Indentation: Adding whitespace to show hierarchy, making nested structures clear.
  • Syntax Highlighting: Coloring different parts of the JSON (keys, strings, numbers, booleans, null) for easier reading.
  • Collapsing/Expanding: Allowing users to hide/show nested objects and arrays.

A basic formatter is essential, but it's often static and unaware of the semantic meaning of the data it displays.

Why a Plugin Ecosystem?

The limitations of a basic formatter become apparent when dealing with:

  • Large Data: Simply displaying a massive JSON object can be overwhelming and slow. Plugins could offer filtering, pagination, or summarized views.
  • Specific Data Types: A string might be a timestamp, a URL, a base64 image, or even embedded JSON. A plugin could recognize this and render it interactively (e.g., a clickable link, an image preview, or a nested formatter).
  • Custom Views: Sometimes, you want to see a JSON object not just as a tree, but as a table, a chart, or a custom widget based on its known structure.
  • Actions: Users might want to copy a specific value, send a value to another tool, or trigger an action based on the data.
  • Annotations: Highlighting specific parts based on validation rules, search results, or user-defined criteria.

A plugin ecosystem allows the core formatter to remain focused and maintainable while offloading specific functionalities to modular, developer-created extensions.

Designing the Architecture

A successful plugin architecture requires defining clear interfaces and extension points.

Plugin Types

Plugins could specialize in different areas:

  • Value Renderers: Overrides how specific primitive values (strings, numbers) are displayed, potentially adding custom JSX.
  • Node Annotators: Adds visual cues (borders, backgrounds, icons) to entire nodes (objects, arrays, key-value pairs).
  • Action Providers: Adds buttons, context menu items, or drag-and-drop capabilities to nodes.
  • Structure Transformers: Provides alternative views or summaries for specific object/array structures.
  • Global Modifiers: Affects formatter-wide behavior like sorting keys, filtering nodes, or custom collapsing logic.

Plugin Interface (API)

The core formatter needs a contract for plugins. A TypeScript interface is ideal for this. What data does a plugin receive? What should it return?

Conceptual Plugin Interfaces:

interface JsonNode {
  path: (string | number)[];
  key: string | number | null;
  value: any;
  type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null';
  parentType: 'object' | 'array' | null;
}

interface ValueRendererPlugin {
  name: string;
  shouldHandle?(node: JsonNode): boolean;
  renderValue(node: JsonNode): React.ReactNode;
}

interface NodeAnnotatorPlugin {
  name: string;
  shouldAnnotate?(node: JsonNode): boolean;
  getAnnotation?(node: JsonNode): { className?: string; style?: React.CSSProperties; icon?: React.ReactNode };
}

interface ActionProviderPlugin {
  name: string;
  shouldProvideActions?(node: JsonNode): boolean;
  getActions?(node: JsonNode): { label: string; onClick: (node: JsonNode) => void; icon?: React.ReactNode }[];
}

Each plugin type defines specific methods (`shouldHandle`, `renderValue`, etc.) that the core formatter will call. TheJsonNode interface is crucial, providing context about the current piece of data being processed (its value, type, key, and path within the overall structure).

Plugin Registration

The core formatter needs a way to know which plugins are available. This could be a simple array of plugin instances passed to the formatter component or a global registry.

Conceptual Plugin Registration:


import { MyCoreFormatter } from './core-formatter';
import { TimestampPlugin } from './plugins/timestamp-plugin';
import { UrlPlugin } from './plugins/url-plugin';

const registeredPlugins = [
  new TimestampPlugin(),
  new UrlPlugin(),

];

function App() {
  const jsonString = '...';
  const parsedData = JSON.parse(jsonString);

  return (
    <MyCoreFormatter data={parsedData} plugins={registeredPlugins} />
  );
}


import { PluginRegistry } from './plugin-registry';
import { TimestampPlugin } from './plugins/timestamp-plugin';
import { UrlPlugin } from './plugins/url-plugin';

PluginRegistry.register(new TimestampPlugin());
PluginRegistry.register(new UrlPlugin());


import { PluginRegistry } from './plugin-registry';

function MyCoreFormatter({ data }: { data: any }) {
  const plugins = PluginRegistry.getAll();

}

Passing plugins explicitly via props offers better testability and locality, while a global registry might be simpler for applications with many plugins used everywhere.

Extension Points & Rendering

The core formatter's rendering logic is where plugins hook in. When rendering a value or a node, the formatter iterates through the registered plugins of the relevant type and asks if any want to handle it using the `shouldHandle` (or similar) method.

Conceptual Rendering Logic with Plugins:


interface JsonNodeRendererProps {
  node: JsonNode;
  plugins: (ValueRendererPlugin | NodeAnnotatorPlugin | ActionProviderPlugin)[];

}

function JsonNodeRenderer({ node, plugins }: JsonNodeRendererProps) {
  const valueRenderer = plugins
    .filter(p => typeof (p as ValueRendererPlugin).renderValue === 'function')
    .find(p => (p as ValueRendererPlugin).shouldHandle?.(node));

  const annotatorPlugins = plugins
    .filter(p => typeof (p as NodeAnnotatorPlugin).getAnnotation === 'function')
    .filter(p => (p as NodeAnnotatorPlugin).shouldAnnotate?.(node));

  const actionPlugins = plugins
    .filter(p => typeof (p as ActionProviderPlugin).getActions === 'function')
    .filter(p => (p as ActionProviderPlugin).shouldProvideActions?.(node));

  const annotations = annotatorPlugins.map(p => (p as NodeAnnotatorPlugin).getAnnotation?.(node)).filter(Boolean);
  const actions = actionPlugins.flatMap(p => (p as ActionProviderPlugin).getActions?.(node) || []);

  const nodeClassName = annotations.map(a => a.className).join(' ');
  const nodeStyle = annotations.reduce((acc, a) => ({ ...acc, ...a.style }), {});
  const nodeIcons = annotations.map(a => a.icon).filter(Boolean);

  const keyElement = node.key !== null ? (
    <span className="json-key">{node.key}:</span>
  ) : null;

  let valueElement;
  if (valueRenderer) {
    valueElement = valueRenderer.renderValue(node);
  } else {
    switch (node.type) {
      case 'object':
        valueElement = (
          <span className="json-brace">{}</span>

          <span className="json-brace">}}</span>
        );
        break;
      case 'array':
        valueElement = (
          <span className="json-bracket">[}</span>

          <span className="json-bracket">]}</span>
        );
        break;
      case 'string':
        valueElement = <span className="json-string">"{node.value}"</span>;
        break;

      default:
        valueElement = <span className="json-value">{String(node.value)}</span>;
    }
  }

  return (
    <div className={`json-node ${nodeClassName}`} style={nodeStyle}>
      {keyElement}
      {nodeIcons}
      {valueElement}
      {actions.length > 0 && (
        <span className="json-actions">
          {actions.map((action, i) => (
            <button key={i} onClick={() => action.onClick(node)}>
              {action.icon} {action.label}
            </button>
          ))}
        </span>
      )}
    </div>
  );
}

This conceptual code illustrates how the `JsonNodeRenderer` component would receive the `JsonNode` data and the list of plugins. It then queries the plugins to see if they apply and uses their output (JSX, styles, actions) in its rendering.

Implementing a Sample Plugin: Timestamp Formatter

Let's imagine a simple `ValueRendererPlugin` that detects string values that look like timestamps and renders them in a more human-friendly way alongside the raw timestamp.

Conceptual Timestamp Plugin:


export class TimestampPlugin implements ValueRendererPlugin {
  name = 'timestamp-formatter';

  shouldHandle(node: JsonNode): boolean {
    if (node.type !== 'string' || typeof node.value !== 'string') {
      return false;
    }
    const date = new Date(node.value);
    return !isNaN(date.getTime()) && date.toISOString() === node.value;
  }

  renderValue(node: JsonNode): React.ReactNode {
    const rawTimestamp = node.value as string;
    const date = new Date(rawTimestamp);
    const formattedDate = date.toLocaleString();

    return (
      <span className="json-value json-string">
        "{rawTimestamp}"
        <span className="text-xs text-gray-500 ml-2">
          ({formattedDate})
        </span>
      </span>
    );
  }
}

This plugin checks if a string value can be parsed into a valid date. If it can, it provides custom JSX to display both the original string and a formatted version. The core formatter would use this JSX instead of its default string rendering when `TimestampPlugin.shouldHandle` returns `true`.

Advantages of the Ecosystem

  • Extensibility: Easily add new features or handle new data types without modifying the core formatter.
  • Modularity: Plugins are self-contained units of functionality.
  • Reusability: Plugins can potentially be shared and used across different applications using the same formatter core.
  • Community Contributions: A well-designed API can encourage external developers to build and share plugins.
  • Tailored Experiences: Applications can bundle specific sets of plugins relevant to their domain.

Challenges

  • API Design: Defining a flexible yet stable plugin API is crucial and challenging. Changes to the core API can break plugins.
  • Performance: Iterating through many plugins for every node, especially on large JSON, can impact performance. Plugins need to be efficient in their `shouldHandle` checks.
  • Plugin Conflicts: What happens if multiple plugins want to handle the same node or render the same value? A priority system or clear rules are needed.
  • Security: If plugins can execute arbitrary code (less of a concern in a server-side rendering context like Next.js pages, but relevant for client-side formatters), sandboxing might be necessary.
  • Discoverability: Users need to know what plugins exist and how to install/enable them.
  • Maintenance: Maintaining the core API and potentially a collection of official plugins requires ongoing effort.

Conclusion

Building a JSON formatter with a plugin ecosystem transforms a basic utility into a powerful, adaptable tool. By carefully designing the core formatter, defining clear plugin interfaces, and establishing extension points, developers can create a platform that can be easily extended to handle the diverse and evolving needs of working with JSON data. While challenges exist, the benefits in terms of flexibility and community enablement make it a worthwhile architectural approach.

Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool