From 7afbb95a8d5990130d9a669dc7882f8b88ccf68c Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Wed, 8 Jul 2026 17:59:57 +0530 Subject: [PATCH 01/10] feat(richtext): type richtext field value from OpenAPI spec Expands richtext-field-value.yaml from a loose `type: object` into a fully discriminated schema with $defs for every node and mark type, mirroring the TypeScript types in richtext-attrs.ts. Wires @storyblok/richtext into the openapi-codegen pipeline: - scripts/generate.ts generates RichtextDoc, RichTextNode, RichTextMark from the overlay spec into src/generated/ - RichtextDoc / RichTextNode / RichTextMark added to overlay.openapi.yaml as top-level schemas and to aliases.ts so they can be included by name - generate:openapi nx target added; existing generate target (Tiptap-based) is unchanged Reduces manual type maintenance in the richtext package: - SbRichTextNode and SbRichTextMark are no longer hand-rolled unions in types.generated.ts; they delegate to the OpenAPI-generated RichTextNode and RichTextMark with _key, context, and common optional fields intersected in for renderer use - SbRichTextDoc is now RichtextDoc (the OpenAPI root type) instead of SbRichTextNode & { type: 'doc' } which became never after the change - SbRichTextInput extended to include SbRichTextDoc explicitly Fixes copyWrapperTemplates in openapi-codegen to skip writing the empty types/_sources.ts when no wrapper templates are requested. Fixes DX-487 --- packages/richtext/package.json | 17 +- packages/richtext/scripts/generate.ts | 24 + .../src/generated/overlay/types.gen.ts | 328 ++++++++++ .../src/static/generate/richtext-type.ts | 28 +- packages/richtext/src/static/types.ts | 9 +- packages/richtext/src/test-utils/helpers.ts | 6 +- packages/richtext/src/test-utils/nodes.ts | 4 +- packages/richtext/src/test-utils/types.ts | 4 +- pnpm-lock.yaml | 3 + .../specs/overlay.openapi.yaml | 4 + .../field-types/richtext-field-value.yaml | 579 +++++++++++++++++- tools/openapi-codegen/src/aliases.ts | 3 + tools/openapi-codegen/src/index.ts | 6 +- 13 files changed, 977 insertions(+), 38 deletions(-) create mode 100644 packages/richtext/scripts/generate.ts create mode 100644 packages/richtext/src/generated/overlay/types.gen.ts diff --git a/packages/richtext/package.json b/packages/richtext/package.json index b22272e29..01f59928c 100644 --- a/packages/richtext/package.json +++ b/packages/richtext/package.json @@ -75,7 +75,8 @@ "playground:all": "pnpm -r --parallel --filter='./playground/*' run dev", "release": "release-it", "release:dry": "release-it --dry-run", - "generate": "tsx src/static/generate/index.ts" + "generate": "tsx src/static/generate/index.ts", + "generate:openapi": "node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/generate.ts" }, "dependencies": { "@tiptap/core": "^3.22.3", @@ -113,6 +114,7 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@storyblok/eslint-config": "workspace:*", + "@storyblok/openapi-codegen": "workspace:*", "@types/markdown-it": "^14.1.2", "@types/node": "^24.11.0", "@vitest/coverage-v8": "^3.1.3", @@ -156,6 +158,19 @@ "{projectRoot}/src/static/{render-map,types}.generated.ts" ] }, + "generate:openapi": { + "inputs": [ + "{projectRoot}/scripts/generate.ts", + "openapiCodegen" + ], + "outputs": [ + "{projectRoot}/src/generated" + ], + "cache": true, + "dependsOn": [ + "@storyblok/openapi-codegen:build" + ] + }, "build": { "dependsOn": [ "generate", diff --git a/packages/richtext/scripts/generate.ts b/packages/richtext/scripts/generate.ts new file mode 100644 index 000000000..f02eae4bc --- /dev/null +++ b/packages/richtext/scripts/generate.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env -S node --experimental-strip-types --no-warnings=ExperimentalWarning +/** + * Generates `@storyblok/richtext`'s OpenAPI-derived types from the pinned + * overlay spec cache. Produces `RichtextDoc`, `RichTextNode`, and + * `RichTextMark` in `src/generated/` — the public-API types for the richtext + * document format consumed by story content. + * + * These types mirror the hand-authored `src/static/types.generated.ts` + * (which is driven by the live Tiptap schema) and serve as the + * contract-first source of truth for consumers who only need the data shape. + * + * Re-run after `pnpm --filter @storyblok/openapi-codegen pull[:update]`. + */ + +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generate } from '@storyblok/openapi-codegen'; + +const PKG_ROOT = resolve(fileURLToPath(import.meta.url), '../..'); + +await generate({ + outDir: resolve(PKG_ROOT, 'src/generated'), + include: ['RichtextDoc', 'RichTextNode', 'RichTextMark'], +}); diff --git a/packages/richtext/src/generated/overlay/types.gen.ts b/packages/richtext/src/generated/overlay/types.gen.ts new file mode 100644 index 000000000..eb9aea20d --- /dev/null +++ b/packages/richtext/src/generated/overlay/types.gen.ts @@ -0,0 +1,328 @@ +// Generated by @storyblok/openapi-codegen. Do not edit by hand. + +export type RichtextDoc = RichtextFieldValueRoot; + +export type RichTextNode = RichtextFieldValueRichTextNode; + +export type RichTextMark = RichtextFieldValueRichTextMark; + +/** + * Richtext field type - structured rich text document (ProseMirror/Tiptap format) + */ +export interface RichtextFieldValueRoot { + /** + * Root node type — always "doc" + */ + type: 'doc'; + /** + * Top-level richtext nodes + */ + content: Array; +} + +/** + * A richtext document node + */ +export type RichtextFieldValueRichTextNode = RichtextFieldValueParagraphNode | RichtextFieldValueTextNode | RichtextFieldValueHeadingNode | RichtextFieldValueBlockquoteNode | RichtextFieldValueBulletListNode | RichtextFieldValueOrderedListNode | RichtextFieldValueListItemNode | RichtextFieldValueCodeBlockNode | RichtextFieldValueHardBreakNode | RichtextFieldValueHorizontalRuleNode | RichtextFieldValueImageNode | RichtextFieldValueEmojiNode | RichtextFieldValueTableNode | RichtextFieldValueTableRowNode | RichtextFieldValueTableCellNode | RichtextFieldValueTableHeaderNode | RichtextFieldValueBlokNode; + +/** + * Inline formatting mark applied to a text node + */ +export type RichtextFieldValueRichTextMark = RichtextFieldValueLinkMark | RichtextFieldValueBoldMark | RichtextFieldValueItalicMark | RichtextFieldValueStrikeMark | RichtextFieldValueUnderlineMark | RichtextFieldValueCodeMark | RichtextFieldValueSuperscriptMark | RichtextFieldValueSubscriptMark | RichtextFieldValueHighlightMark | RichtextFieldValueTextStyleMark | RichtextFieldValueAnchorMark | RichtextFieldValueStyledMark; + +export interface RichtextFieldValueParagraphNode { + type: 'paragraph'; + attrs?: { + /** + * Text alignment + */ + textAlign?: 'left' | 'center' | 'right' | 'justify' | null; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueTextNode { + type: 'text'; + /** + * The text content + */ + text: string; + marks?: Array; +} + +export interface RichtextFieldValueHeadingNode { + type: 'heading'; + attrs?: { + /** + * Heading level (h1–h6) + */ + level?: 1 | 2 | 3 | 4 | 5 | 6 | null; + /** + * Text alignment + */ + textAlign?: 'left' | 'center' | 'right' | 'justify' | null; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueBlockquoteNode { + type: 'blockquote'; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueBulletListNode { + type: 'bullet_list'; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueOrderedListNode { + type: 'ordered_list'; + attrs?: { + /** + * Starting number for the ordered list + */ + order?: number; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueListItemNode { + type: 'list_item'; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueCodeBlockNode { + type: 'code_block'; + attrs?: { + /** + * Language class (e.g. "language-typescript") + */ + class?: string | null; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueHardBreakNode { + type: 'hard_break'; + marks?: Array; +} + +export interface RichtextFieldValueHorizontalRuleNode { + type: 'horizontal_rule'; +} + +export interface RichtextFieldValueImageNode { + type: 'image'; + attrs?: { + /** + * Storyblok asset ID + */ + id?: number | null; + /** + * Image URL + */ + src: string; + /** + * Alternative text + */ + alt?: string | null; + title?: string | null; + source?: string | null; + copyright?: string | null; + /** + * Asset metadata + */ + meta_data?: { + alt?: string | null; + title?: string | null; + source?: string | null; + copyright?: string | null; + } | null; + }; + marks?: Array; +} + +export interface RichtextFieldValueEmojiNode { + type: 'emoji'; + attrs?: { + /** + * Emoji name/slug + */ + name: string; + /** + * Emoji character + */ + emoji: string; + /** + * URL to a fallback image for unsupported environments + */ + fallbackImage: string; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueTableNode { + type: 'table'; + content?: Array; +} + +export interface RichtextFieldValueTableRowNode { + type: 'tableRow'; + content?: Array; +} + +export interface RichtextFieldValueTableCellNode { + type: 'tableCell'; + attrs?: { + colspan?: number; + rowspan?: number; + /** + * Column widths in pixels + */ + colwidth?: Array | null; + backgroundColor?: string | null; + }; + content?: Array; +} + +export interface RichtextFieldValueTableHeaderNode { + type: 'tableHeader'; + attrs?: { + colspan?: number; + rowspan?: number; + /** + * Column widths in pixels + */ + colwidth?: Array | null; + }; + content?: Array; +} + +export interface RichtextFieldValueBlokNode { + type: 'blok'; + attrs?: { + /** + * Blok instance ID + */ + id?: string | null; + /** + * Array of embedded component instances + */ + body?: Array<{ + _uid: string; + component: string; + _editable?: string; + [key: string]: unknown | string | undefined; + }> | null; + }; +} + +export interface RichtextFieldValueLinkMark { + type: 'link'; + /** + * Link attributes + */ + attrs?: { + /** + * Link URL + */ + href?: string | null; + /** + * UUID of the linked story (for internal links) + */ + uuid?: string | null; + /** + * Anchor/fragment identifier + */ + anchor?: string | null; + /** + * Link target attribute + */ + target?: '_self' | '_blank' | '_parent' | '_top' | null; + /** + * Type of link + */ + linktype?: 'story' | 'url' | 'email' | 'asset' | null; + /** + * Custom link attributes + */ + custom?: { + [key: string]: unknown; + }; + }; +} + +export interface RichtextFieldValueBoldMark { + type: 'bold'; +} + +export interface RichtextFieldValueItalicMark { + type: 'italic'; +} + +export interface RichtextFieldValueStrikeMark { + type: 'strike'; +} + +export interface RichtextFieldValueUnderlineMark { + type: 'underline'; +} + +export interface RichtextFieldValueCodeMark { + type: 'code'; +} + +export interface RichtextFieldValueSuperscriptMark { + type: 'superscript'; +} + +export interface RichtextFieldValueSubscriptMark { + type: 'subscript'; +} + +export interface RichtextFieldValueHighlightMark { + type: 'highlight'; + attrs?: { + /** + * Highlight color (CSS color value) + */ + color: string; + }; +} + +export interface RichtextFieldValueTextStyleMark { + type: 'textStyle'; + attrs?: { + color?: string | null; + id?: string | null; + class?: string | null; + }; +} + +export interface RichtextFieldValueAnchorMark { + type: 'anchor'; + attrs?: { + /** + * Anchor identifier + */ + id: string; + }; +} + +export interface RichtextFieldValueStyledMark { + type: 'styled'; + attrs?: { + /** + * CSS class name + */ + class?: string | null; + }; +} diff --git a/packages/richtext/src/static/generate/richtext-type.ts b/packages/richtext/src/static/generate/richtext-type.ts index 2fcf8e130..b0af9dd5c 100644 --- a/packages/richtext/src/static/generate/richtext-type.ts +++ b/packages/richtext/src/static/generate/richtext-type.ts @@ -53,23 +53,6 @@ function markShape(name: string): string { return `{ type: '${name}'; attrs?: TiptapMarkAttributes['${name}']; _key?: string; }`; } -/** Generate SbRichTextNode discriminated union type */ -function genPMNode(schema: Schema): string { - let out = 'export type SbRichTextNode =\n'; - for (const [name] of Object.entries(schema.nodes) as [string, NodeType][]) { - out += ` | ${nodeShape(schema, name)}\n`; - } - return `${out};`; -} - -function genPMMark(schema: Schema): string { - let out = 'export type SbRichTextMark =\n'; - for (const [name] of Object.entries(schema.marks)) { - out += ` | ${markShape(name)}\n`; - } - return `${out};`; -} - /** * Generate a flat lookup interface keyed by element name. * This allows `SbRichTextElementByType[T]` indexed access to resolve to the @@ -100,6 +83,7 @@ export function generateTypes() { let output = ''; output += '// THIS FILE IS AUTO-GENERATED. DO NOT EDIT.\n'; output += `import type { MarkAttrTypeMap, NodeAttrTypeMap } from '../extensions/richtext-attrs';\n`; + output += `import type { RichTextMark as _RichTextMark, RichTextNode as _RichTextNode } from '../generated/overlay/types.gen';\n`; output += '\n'; // --- Attribute types output += '/** Attribute types for all Tiptap node extensions */\n'; @@ -113,9 +97,13 @@ export function generateTypes() { // --- Node name unions output += 'export type TiptapNodeName = keyof TiptapNodeAttributes;\n'; output += 'export type TiptapMarkName = keyof TiptapMarkAttributes;\n'; - // --- SbRichTextNode/SbRichTextMark - output += `${genPMNode(schema)}\n\n`; - output += `${genPMMark(schema)}\n\n`; + // --- SbRichTextNode/SbRichTextMark delegate to OpenAPI-generated types, + // adding common optional fields so the renderer can access content/marks/attrs + // without narrowing every union member, plus _key and context generics. + output += '/** Richtext node — wire shape from OpenAPI extended with renderer additions. */\n'; + output += 'export type SbRichTextNode = _RichTextNode & { content?: Array>; marks?: Array>; attrs?: Record; _key?: string; context?: TContext };\n'; + output += '/** Richtext mark — wire shape from OpenAPI extended with renderer additions. */\n'; + output += 'export type SbRichTextMark = _RichTextMark & { attrs?: Record; _key?: string; context?: TContext };\n\n'; output += `${genElementByType(schema)}\n`; return output; } diff --git a/packages/richtext/src/static/types.ts b/packages/richtext/src/static/types.ts index d73a5aeaa..bd1c23431 100644 --- a/packages/richtext/src/static/types.ts +++ b/packages/richtext/src/static/types.ts @@ -1,8 +1,11 @@ import type { SbRichTextImageOptions } from '../types'; -import type { SbRichTextElementByType, SbRichTextNode, TiptapMarkName, TiptapNodeName } from './types.generated'; +import type { RichtextDoc } from '../generated/overlay/types.gen'; +import type { SbRichTextElementByType, SbRichTextMark, SbRichTextNode, TiptapMarkName, TiptapNodeName } from './types.generated'; export type SbRichTextElement = Exclude; +export type { SbRichTextMark, SbRichTextNode }; + interface ISbComponentType { _uid?: string; component?: T; @@ -25,9 +28,9 @@ export interface RenderSpec { } /** Canonical type for a Storyblok RichText JSON root */ -export type SbRichTextDoc = SbRichTextNode & { type: 'doc' }; +export type SbRichTextDoc = RichtextDoc; export type SbRichTextTextNode = SbRichTextNode & { type: 'text' }; -export type SbRichTextInput = SbRichTextNode | SbRichTextNode[] | null | undefined; +export type SbRichTextInput = SbRichTextDoc | SbRichTextNode | SbRichTextNode[] | null | undefined; export type SbRichTextProps< T extends SbRichTextElement, diff --git a/packages/richtext/src/test-utils/helpers.ts b/packages/richtext/src/test-utils/helpers.ts index 020d9d2b9..74ba173fd 100644 --- a/packages/richtext/src/test-utils/helpers.ts +++ b/packages/richtext/src/test-utils/helpers.ts @@ -2,7 +2,7 @@ import type { SbRichTextDoc, SbRichTextMark, SbRichTextNode } from '../static'; export const text = ( content: string, - marks?: SbRichTextNode['marks'], + marks?: SbRichTextMark[], ): SbRichTextNode => ({ type: 'text', text: content, @@ -33,7 +33,7 @@ export const linkMark = ( export const tableCell = ( content: string, attrs: { colspan?: number; rowspan?: number; colwidth?: number[]; backgroundColor?: string } = {}, - marks?: SbRichTextNode['marks'], + marks?: SbRichTextMark[], ): SbRichTextNode => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [text(content, marks)] }], @@ -45,7 +45,7 @@ export const tableCell = ( }, }); -export const tableHeader = (content: string, marks?: SbRichTextNode['marks']): SbRichTextNode => ({ +export const tableHeader = (content: string, marks?: SbRichTextMark[]): SbRichTextNode => ({ type: 'tableHeader', content: [{ type: 'paragraph', content: [text(content, marks)] }], attrs: { colspan: 1, rowspan: 1 }, diff --git a/packages/richtext/src/test-utils/nodes.ts b/packages/richtext/src/test-utils/nodes.ts index db1ccaad9..d0977e069 100644 --- a/packages/richtext/src/test-utils/nodes.ts +++ b/packages/richtext/src/test-utils/nodes.ts @@ -1,4 +1,5 @@ import { text } from './helpers'; +import type { SbRichTextNode } from '../static'; import type { HtmlFixture } from './types'; export const nodeFixtures: HtmlFixture[] = [ @@ -82,7 +83,8 @@ export const nodeFixtures: HtmlFixture[] = [ type: 'doc', content: [ { type: 'paragraph', content: [text('Outer')] }, - { type: 'doc', content: [{ type: 'paragraph', content: [text('Inner')] }] }, + // doc nested inside content — runtime edge case the renderer handles + { type: 'doc', content: [{ type: 'paragraph', content: [text('Inner')] }] } as unknown as SbRichTextNode, ], }, expected: '

Outer

Inner

', diff --git a/packages/richtext/src/test-utils/types.ts b/packages/richtext/src/test-utils/types.ts index cb65f3bb3..093de2c9d 100644 --- a/packages/richtext/src/test-utils/types.ts +++ b/packages/richtext/src/test-utils/types.ts @@ -1,7 +1,7 @@ -import type { SbRichTextNode } from '../static'; +import type { SbRichTextInput } from '../static'; export interface HtmlFixture { title: string; - input: SbRichTextNode | SbRichTextNode[]; + input: NonNullable; expected: string; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e4389f24..4a5326999 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1227,6 +1227,9 @@ importers: '@storyblok/eslint-config': specifier: workspace:* version: link:../eslint-config + '@storyblok/openapi-codegen': + specifier: workspace:* + version: link:../../tools/openapi-codegen '@types/markdown-it': specifier: ^14.1.2 version: 14.1.2 diff --git a/tools/openapi-codegen/specs/overlay.openapi.yaml b/tools/openapi-codegen/specs/overlay.openapi.yaml index 8a6620423..21558f003 100644 --- a/tools/openapi-codegen/specs/overlay.openapi.yaml +++ b/tools/openapi-codegen/specs/overlay.openapi.yaml @@ -23,6 +23,10 @@ components: $ref: ./shared/stories/field-types/plugin-field-value.yaml RichtextFieldValue: $ref: ./shared/stories/field-types/richtext-field-value.yaml + RichTextNode: + $ref: ./shared/stories/field-types/richtext-field-value.yaml#/$defs/RichTextNode + RichTextMark: + $ref: ./shared/stories/field-types/richtext-field-value.yaml#/$defs/RichTextMark TableFieldValue: $ref: ./shared/stories/field-types/table-field-value.yaml ComponentSchemaField: diff --git a/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml b/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml index ff1718615..f9ebb62bf 100644 --- a/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml +++ b/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml @@ -1,18 +1,585 @@ # See: https://www.storyblok.com/docs/api/management/components/possible-field-types # See: https://www.storyblok.com/docs/packages/storyblok-richtext +# Types mirror packages/richtext/src/extensions/richtext-attrs.ts and +# packages/richtext/src/static/types.generated.ts — keep in sync. type: object -description: Richtext field type - structured rich text document (ProseMirror format) +description: Richtext field type - structured rich text document (ProseMirror/Tiptap format) required: - type + - content properties: type: type: string enum: [doc] - description: Root node type for richtext documents + description: Root node type — always "doc" content: type: array - description: Array of richtext nodes (paragraphs, headings, lists, bloks, etc.) + description: Top-level richtext nodes items: - type: object - additionalProperties: true - description: Richtext node structure varies by type (paragraph, heading, bullet_list, ordered_list, blockquote, code_block, horizontal_rule, hard_break, image, blok, etc.) + $ref: '#/$defs/RichTextNode' + +$defs: + + # ----------------------------------------------------------------------- + # Marks + # ----------------------------------------------------------------------- + + RichTextMark: + description: Inline formatting mark applied to a text node + oneOf: + - $ref: '#/$defs/LinkMark' + - $ref: '#/$defs/BoldMark' + - $ref: '#/$defs/ItalicMark' + - $ref: '#/$defs/StrikeMark' + - $ref: '#/$defs/UnderlineMark' + - $ref: '#/$defs/CodeMark' + - $ref: '#/$defs/SuperscriptMark' + - $ref: '#/$defs/SubscriptMark' + - $ref: '#/$defs/HighlightMark' + - $ref: '#/$defs/TextStyleMark' + - $ref: '#/$defs/AnchorMark' + - $ref: '#/$defs/StyledMark' + + LinkMark: + type: object + required: [type] + properties: + type: + type: string + enum: [link] + attrs: + type: object + description: Link attributes + properties: + href: + type: [string, 'null'] + description: Link URL + uuid: + type: [string, 'null'] + description: UUID of the linked story (for internal links) + anchor: + type: [string, 'null'] + description: Anchor/fragment identifier + target: + description: Link target attribute + oneOf: + - type: string + enum: [_self, _blank, _parent, _top] + - type: 'null' + linktype: + description: Type of link + oneOf: + - type: string + enum: [story, url, email, asset] + - type: 'null' + custom: + type: object + additionalProperties: true + description: Custom link attributes + + BoldMark: + type: object + required: [type] + properties: + type: + type: string + enum: [bold] + + ItalicMark: + type: object + required: [type] + properties: + type: + type: string + enum: [italic] + + StrikeMark: + type: object + required: [type] + properties: + type: + type: string + enum: [strike] + + UnderlineMark: + type: object + required: [type] + properties: + type: + type: string + enum: [underline] + + CodeMark: + type: object + required: [type] + properties: + type: + type: string + enum: [code] + + SuperscriptMark: + type: object + required: [type] + properties: + type: + type: string + enum: [superscript] + + SubscriptMark: + type: object + required: [type] + properties: + type: + type: string + enum: [subscript] + + HighlightMark: + type: object + required: [type] + properties: + type: + type: string + enum: [highlight] + attrs: + type: object + required: [color] + properties: + color: + type: string + description: Highlight color (CSS color value) + + TextStyleMark: + type: object + required: [type] + properties: + type: + type: string + enum: [textStyle] + attrs: + type: object + properties: + color: + type: [string, 'null'] + id: + type: [string, 'null'] + class: + type: [string, 'null'] + + AnchorMark: + type: object + required: [type] + properties: + type: + type: string + enum: [anchor] + attrs: + type: object + required: [id] + properties: + id: + type: string + description: Anchor identifier + + StyledMark: + type: object + required: [type] + properties: + type: + type: string + enum: [styled] + attrs: + type: object + properties: + class: + type: [string, 'null'] + description: CSS class name + + # ----------------------------------------------------------------------- + # Nodes + # ----------------------------------------------------------------------- + + RichTextNode: + description: A richtext document node + oneOf: + - $ref: '#/$defs/ParagraphNode' + - $ref: '#/$defs/TextNode' + - $ref: '#/$defs/HeadingNode' + - $ref: '#/$defs/BlockquoteNode' + - $ref: '#/$defs/BulletListNode' + - $ref: '#/$defs/OrderedListNode' + - $ref: '#/$defs/ListItemNode' + - $ref: '#/$defs/CodeBlockNode' + - $ref: '#/$defs/HardBreakNode' + - $ref: '#/$defs/HorizontalRuleNode' + - $ref: '#/$defs/ImageNode' + - $ref: '#/$defs/EmojiNode' + - $ref: '#/$defs/TableNode' + - $ref: '#/$defs/TableRowNode' + - $ref: '#/$defs/TableCellNode' + - $ref: '#/$defs/TableHeaderNode' + - $ref: '#/$defs/BlokNode' + + ParagraphNode: + type: object + required: [type] + properties: + type: + type: string + enum: [paragraph] + attrs: + type: object + properties: + textAlign: + description: Text alignment + oneOf: + - type: string + enum: [left, center, right, justify] + - type: 'null' + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + TextNode: + type: object + required: [type, text] + properties: + type: + type: string + enum: [text] + text: + type: string + description: The text content + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + HeadingNode: + type: object + required: [type] + properties: + type: + type: string + enum: [heading] + attrs: + type: object + properties: + level: + description: Heading level (h1–h6) + oneOf: + - type: integer + enum: [1, 2, 3, 4, 5, 6] + - type: 'null' + textAlign: + description: Text alignment + oneOf: + - type: string + enum: [left, center, right, justify] + - type: 'null' + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + BlockquoteNode: + type: object + required: [type] + properties: + type: + type: string + enum: [blockquote] + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + BulletListNode: + type: object + required: [type] + properties: + type: + type: string + enum: [bullet_list] + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + OrderedListNode: + type: object + required: [type] + properties: + type: + type: string + enum: [ordered_list] + attrs: + type: object + properties: + order: + type: integer + description: Starting number for the ordered list + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + ListItemNode: + type: object + required: [type] + properties: + type: + type: string + enum: [list_item] + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + CodeBlockNode: + type: object + required: [type] + properties: + type: + type: string + enum: [code_block] + attrs: + type: object + properties: + class: + type: [string, 'null'] + description: Language class (e.g. "language-typescript") + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + HardBreakNode: + type: object + required: [type] + properties: + type: + type: string + enum: [hard_break] + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + HorizontalRuleNode: + type: object + required: [type] + properties: + type: + type: string + enum: [horizontal_rule] + + ImageNode: + type: object + required: [type] + properties: + type: + type: string + enum: [image] + attrs: + type: object + required: [src] + properties: + id: + type: [integer, 'null'] + description: Storyblok asset ID + src: + type: string + description: Image URL + alt: + type: [string, 'null'] + description: Alternative text + title: + type: [string, 'null'] + source: + type: [string, 'null'] + copyright: + type: [string, 'null'] + meta_data: + description: Asset metadata + oneOf: + - type: object + properties: + alt: + type: [string, 'null'] + title: + type: [string, 'null'] + source: + type: [string, 'null'] + copyright: + type: [string, 'null'] + - type: 'null' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + EmojiNode: + type: object + required: [type] + properties: + type: + type: string + enum: [emoji] + attrs: + type: object + required: [name, emoji, fallbackImage] + properties: + name: + type: string + description: Emoji name/slug + emoji: + type: string + description: Emoji character + fallbackImage: + type: string + description: URL to a fallback image for unsupported environments + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + marks: + type: array + items: + $ref: '#/$defs/RichTextMark' + + TableNode: + type: object + required: [type] + properties: + type: + type: string + enum: [table] + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + + TableRowNode: + type: object + required: [type] + properties: + type: + type: string + enum: [tableRow] + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + + TableCellNode: + type: object + required: [type] + properties: + type: + type: string + enum: [tableCell] + attrs: + type: object + properties: + colspan: + type: integer + rowspan: + type: integer + colwidth: + description: Column widths in pixels + oneOf: + - type: array + items: + type: integer + - type: 'null' + backgroundColor: + type: [string, 'null'] + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + + TableHeaderNode: + type: object + required: [type] + properties: + type: + type: string + enum: [tableHeader] + attrs: + type: object + properties: + colspan: + type: integer + rowspan: + type: integer + colwidth: + description: Column widths in pixels + oneOf: + - type: array + items: + type: integer + - type: 'null' + content: + type: array + items: + $ref: '#/$defs/RichTextNode' + + BlokNode: + type: object + required: [type] + properties: + type: + type: string + enum: [blok] + attrs: + type: object + properties: + id: + type: [string, 'null'] + description: Blok instance ID + body: + description: Array of embedded component instances + oneOf: + - type: array + items: + type: object + required: [_uid, component] + properties: + _uid: + type: string + component: + type: string + _editable: + type: string + additionalProperties: true + - type: 'null' diff --git a/tools/openapi-codegen/src/aliases.ts b/tools/openapi-codegen/src/aliases.ts index b4d84827e..83ee66e8b 100644 --- a/tools/openapi-codegen/src/aliases.ts +++ b/tools/openapi-codegen/src/aliases.ts @@ -78,6 +78,9 @@ export const ALIASES = [ { source: 'MultilinkFieldValue', spec: 'overlay', emitAs: 'MultilinkFieldValue' }, { source: 'PluginFieldValue', spec: 'overlay', emitAs: 'PluginFieldValue' }, { source: 'RichtextFieldValue', spec: 'overlay', emitAs: 'RichtextFieldValue' }, + { source: 'RichtextFieldValue', spec: 'overlay', emitAs: 'RichtextDoc' }, + { source: 'RichTextNode', spec: 'overlay', emitAs: 'RichTextNode' }, + { source: 'RichTextMark', spec: 'overlay', emitAs: 'RichTextMark' }, { source: 'TableFieldValue', spec: 'overlay', emitAs: 'TableFieldValue' }, { source: 'ComponentSchemaField', spec: 'overlay', emitAs: 'Field' }, ] as const satisfies readonly AliasSpec[]; diff --git a/tools/openapi-codegen/src/index.ts b/tools/openapi-codegen/src/index.ts index 849eb381d..011fefb0b 100644 --- a/tools/openapi-codegen/src/index.ts +++ b/tools/openapi-codegen/src/index.ts @@ -228,6 +228,9 @@ function copyWrapperTemplates( leafLocation: ReadonlyMap, sdk: 'mapi' | 'capi' | false | undefined, ): void { + if (wrappers.size === 0) { + return; + } const typesDir = resolve(outDir, 'types'); mkdirSync(typesDir, { recursive: true }); @@ -248,9 +251,8 @@ function copyWrapperTemplates( // instead of being redefined per template. if (wrappers.size > 0) { writeFileSync(resolve(typesDir, '_utils.ts'), buildUtilsFile(), 'utf8'); + writeFileSync(resolve(typesDir, '_sources.ts'), buildSourcesFile(publicPerSpec, leafPerSpec, leafLocation, sdk), 'utf8'); } - - writeFileSync(resolve(typesDir, '_sources.ts'), buildSourcesFile(publicPerSpec, leafPerSpec, leafLocation, sdk), 'utf8'); } function buildUtilsFile(): string { From f2d13d004e8fd7554c867e07c7095ad8aa1e0657 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Wed, 8 Jul 2026 18:49:25 +0530 Subject: [PATCH 02/10] fix(richtext): align YAML spec and generated types with richtext-attrs.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make attrs required on all nodes/marks that define an attrs object, and add inner required arrays matching the non-optional fields declared in richtext-attrs.ts (LinkAttrs, ParagraphAttrs, HeadingAttrs, etc.). Also fix two structural issues surfaced by the stricter types: - NoAttrs: Record → Record to be compatible with the Record that SbRichTextMark adds via intersection for attr-less marks (bold, italic, etc.) - BlokNode body items: remove additionalProperties: true whose [key: string]: unknown index signature was incompatible with SbBlokKeyDataTypes in SbBlokData --- .../richtext/src/extensions/richtext-attrs.ts | 2 +- .../src/generated/overlay/types.gen.ts | 71 +++++++++---------- .../field-types/richtext-field-value.yaml | 38 +++++----- 3 files changed, 58 insertions(+), 53 deletions(-) diff --git a/packages/richtext/src/extensions/richtext-attrs.ts b/packages/richtext/src/extensions/richtext-attrs.ts index a7f9123bd..098e3b9fe 100644 --- a/packages/richtext/src/extensions/richtext-attrs.ts +++ b/packages/richtext/src/extensions/richtext-attrs.ts @@ -3,7 +3,7 @@ import type { SbBlokData } from '../static/types'; import type { TiptapMarkName, TiptapNodeName } from '../static/types.generated'; /** For node and mark that do not have any attribute support */ -export type NoAttrs = Record; +export type NoAttrs = Record; /** Node Attributes */ diff --git a/packages/richtext/src/generated/overlay/types.gen.ts b/packages/richtext/src/generated/overlay/types.gen.ts index eb9aea20d..06945e453 100644 --- a/packages/richtext/src/generated/overlay/types.gen.ts +++ b/packages/richtext/src/generated/overlay/types.gen.ts @@ -32,11 +32,11 @@ export type RichtextFieldValueRichTextMark = RichtextFieldValueLinkMark | Richte export interface RichtextFieldValueParagraphNode { type: 'paragraph'; - attrs?: { + attrs: { /** * Text alignment */ - textAlign?: 'left' | 'center' | 'right' | 'justify' | null; + textAlign: 'left' | 'center' | 'right' | 'justify' | null; }; content?: Array; marks?: Array; @@ -53,7 +53,7 @@ export interface RichtextFieldValueTextNode { export interface RichtextFieldValueHeadingNode { type: 'heading'; - attrs?: { + attrs: { /** * Heading level (h1–h6) */ @@ -61,7 +61,7 @@ export interface RichtextFieldValueHeadingNode { /** * Text alignment */ - textAlign?: 'left' | 'center' | 'right' | 'justify' | null; + textAlign: 'left' | 'center' | 'right' | 'justify' | null; }; content?: Array; marks?: Array; @@ -81,7 +81,7 @@ export interface RichtextFieldValueBulletListNode { export interface RichtextFieldValueOrderedListNode { type: 'ordered_list'; - attrs?: { + attrs: { /** * Starting number for the ordered list */ @@ -99,11 +99,11 @@ export interface RichtextFieldValueListItemNode { export interface RichtextFieldValueCodeBlockNode { type: 'code_block'; - attrs?: { + attrs: { /** * Language class (e.g. "language-typescript") */ - class?: string | null; + class: string | null; }; content?: Array; marks?: Array; @@ -120,11 +120,11 @@ export interface RichtextFieldValueHorizontalRuleNode { export interface RichtextFieldValueImageNode { type: 'image'; - attrs?: { + attrs: { /** * Storyblok asset ID */ - id?: number | null; + id: number | null; /** * Image URL */ @@ -132,18 +132,18 @@ export interface RichtextFieldValueImageNode { /** * Alternative text */ - alt?: string | null; - title?: string | null; - source?: string | null; - copyright?: string | null; + alt: string | null; + title: string | null; + source: string | null; + copyright: string | null; /** * Asset metadata */ - meta_data?: { - alt?: string | null; - title?: string | null; - source?: string | null; - copyright?: string | null; + meta_data: { + alt: string | null; + title: string | null; + source: string | null; + copyright: string | null; } | null; }; marks?: Array; @@ -151,7 +151,7 @@ export interface RichtextFieldValueImageNode { export interface RichtextFieldValueEmojiNode { type: 'emoji'; - attrs?: { + attrs: { /** * Emoji name/slug */ @@ -181,7 +181,7 @@ export interface RichtextFieldValueTableRowNode { export interface RichtextFieldValueTableCellNode { type: 'tableCell'; - attrs?: { + attrs: { colspan?: number; rowspan?: number; /** @@ -195,7 +195,7 @@ export interface RichtextFieldValueTableCellNode { export interface RichtextFieldValueTableHeaderNode { type: 'tableHeader'; - attrs?: { + attrs: { colspan?: number; rowspan?: number; /** @@ -208,19 +208,18 @@ export interface RichtextFieldValueTableHeaderNode { export interface RichtextFieldValueBlokNode { type: 'blok'; - attrs?: { + attrs: { /** * Blok instance ID */ - id?: string | null; + id: string | null; /** * Array of embedded component instances */ - body?: Array<{ + body: Array<{ _uid: string; component: string; _editable?: string; - [key: string]: unknown | string | undefined; }> | null; }; } @@ -230,27 +229,27 @@ export interface RichtextFieldValueLinkMark { /** * Link attributes */ - attrs?: { + attrs: { /** * Link URL */ - href?: string | null; + href: string | null; /** * UUID of the linked story (for internal links) */ - uuid?: string | null; + uuid: string | null; /** * Anchor/fragment identifier */ - anchor?: string | null; + anchor: string | null; /** * Link target attribute */ - target?: '_self' | '_blank' | '_parent' | '_top' | null; + target: '_self' | '_blank' | '_parent' | '_top' | null; /** * Type of link */ - linktype?: 'story' | 'url' | 'email' | 'asset' | null; + linktype: 'story' | 'url' | 'email' | 'asset' | null; /** * Custom link attributes */ @@ -290,7 +289,7 @@ export interface RichtextFieldValueSubscriptMark { export interface RichtextFieldValueHighlightMark { type: 'highlight'; - attrs?: { + attrs: { /** * Highlight color (CSS color value) */ @@ -300,7 +299,7 @@ export interface RichtextFieldValueHighlightMark { export interface RichtextFieldValueTextStyleMark { type: 'textStyle'; - attrs?: { + attrs: { color?: string | null; id?: string | null; class?: string | null; @@ -309,7 +308,7 @@ export interface RichtextFieldValueTextStyleMark { export interface RichtextFieldValueAnchorMark { type: 'anchor'; - attrs?: { + attrs: { /** * Anchor identifier */ @@ -319,10 +318,10 @@ export interface RichtextFieldValueAnchorMark { export interface RichtextFieldValueStyledMark { type: 'styled'; - attrs?: { + attrs: { /** * CSS class name */ - class?: string | null; + class: string | null; }; } diff --git a/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml b/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml index f9ebb62bf..4ebc158da 100644 --- a/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml +++ b/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml @@ -42,7 +42,7 @@ $defs: LinkMark: type: object - required: [type] + required: [type, attrs] properties: type: type: string @@ -50,6 +50,7 @@ $defs: attrs: type: object description: Link attributes + required: [href, uuid, anchor, target, linktype] properties: href: type: [string, 'null'] @@ -135,7 +136,7 @@ $defs: HighlightMark: type: object - required: [type] + required: [type, attrs] properties: type: type: string @@ -150,7 +151,7 @@ $defs: TextStyleMark: type: object - required: [type] + required: [type, attrs] properties: type: type: string @@ -167,7 +168,7 @@ $defs: AnchorMark: type: object - required: [type] + required: [type, attrs] properties: type: type: string @@ -182,13 +183,14 @@ $defs: StyledMark: type: object - required: [type] + required: [type, attrs] properties: type: type: string enum: [styled] attrs: type: object + required: [class] properties: class: type: [string, 'null'] @@ -221,13 +223,14 @@ $defs: ParagraphNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string enum: [paragraph] attrs: type: object + required: [textAlign] properties: textAlign: description: Text alignment @@ -261,13 +264,14 @@ $defs: HeadingNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string enum: [heading] attrs: type: object + required: [textAlign] properties: level: description: Heading level (h1–h6) @@ -324,7 +328,7 @@ $defs: OrderedListNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string @@ -362,13 +366,14 @@ $defs: CodeBlockNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string enum: [code_block] attrs: type: object + required: [class] properties: class: type: [string, 'null'] @@ -404,14 +409,14 @@ $defs: ImageNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string enum: [image] attrs: type: object - required: [src] + required: [src, id, alt, title, source, copyright, meta_data] properties: id: type: [integer, 'null'] @@ -432,6 +437,7 @@ $defs: description: Asset metadata oneOf: - type: object + required: [alt, title, source, copyright] properties: alt: type: [string, 'null'] @@ -449,7 +455,7 @@ $defs: EmojiNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string @@ -502,7 +508,7 @@ $defs: TableCellNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string @@ -530,7 +536,7 @@ $defs: TableHeaderNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string @@ -556,13 +562,14 @@ $defs: BlokNode: type: object - required: [type] + required: [type, attrs] properties: type: type: string enum: [blok] attrs: type: object + required: [id, body] properties: id: type: [string, 'null'] @@ -581,5 +588,4 @@ $defs: type: string _editable: type: string - additionalProperties: true - type: 'null' From fd36c09955d7225df44415f4d669602221abc2d6 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 9 Jul 2026 13:11:51 +0530 Subject: [PATCH 03/10] chore(openapi): update richtext field-value spec and regenerate types Fixes DX-487 --- packages/richtext/scripts/generate.ts | 5 +++++ packages/richtext/src/generated/overlay/types.gen.ts | 3 +++ .../shared/stories/field-types/richtext-field-value.yaml | 8 +++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/richtext/scripts/generate.ts b/packages/richtext/scripts/generate.ts index f02eae4bc..a6aa2643f 100644 --- a/packages/richtext/scripts/generate.ts +++ b/packages/richtext/scripts/generate.ts @@ -15,6 +15,7 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { generate } from '@storyblok/openapi-codegen'; +import { execSync } from 'node:child_process'; const PKG_ROOT = resolve(fileURLToPath(import.meta.url), '../..'); @@ -22,3 +23,7 @@ await generate({ outDir: resolve(PKG_ROOT, 'src/generated'), include: ['RichtextDoc', 'RichTextNode', 'RichTextMark'], }); +const GENERATED_PATH = resolve(PKG_ROOT, 'src/generated'); +execSync(`pnpm eslint ${GENERATED_PATH} --fix`, { + stdio: 'inherit', +}); diff --git a/packages/richtext/src/generated/overlay/types.gen.ts b/packages/richtext/src/generated/overlay/types.gen.ts index 06945e453..1ebca18db 100644 --- a/packages/richtext/src/generated/overlay/types.gen.ts +++ b/packages/richtext/src/generated/overlay/types.gen.ts @@ -220,6 +220,9 @@ export interface RichtextFieldValueBlokNode { _uid: string; component: string; _editable?: string; + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | string | undefined; }> | null; }; } diff --git a/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml b/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml index 4ebc158da..0f1c780c6 100644 --- a/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml +++ b/tools/openapi-codegen/specs/shared/stories/field-types/richtext-field-value.yaml @@ -580,7 +580,7 @@ $defs: - type: array items: type: object - required: [_uid, component] + required: ['_uid', 'component'] properties: _uid: type: string @@ -588,4 +588,10 @@ $defs: type: string _editable: type: string + additionalProperties: + oneOf: + - type: string + - type: number + - type: boolean + - type: object - type: 'null' From d58d33d346aa80ee63fcdaa92ba1055c2379b97d Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 9 Jul 2026 13:12:24 +0530 Subject: [PATCH 04/10] fix(richtext): update render logic, type signatures, and tests for new OpenAPI types Fixes DX-487 --- .../lib/richtext/rich-text.component.spec.ts | 15 +- packages/js/src/index.test.ts | 141 ++++++++------ packages/richtext/src/render-richtext.test.ts | 76 ++++++-- packages/richtext/src/render-richtext.ts | 75 ++++++-- .../richtext/src/static/node-helpers.test.ts | 182 ++++++++++++------ .../richtext/src/static/types.type-test.ts | 3 +- packages/richtext/src/static/util.ts | 9 +- packages/richtext/src/test-utils/helpers.ts | 4 +- .../richtext/src/test-utils/integration.ts | 2 +- packages/richtext/src/test-utils/link.ts | 7 + packages/richtext/src/test-utils/marks.ts | 4 +- packages/richtext/src/test-utils/nodes.ts | 16 +- packages/richtext/src/test-utils/tables.ts | 6 +- 13 files changed, 364 insertions(+), 176 deletions(-) diff --git a/packages/angular/src/lib/richtext/rich-text.component.spec.ts b/packages/angular/src/lib/richtext/rich-text.component.spec.ts index 68743c577..067827c0d 100644 --- a/packages/angular/src/lib/richtext/rich-text.component.spec.ts +++ b/packages/angular/src/lib/richtext/rich-text.component.spec.ts @@ -162,7 +162,11 @@ describe('SbRichTextComponent', () => { }); describe('custom renderers', () => { it('overrides node rendering with custom component', async () => { - const node: SbRichTextNode = { type: 'paragraph', content: [text('Hello')] }; + const node: SbRichTextNode = { + type: 'paragraph', + attrs: { textAlign: null }, + content: [text('Hello')], + }; TestBed.resetTestingModule(); await TestBed.configureTestingModule({ imports: [SbRichTextComponent], @@ -198,6 +202,7 @@ describe('SbRichTextComponent', () => { const node: SbRichTextNode = { type: 'paragraph', + attrs: { textAlign: null }, content: [text('Bold Link', [{ type: 'bold' }, linkMark('https://example.com')])], }; fixture.componentRef.setInput('sbDocument', node); @@ -314,9 +319,9 @@ describe('SbRichTextComponent', () => { const content: SbRichTextDoc = { type: 'doc', content: [ - { type: 'paragraph', content: [text('Before')] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [text('Before')] }, { type: 'blok', attrs: { id: 'x', body: [{ _uid: '1', component: 'test' }] } }, - { type: 'paragraph', content: [text('After')] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [text('After')] }, ], }; fixture.componentRef.setInput('sbDocument', content); @@ -431,9 +436,9 @@ describe('SbRichTextComponent', () => { const doc: SbRichTextDoc = { type: 'doc', content: [ - { type: 'paragraph', content: [text('Before image')] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [text('Before image')] }, imageNode, - { type: 'paragraph', content: [text('After image')] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [text('After image')] }, ], }; fixture.componentRef.setInput('sbDocument', doc); diff --git a/packages/js/src/index.test.ts b/packages/js/src/index.test.ts index cf91519be..c70622246 100644 --- a/packages/js/src/index.test.ts +++ b/packages/js/src/index.test.ts @@ -25,21 +25,26 @@ describe('@storyblok/js', () => { it('is loaded correctly when using the apiPlugin', async () => { // Mock fetch to return a successful response - const fetchSpy = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(JSON.stringify({ - stories: [ - { id: 1, name: 'Story 1' }, - { id: 2, name: 'Story 2' }, - { id: 3, name: 'Story 3' }, - ], - }))); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + stories: [ + { id: 1, name: 'Story 1' }, + { id: 2, name: 'Story 2' }, + { id: 3, name: 'Story 3' }, + ], + }), + ), + ); const { storyblokApi } = storyblokInit({ accessToken: 'TEST_TOKEN', use: [apiPlugin], }); - const result = await storyblokApi!.getAll('cdn/stories', { version: 'draft' }); + const result = await storyblokApi!.getAll('cdn/stories', { + version: 'draft', + }); expect(result.length).toBeGreaterThan(0); expect(fetchSpy).toHaveBeenCalled(); @@ -66,7 +71,8 @@ describe('@storyblok/js', () => { it('should handle failed API calls', async () => { // Create an isolated mock just for this test - const fetchSpy = vi.spyOn(globalThis, 'fetch') + const fetchSpy = vi + .spyOn(globalThis, 'fetch') .mockRejectedValueOnce(new Error('API Error')); const { storyblokApi } = storyblokInit({ @@ -82,10 +88,13 @@ describe('@storyblok/js', () => { it('should support different API endpoints', async () => { // Create a spy that returns successful responses - const fetchSpy = vi.spyOn(globalThis, 'fetch') + const fetchSpy = vi + .spyOn(globalThis, 'fetch') .mockResolvedValueOnce(new Response(JSON.stringify({ stories: [] }))) .mockResolvedValueOnce(new Response(JSON.stringify({ links: [] }))) - .mockResolvedValueOnce(new Response(JSON.stringify({ datasources: [] }))); + .mockResolvedValueOnce( + new Response(JSON.stringify({ datasources: [] })), + ); const { storyblokApi } = storyblokInit({ accessToken: 'test-token', @@ -105,19 +114,28 @@ describe('@storyblok/js', () => { it('should handle pagination correctly', async () => { // Mock fetch to return paginated responses - const fetchSpy = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(JSON.stringify({ - stories: [{ id: 1 }, { id: 2 }], - total: 4, - perPage: 2, - page: 1, - }))) - .mockResolvedValueOnce(new Response(JSON.stringify({ - stories: [{ id: 3 }, { id: 4 }], - total: 4, - perPage: 2, - page: 2, - }))); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + stories: [{ id: 1 }, { id: 2 }], + total: 4, + perPage: 2, + page: 1, + }), + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + stories: [{ id: 3 }, { id: 4 }], + total: 4, + perPage: 2, + page: 2, + }), + ), + ); const { storyblokApi } = storyblokInit({ accessToken: 'test-token', @@ -150,12 +168,7 @@ describe('@storyblok/js', () => { // Verify pagination results expect(allStories).toHaveLength(4); - expect(allStories).toEqual([ - { id: 1 }, - { id: 2 }, - { id: 3 }, - { id: 4 }, - ]); + expect(allStories).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]); // Verify that fetch was called twice for the two pages expect(fetchSpy).toHaveBeenCalledTimes(2); @@ -203,7 +216,9 @@ describe('@storyblok/js', () => { }); // Get the bridge script element - const bridgeScript = document.querySelector('#storyblok-javascript-bridge'); + const bridgeScript = document.querySelector( + '#storyblok-javascript-bridge', + ); // Verify bridge script was added with correct URL expect(bridgeScript).toBeTruthy(); @@ -238,7 +253,9 @@ describe('@storyblok/js', () => { }); // Get the bridge script element - const bridgeScript = document.querySelector('#storyblok-javascript-bridge'); + const bridgeScript = document.querySelector( + '#storyblok-javascript-bridge', + ); // Verify the script was created expect(bridgeScript).toBeTruthy(); @@ -256,17 +273,20 @@ describe('@storyblok/js', () => { describe('editable', () => { it('gets data-blok-c and data-blok-uid', async () => { // Mock fetch to return a story response - const fetchSpy = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(JSON.stringify({ - story: { - id: 123456, - uid: 'test-uid-123', - content: { - component: 'page', - body: [], + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + story: { + id: 123456, + uid: 'test-uid-123', + content: { + component: 'page', + body: [], + }, }, - }, - }))); + }), + ), + ); const { storyblokApi } = storyblokInit({ accessToken: 'TEST_TOKEN', @@ -300,9 +320,16 @@ describe('@storyblok/js', () => { }); it('should use renderers for customize elements', () => { - const data: SbRichTextDoc = { type: 'doc', content: [ - { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }, - ] }; + const data: SbRichTextDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }; const html = renderRichText(data, { renderers: { @@ -326,7 +353,9 @@ describe('@storyblok/js', () => { const loadPromise = loadBridge(bridgeUrl); // Check if script was added to DOM - const script = document.querySelector('#storyblok-javascript-bridge') as HTMLScriptElement; + const script = document.querySelector( + '#storyblok-javascript-bridge', + ) as HTMLScriptElement; expect(script).toBeTruthy(); expect(script?.getAttribute('src')).toBe(bridgeUrl); expect(script?.async).toBe(true); @@ -345,7 +374,9 @@ describe('@storyblok/js', () => { const script = document.querySelector('#storyblok-javascript-bridge'); // Simulate script error - const event = new ErrorEvent('error', { error: new Error('Failed to load script') }); + const event = new ErrorEvent('error', { + error: new Error('Failed to load script'), + }); script?.dispatchEvent(event); await expect(loadPromise).rejects.toBeDefined(); @@ -435,9 +466,11 @@ describe('@storyblok/js', () => { const originalWindow = globalThis.window; delete (globalThis as any).window; - await expect(loadBridge('https://app.storyblok.com/f/storyblok-v2-latest.js')) - .rejects - .toThrow('Cannot load Storyblok bridge: window is undefined (server-side environment)'); + await expect( + loadBridge('https://app.storyblok.com/f/storyblok-v2-latest.js'), + ).rejects.toThrow( + 'Cannot load Storyblok bridge: window is undefined (server-side environment)', + ); // Restore window globalThis.window = originalWindow; @@ -451,9 +484,9 @@ describe('@storyblok/js', () => { document.head.appendChild(existingScript); // Should resolve immediately since script already exists - await expect(loadBridge('https://app.storyblok.com/f/storyblok-v2-latest.js')) - .resolves - .toBe(undefined); + await expect( + loadBridge('https://app.storyblok.com/f/storyblok-v2-latest.js'), + ).resolves.toBe(undefined); // Clean up existingScript.remove(); diff --git a/packages/richtext/src/render-richtext.test.ts b/packages/richtext/src/render-richtext.test.ts index 2a4e65457..a20dec26a 100644 --- a/packages/richtext/src/render-richtext.test.ts +++ b/packages/richtext/src/render-richtext.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; import { renderRichText } from './render-richtext'; import type { SbRichTextDoc, SbRichTextRenderContext } from './static'; -import { customRendererFixture, integrationFixtures, linkFixtures, linkMark, markFixtures, nodeFixtures, tableFixtures, text } from './test-utils'; +import { + customRendererFixture, + integrationFixtures, + linkFixtures, + linkMark, + markFixtures, + nodeFixtures, + tableFixtures, + text, +} from './test-utils'; import { attrsToHtmlString, splitTableRows } from './static'; describe('renderRichText', () => { @@ -61,9 +70,11 @@ describe('renderRichText', () => { it(node_and_mark.title, () => { const options: SbRichTextRenderContext = { renderers: { - heading: ({ content, attrs, context }) => `${renderRichText(content, context)}`, + heading: ({ content, attrs, context }) => + `${renderRichText(content, context)}`, bold: ({ children }) => `${children}`, - link: ({ children, attrs }) => `${children}`, + link: ({ children, attrs }) => + `${children}`, }, }; const result = renderRichText(node_and_mark.input, options); @@ -73,7 +84,8 @@ describe('renderRichText', () => { it(recursive.title, () => { const options: SbRichTextRenderContext = { renderers: { - heading: ({ attrs, content }) => `${renderRichText(content, options)}`, + heading: ({ attrs, content }) => + `${renderRichText(content, options)}`, bold: ({ children }) => `${children}`, }, }; @@ -111,16 +123,18 @@ describe('renderRichText', () => { describe('blok nodes', () => { const blokDoc: SbRichTextDoc = { type: 'doc', - content: [{ - type: 'blok', - attrs: { - id: 'blok-123', - body: [ - { _uid: '1', component: 'button', title: 'Click Me' }, - { _uid: '2', component: 'button', title: 'Submit' }, - ], + content: [ + { + type: 'blok', + attrs: { + id: 'blok-123', + body: [ + { _uid: '1', component: 'button', title: 'Click Me' }, + { _uid: '2', component: 'button', title: 'Submit' }, + ], + }, }, - }], + ], }; it('warns when no custom renderer provided', () => { @@ -141,7 +155,9 @@ describe('renderRichText', () => { renderers: { blok: ({ attrs }) => { const body = Array.isArray(attrs?.body) ? attrs.body : []; - return body.map(b => ``).join(''); + return body + .map(b => ``) + .join(''); }, }, }; @@ -179,23 +195,43 @@ describe('renderRichText', () => { const content: SbRichTextDoc = { type: 'doc', content: [ - { type: 'paragraph', content: [text('Before')] }, - { type: 'blok', attrs: { id: 'x', body: [{ _uid: '1' }] } }, - { type: 'paragraph', content: [text('After')] }, + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [text('Before')], + }, + { + type: 'blok', + attrs: { + id: 'x', + body: [{ _uid: '1', component: 'button', title: 'Click Me' }], + }, + }, + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [text('After')], + }, ], }; - expect(renderRichText(content, options)).toBe('

Before

After

'); + expect(renderRichText(content, options)).toBe( + '

Before

After

', + ); }); }); describe('xSS prevention', () => { it('escapes HTML in text content', () => { const node = text(''); - expect(renderRichText(node)).toBe('<script>alert("xss")</script>'); + expect(renderRichText(node)).toBe( + '<script>alert("xss")</script>', + ); }); it('escapes HTML in attributes', () => { const node = text('Link', [linkMark('javascript:alert("xss")')]); - expect(renderRichText(node)).toContain('href="javascript:alert("xss")"'); + expect(renderRichText(node)).toContain( + 'href="javascript:alert("xss")"', + ); }); }); }); diff --git a/packages/richtext/src/render-richtext.ts b/packages/richtext/src/render-richtext.ts index 1947fac18..228192e15 100644 --- a/packages/richtext/src/render-richtext.ts +++ b/packages/richtext/src/render-richtext.ts @@ -1,7 +1,26 @@ import { escapeHtml } from './utils'; import { optimizeImage } from './images-optimization'; -import { areLinkMarksEqual, attrsToHtmlString, getStaticChildren, getTextNodeLinkMark, isSelfClosing, isTableHeaderRow, normalizeNodes, processAttrs, resolveTag, styleToString } from './static'; -import type { RenderSpec, SbRichTextElement, SbRichTextInput, SbRichTextMark, SbRichTextNode, SbRichTextRenderContext, SbRichTextTextNode } from './static'; +import { + areLinkMarksEqual, + attrsToHtmlString, + getStaticChildren, + getTextNodeLinkMark, + isSelfClosing, + isTableHeaderRow, + normalizeNodes, + processAttrs, + resolveTag, + styleToString, +} from './static'; +import type { + RenderSpec, + SbRichTextElement, + SbRichTextInput, + SbRichTextMark, + SbRichTextNode, + SbRichTextRenderContext, + SbRichTextTextNode, +} from './static'; /** * Renders a Storyblok RichText JSON document to an HTML string. * @@ -13,7 +32,7 @@ import type { RenderSpec, SbRichTextElement, SbRichTextInput, SbRichTextMark, Sb * ```ts * const html = renderRichText({ * type: 'doc', - * content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }] + * content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ type: 'text', text: 'Hello' }] }] * }); * // => '

Hello

' * ``` @@ -25,25 +44,33 @@ export function renderRichText( const nodes = normalizeNodes(document); return nodes?.length ? renderChildren(nodes, context) : ''; } -type NodeRenderer = (props: SbRichTextNode & { children: string; context?: SbRichTextRenderContext }) => string; +type NodeRenderer = ( + props: SbRichTextNode & { + children: string; + context?: SbRichTextRenderContext; + }, +) => string; type MarkRenderer = (props: SbRichTextMark & { children: string }) => string; /** Renders a single node to HTML. */ -function renderNode(node: SbRichTextNode, context?: SbRichTextRenderContext): string { +function renderNode( + node: SbRichTextNode, + context?: SbRichTextRenderContext, +): string { if (node.type === 'text') { return renderTextNode(node, node.marks, context); } const content = node.content ? renderChildren(node.content, context) : ''; // Custom renderer takes full control - const customRenderer = context?.renderers?.[node.type] as NodeRenderer | undefined; + const customRenderer = context?.renderers?.[node.type] as + | NodeRenderer + | undefined; if (customRenderer) { return customRenderer({ ...node, children: content, context }); } if (node.type === 'blok') { - console.warn( - '"blok" nodes require a custom renderer in renderRichText.', - ); + console.warn('"blok" nodes require a custom renderer in renderRichText.'); return ''; } @@ -70,7 +97,12 @@ function renderNode(node: SbRichTextNode, context?: SbRichTextRenderContext): st const staticChildren = getStaticChildren(node); if (staticChildren) { - const inner = renderStaticStructure(node.type, staticChildren, node.attrs, content); + const inner = renderStaticStructure( + node.type, + staticChildren, + node.attrs, + content, + ); return `<${tag}>${inner}`; } @@ -109,7 +141,10 @@ function renderOptimizedImage( * This produces cleaner HTML: `text bold more` * instead of: `textboldmore` */ -function renderChildren(children: SbRichTextNode[], context?: SbRichTextRenderContext): string { +function renderChildren( + children: SbRichTextNode[], + context?: SbRichTextRenderContext, +): string { let result = ''; let i = 0; const len = children.length; @@ -121,7 +156,10 @@ function renderChildren(children: SbRichTextNode[], context?: SbRichTextRenderCo if (linkMark) { // Find end of link group (consecutive text nodes with same link) let end = i + 1; - while (end < len && areLinkMarksEqual(linkMark, getTextNodeLinkMark(children[end]))) { + while ( + end < len + && areLinkMarksEqual(linkMark, getTextNodeLinkMark(children[end])) + ) { end++; } result += renderLinkGroup(children, i, end, linkMark, context); @@ -162,7 +200,9 @@ function wrapWithMark( context?: SbRichTextRenderContext, ): string { // Custom mark renderer - const customRenderer = context?.renderers?.[mark.type] as MarkRenderer | undefined; + const customRenderer = context?.renderers?.[mark.type] as + | MarkRenderer + | undefined; if (customRenderer) { return customRenderer({ ...mark, @@ -197,7 +237,9 @@ function renderLinkGroup( } // Custom link renderer - const customRenderer = context?.renderers?.[linkMark.type] as MarkRenderer | undefined; + const customRenderer = context?.renderers?.[linkMark.type] as + | MarkRenderer + | undefined; if (customRenderer) { return customRenderer({ ...linkMark, @@ -283,7 +325,10 @@ function renderStaticStructure( } /** Builds HTML attribute string from node/mark type and attrs. */ -export function buildHtmlAttrs(type: SbRichTextElement, attrs: Record | undefined): string { +export function buildHtmlAttrs( + type: SbRichTextElement, + attrs: Record | undefined, +): string { const processed = processAttrs(type, attrs, { colspan: 'colspan', rowspan: 'rowspan', diff --git a/packages/richtext/src/static/node-helpers.test.ts b/packages/richtext/src/static/node-helpers.test.ts index 31e5f24c3..f7ed0fc9a 100644 --- a/packages/richtext/src/static/node-helpers.test.ts +++ b/packages/richtext/src/static/node-helpers.test.ts @@ -16,7 +16,11 @@ import { linkMark, text as textNode } from '../test-utils/helpers'; describe('getTextNodeLinkMark', () => { it('returns null for non-text nodes', () => { - const node: SbRichTextNode = { type: 'paragraph', content: [] }; + const node: SbRichTextNode = { + type: 'paragraph', + attrs: { textAlign: null }, + content: [], + }; expect(getTextNodeLinkMark(node)).toBeNull(); }); @@ -38,7 +42,11 @@ describe('getTextNodeLinkMark', () => { it('returns link mark even with other marks present', () => { const mark = linkMark('/test'); - const node = textNode('hello', [{ type: 'bold' }, mark, { type: 'italic' }]); + const node = textNode('hello', [ + { type: 'bold' }, + mark, + { type: 'italic' }, + ]); expect(getTextNodeLinkMark(node)).toEqual(mark); }); }); @@ -74,66 +82,84 @@ describe('areLinkMarksEqual', () => { }); it('returns false for marks with different target', () => { - expect(areLinkMarksEqual( - linkMark('/a', { target: '_blank' }), - linkMark('/a', { target: '_self' }), - )).toBe(false); + expect( + areLinkMarksEqual( + linkMark('/a', { target: '_blank' }), + linkMark('/a', { target: '_self' }), + ), + ).toBe(false); }); it('returns false for marks with different linktype', () => { - expect(areLinkMarksEqual( - linkMark('/a', { linktype: 'story' }), - linkMark('/a', { linktype: 'url' }), - )).toBe(false); + expect( + areLinkMarksEqual( + linkMark('/a', { linktype: 'story' }), + linkMark('/a', { linktype: 'url' }), + ), + ).toBe(false); }); it('returns false for marks with different anchor', () => { - expect(areLinkMarksEqual( - linkMark('/a', { anchor: 'section1' }), - linkMark('/a', { anchor: 'section2' }), - )).toBe(false); + expect( + areLinkMarksEqual( + linkMark('/a', { anchor: 'section1' }), + linkMark('/a', { anchor: 'section2' }), + ), + ).toBe(false); }); it('returns false for marks with different uuid', () => { - expect(areLinkMarksEqual( - linkMark('/a', { uuid: 'uuid-1' }), - linkMark('/a', { uuid: 'uuid-2' }), - )).toBe(false); + expect( + areLinkMarksEqual( + linkMark('/a', { uuid: 'uuid-1' }), + linkMark('/a', { uuid: 'uuid-2' }), + ), + ).toBe(false); }); it('returns true for marks with same custom attributes', () => { - expect(areLinkMarksEqual( - linkMark('/a', { custom: { title: 'hello' } }), - linkMark('/a', { custom: { title: 'hello' } }), - )).toBe(true); + expect( + areLinkMarksEqual( + linkMark('/a', { custom: { title: 'hello' } }), + linkMark('/a', { custom: { title: 'hello' } }), + ), + ).toBe(true); }); it('returns false for marks with different custom attributes', () => { - expect(areLinkMarksEqual( - linkMark('/a', { custom: { title: 'hello' } }), - linkMark('/a', { custom: { title: 'world' } }), - )).toBe(false); + expect( + areLinkMarksEqual( + linkMark('/a', { custom: { title: 'hello' } }), + linkMark('/a', { custom: { title: 'world' } }), + ), + ).toBe(false); }); it('returns false when one mark has custom and other does not', () => { - expect(areLinkMarksEqual( - linkMark('/a', { custom: { title: 'hello' } }), - linkMark('/a'), - )).toBe(false); + expect( + areLinkMarksEqual( + linkMark('/a', { custom: { title: 'hello' } }), + linkMark('/a'), + ), + ).toBe(false); }); it('returns true for marks with nested custom attributes', () => { - expect(areLinkMarksEqual( - linkMark('/a', { custom: { data: { nested: 'value' } } }), - linkMark('/a', { custom: { data: { nested: 'value' } } }), - )).toBe(true); + expect( + areLinkMarksEqual( + linkMark('/a', { custom: { data: { nested: 'value' } } }), + linkMark('/a', { custom: { data: { nested: 'value' } } }), + ), + ).toBe(true); }); it('returns false for marks with different nested custom attributes', () => { - expect(areLinkMarksEqual( - linkMark('/a', { custom: { data: { nested: 'value1' } } }), - linkMark('/a', { custom: { data: { nested: 'value2' } } }), - )).toBe(false); + expect( + areLinkMarksEqual( + linkMark('/a', { custom: { data: { nested: 'value1' } } }), + linkMark('/a', { custom: { data: { nested: 'value2' } } }), + ), + ).toBe(false); }); }); @@ -143,7 +169,11 @@ describe('areLinkMarksEqual', () => { describe('getInnerMarks', () => { it('returns empty array for non-text nodes', () => { - const node: SbRichTextNode = { type: 'paragraph', content: [] }; + const node: SbRichTextNode = { + type: 'paragraph', + attrs: { textAlign: null }, + content: [], + }; expect(getInnerMarks(node)).toEqual([]); }); @@ -160,11 +190,15 @@ describe('getInnerMarks', () => { it('returns non-link marks', () => { const boldMark = { type: 'bold' }; const italicMark = { type: 'italic' }; - const node = textNode('hello', [{ - type: 'bold', - }, linkMark('/test'), { - type: 'italic', - }]); + const node = textNode('hello', [ + { + type: 'bold', + }, + linkMark('/test'), + { + type: 'italic', + }, + ]); expect(getInnerMarks(node)).toEqual([boldMark, italicMark]); }); }); @@ -181,14 +215,18 @@ describe('groupLinkNodes', () => { it('groups single non-linked text node', () => { const node = textNode('hello'); const result = groupLinkNodes([node]); - expect(result).toEqual([{ _key: 'group-node-0', nodes: [node], linkMark: null }]); + expect(result).toEqual([ + { _key: 'group-node-0', nodes: [node], linkMark: null }, + ]); }); it('groups single linked text node', () => { const mark = linkMark('/test'); const node = textNode('hello', [mark]); const result = groupLinkNodes([node]); - expect(result).toEqual([{ _key: 'group-link-0', nodes: [node], linkMark: mark }]); + expect(result).toEqual([ + { _key: 'group-link-0', nodes: [node], linkMark: mark }, + ]); }); it('merges adjacent text nodes with same link', () => { @@ -196,7 +234,9 @@ describe('groupLinkNodes', () => { const node1 = textNode('hello ', [mark]); const node2 = textNode('world', [mark]); const result = groupLinkNodes([node1, node2]); - expect(result).toEqual([{ _key: 'group-link-0', nodes: [node1, node2], linkMark: mark }]); + expect(result).toEqual([ + { _key: 'group-link-0', nodes: [node1, node2], linkMark: mark }, + ]); }); it('separates text nodes with different links', () => { @@ -266,7 +306,7 @@ describe('isTableHeaderRow', () => { it('returns false for row with tableCell', () => { const row: SbRichTextNode = { type: 'tableRow', - content: [{ type: 'tableCell' }], + content: [{ type: 'tableCell', attrs: {} }], }; expect(isTableHeaderRow(row)).toBe(false); }); @@ -275,8 +315,8 @@ describe('isTableHeaderRow', () => { const row: SbRichTextNode = { type: 'tableRow', content: [ - { type: 'tableHeader' }, - { type: 'tableHeader' }, + { type: 'tableHeader', attrs: {} }, + { type: 'tableHeader', attrs: {} }, ], }; expect(isTableHeaderRow(row)).toBe(true); @@ -286,8 +326,8 @@ describe('isTableHeaderRow', () => { const row: SbRichTextNode = { type: 'tableRow', content: [ - { type: 'tableHeader' }, - { type: 'tableCell' }, + { type: 'tableHeader', attrs: {} }, + { type: 'tableCell', attrs: {} }, ], }; expect(isTableHeaderRow(row)).toBe(false); @@ -309,24 +349,33 @@ describe('splitTableRows', () => { it('returns all rows as body when no header rows', () => { const rows: SbRichTextNode[] = [ - { type: 'tableRow', content: [{ type: 'tableCell' }] }, - { type: 'tableRow', content: [{ type: 'tableCell' }] }, + { type: 'tableRow', content: [{ type: 'tableCell', attrs: {} }] }, + { type: 'tableRow', content: [{ type: 'tableCell', attrs: {} }] }, ]; expect(splitTableRows(rows)).toEqual({ headerRows: [], bodyRows: rows }); }); it('returns all rows as header when all are header rows', () => { const rows: SbRichTextNode[] = [ - { type: 'tableRow', content: [{ type: 'tableHeader' }] }, - { type: 'tableRow', content: [{ type: 'tableHeader' }] }, + { type: 'tableRow', content: [{ type: 'tableHeader', attrs: {} }] }, + { type: 'tableRow', content: [{ type: 'tableHeader', attrs: {} }] }, ]; expect(splitTableRows(rows)).toEqual({ headerRows: rows, bodyRows: [] }); }); it('splits header and body rows correctly', () => { - const headerRow: SbRichTextNode = { type: 'tableRow', content: [{ type: 'tableHeader' }] }; - const bodyRow1: SbRichTextNode = { type: 'tableRow', content: [{ type: 'tableCell' }] }; - const bodyRow2: SbRichTextNode = { type: 'tableRow', content: [{ type: 'tableCell' }] }; + const headerRow: SbRichTextNode = { + type: 'tableRow', + content: [{ type: 'tableHeader', attrs: {} }], + }; + const bodyRow1: SbRichTextNode = { + type: 'tableRow', + content: [{ type: 'tableCell', attrs: {} }], + }; + const bodyRow2: SbRichTextNode = { + type: 'tableRow', + content: [{ type: 'tableCell', attrs: {} }], + }; const rows = [headerRow, bodyRow1, bodyRow2]; expect(splitTableRows(rows)).toEqual({ headerRows: [headerRow], @@ -335,9 +384,18 @@ describe('splitTableRows', () => { }); it('only considers contiguous header rows at start', () => { - const headerRow1: SbRichTextNode = { type: 'tableRow', content: [{ type: 'tableHeader' }] }; - const bodyRow: SbRichTextNode = { type: 'tableRow', content: [{ type: 'tableCell' }] }; - const headerRow2: SbRichTextNode = { type: 'tableRow', content: [{ type: 'tableHeader' }] }; + const headerRow1: SbRichTextNode = { + type: 'tableRow', + content: [{ type: 'tableHeader', attrs: {} }], + }; + const bodyRow: SbRichTextNode = { + type: 'tableRow', + content: [{ type: 'tableCell', attrs: {} }], + }; + const headerRow2: SbRichTextNode = { + type: 'tableRow', + content: [{ type: 'tableHeader', attrs: {} }], + }; const rows = [headerRow1, bodyRow, headerRow2]; expect(splitTableRows(rows)).toEqual({ headerRows: [headerRow1], diff --git a/packages/richtext/src/static/types.type-test.ts b/packages/richtext/src/static/types.type-test.ts index 65571cf00..5c0533467 100644 --- a/packages/richtext/src/static/types.type-test.ts +++ b/packages/richtext/src/static/types.type-test.ts @@ -29,7 +29,7 @@ export const docWithoutContent: SbRichTextDoc = { export const docWithContentlessChildren: SbRichTextDoc = { type: 'doc', content: [ - { type: 'paragraph' }, + { type: 'paragraph', attrs: { textAlign: null } }, // no `content` here { type: 'horizontal_rule' }, { type: 'bullet_list', @@ -41,4 +41,5 @@ export const docWithContentlessChildren: SbRichTextDoc = { // A bare nested node without `content` is assignable to `SbRichTextNode`. export const contentlessNode: SbRichTextNode = { type: 'paragraph', + attrs: { textAlign: null }, }; diff --git a/packages/richtext/src/static/util.ts b/packages/richtext/src/static/util.ts index 3ab2cb156..66acc24c8 100644 --- a/packages/richtext/src/static/util.ts +++ b/packages/richtext/src/static/util.ts @@ -7,11 +7,13 @@ import { escapeAttr } from './attribute'; * @param node - The Richtext node or mark to resolve the tag for. * @returns The resolved HTML tag as a string, or null if no tag could be resolved. * @example - * const node = { type: 'paragraph', attrs: {} }; + * const node = { type: 'paragraph', attrs: { textAlign: null } }; * const tag = resolveTag(node); * console.log(tag); // Output: "p" */ -export function resolveTag(node: SbRichTextNode | SbRichTextMark): string | null { +export function resolveTag( + node: SbRichTextNode | SbRichTextMark, +): string | null { const type = node.type; const entry @@ -58,7 +60,8 @@ export function isSelfClosing(tag: string): boolean { */ export function getStaticChildren(node: SbRichTextNode) { const renderMap = NODE_RENDER_MAP[node.type as keyof typeof NODE_RENDER_MAP]; - const staticChildren = renderMap && 'children' in renderMap ? renderMap.children : null; + const staticChildren + = renderMap && 'children' in renderMap ? renderMap.children : null; return staticChildren; } diff --git a/packages/richtext/src/test-utils/helpers.ts b/packages/richtext/src/test-utils/helpers.ts index 74ba173fd..3d5a3af46 100644 --- a/packages/richtext/src/test-utils/helpers.ts +++ b/packages/richtext/src/test-utils/helpers.ts @@ -36,7 +36,7 @@ export const tableCell = ( marks?: SbRichTextMark[], ): SbRichTextNode => ({ type: 'tableCell', - content: [{ type: 'paragraph', content: [text(content, marks)] }], + content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [text(content, marks)] }], attrs: { colspan: attrs.colspan ?? 1, rowspan: attrs.rowspan ?? 1, @@ -47,7 +47,7 @@ export const tableCell = ( export const tableHeader = (content: string, marks?: SbRichTextMark[]): SbRichTextNode => ({ type: 'tableHeader', - content: [{ type: 'paragraph', content: [text(content, marks)] }], + content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [text(content, marks)] }], attrs: { colspan: 1, rowspan: 1 }, }); diff --git a/packages/richtext/src/test-utils/integration.ts b/packages/richtext/src/test-utils/integration.ts index 672c121b3..080759a03 100644 --- a/packages/richtext/src/test-utils/integration.ts +++ b/packages/richtext/src/test-utils/integration.ts @@ -26,7 +26,7 @@ export const integrationFixtures: HtmlFixture[] = [ { type: 'heading', attrs: { level: 4, textAlign: 'right' }, content: [text('Feature Heading', [{ type: 'highlight', attrs: { color: 'rgb(204, 255, 204)' } }, linkMark('/feature', { target: '_blank' })])] }, { type: 'paragraph', attrs: { textAlign: 'center' }, content: [text('Intro', [{ type: 'bold' }, { type: 'styled', attrs: { class: 'lead' } }])] }, { type: 'image', attrs: { id: 44, src: 'https://foo', alt: 'center', title: 'T', source: 'Z', copyright: null, meta_data: null } }, - { type: 'bullet_list', content: [{ type: 'list_item', content: [{ type: 'paragraph', content: [text('B')] }] }] }, + { type: 'bullet_list', content: [{ type: 'list_item', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [text('B')] }] }] }, ], expected: '

Feature Heading

Intro

center
  • B

', }, diff --git a/packages/richtext/src/test-utils/link.ts b/packages/richtext/src/test-utils/link.ts index dfaa74825..0d62b4439 100644 --- a/packages/richtext/src/test-utils/link.ts +++ b/packages/richtext/src/test-utils/link.ts @@ -34,6 +34,7 @@ export const linkFixtures: HtmlFixture[] = [ content: [ { type: 'paragraph', + attrs: { textAlign: null }, content: [ text('Hello ', [linkMark('/url')]), text('World', [linkMark('/url')]), @@ -49,6 +50,7 @@ export const linkFixtures: HtmlFixture[] = [ type: 'doc', content: [{ type: 'paragraph', + attrs: { textAlign: null }, content: [ text('Hello ', [linkMark('/a', { custom: { title: 'google' } })]), text('Storyblok', [linkMark('/a', { custom: { title: 'google' } })]), @@ -63,6 +65,7 @@ export const linkFixtures: HtmlFixture[] = [ type: 'doc', content: [{ type: 'paragraph', + attrs: { textAlign: null }, content: [ text('normal ', [linkMark('/url')]), text('bold', [{ type: 'bold' }, linkMark('/url')]), @@ -78,6 +81,7 @@ export const linkFixtures: HtmlFixture[] = [ type: 'doc', content: [{ type: 'paragraph', + attrs: { textAlign: null }, content: [ text('start ', [linkMark('/url')]), text('bold', [{ type: 'bold' }, linkMark('/url')]), @@ -95,6 +99,7 @@ export const linkFixtures: HtmlFixture[] = [ type: 'doc', content: [{ type: 'paragraph', + attrs: { textAlign: null }, content: [ text('Before ', [linkMark('/x')]), { type: 'hard_break' }, @@ -110,6 +115,7 @@ export const linkFixtures: HtmlFixture[] = [ type: 'doc', content: [{ type: 'paragraph', + attrs: { textAlign: null }, content: [ text('A', [linkMark('/a')]), text('B', [linkMark('/a', { target: '_blank' })]), @@ -124,6 +130,7 @@ export const linkFixtures: HtmlFixture[] = [ type: 'doc', content: [{ type: 'paragraph', + attrs: { textAlign: null }, content: [ text('A', [linkMark('/a', { custom: { title: 'google' } })]), text('B', [linkMark('/a', { custom: { title: 'new' } })]), diff --git a/packages/richtext/src/test-utils/marks.ts b/packages/richtext/src/test-utils/marks.ts index 69b44181d..fc24bac6d 100644 --- a/packages/richtext/src/test-utils/marks.ts +++ b/packages/richtext/src/test-utils/marks.ts @@ -39,8 +39,8 @@ export const markFixtures: HtmlFixture[] = [ }, { title: 'highlight mark', - input: text('Highlight', [{ type: 'highlight' }]), - expected: 'Highlight', // highlight is typically rendered as + input: text('Highlight', [{ type: 'highlight', attrs: { color: 'yellow' } }]), + expected: 'Highlight', // highlight is typically rendered as }, { title: 'anchor mark', diff --git a/packages/richtext/src/test-utils/nodes.ts b/packages/richtext/src/test-utils/nodes.ts index d0977e069..a51c6a5af 100644 --- a/packages/richtext/src/test-utils/nodes.ts +++ b/packages/richtext/src/test-utils/nodes.ts @@ -5,12 +5,12 @@ import type { HtmlFixture } from './types'; export const nodeFixtures: HtmlFixture[] = [ { title: 'paragraph', - input: { type: 'paragraph', content: [text('Hello')] }, + input: { type: 'paragraph', attrs: { textAlign: null }, content: [text('Hello')] }, expected: '

Hello

', }, { title: 'empty paragraph', - input: { type: 'paragraph', content: [] }, + input: { type: 'paragraph', attrs: { textAlign: null }, content: [] }, expected: '

', }, { @@ -25,17 +25,17 @@ export const nodeFixtures: HtmlFixture[] = [ }, { title: 'blockquote', - input: { type: 'blockquote', content: [{ type: 'paragraph', content: [text('Quote')] }] }, + input: { type: 'blockquote', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [text('Quote')] }] }, expected: '

Quote

', }, { title: 'bullet list', - input: { type: 'bullet_list', content: [{ type: 'list_item', content: [{ type: 'paragraph', content: [text('List')] }] }] }, + input: { type: 'bullet_list', content: [{ type: 'list_item', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [text('List')] }] }] }, expected: '
  • List

', }, { title: 'ordered list', - input: { type: 'ordered_list', attrs: { order: 5 }, content: [{ type: 'list_item', content: [{ type: 'paragraph', content: [text('Ordered')] }] }] }, + input: { type: 'ordered_list', attrs: { order: 5 }, content: [{ type: 'list_item', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [text('Ordered')] }] }] }, expected: '
  1. Ordered

', }, { @@ -72,8 +72,8 @@ export const nodeFixtures: HtmlFixture[] = [ { title: 'renders array of nodes', input: [ - { type: 'paragraph', content: [text('First')] }, - { type: 'paragraph', content: [text('Second')] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [text('First')] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [text('Second')] }, ], expected: '

First

Second

', }, @@ -82,7 +82,7 @@ export const nodeFixtures: HtmlFixture[] = [ input: { type: 'doc', content: [ - { type: 'paragraph', content: [text('Outer')] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [text('Outer')] }, // doc nested inside content — runtime edge case the renderer handles { type: 'doc', content: [{ type: 'paragraph', content: [text('Inner')] }] } as unknown as SbRichTextNode, ], diff --git a/packages/richtext/src/test-utils/tables.ts b/packages/richtext/src/test-utils/tables.ts index b313e2ca8..a99d5c581 100644 --- a/packages/richtext/src/test-utils/tables.ts +++ b/packages/richtext/src/test-utils/tables.ts @@ -50,15 +50,15 @@ export const tableFixtures: HtmlFixture[] = [ type: 'table', content: [ tableRow([ - { type: 'tableHeader', attrs: { colspan: 1, rowspan: 1, colwidth: [100] }, content: [{ type: 'paragraph', content: [text('THead')] }] }, + { type: 'tableHeader', attrs: { colspan: 1, rowspan: 1, colwidth: [100] }, content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [text('THead')] }] }, ]), tableRow([ { type: 'tableCell', attrs: { colspan: 1, rowspan: 1, backgroundColor: 'rgb(204, 255, 204)' }, content: [ - { type: 'paragraph', content: [text('cell', [linkMark('/c'), { type: 'superscript' }])] }, - { type: 'paragraph', content: [{ type: 'emoji', attrs: { fallbackImage: 'https://cdn/foo.png', name: 'plane', emoji: '✈️' } }] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [text('cell', [linkMark('/c'), { type: 'superscript' }])] }, + { type: 'paragraph', attrs: { textAlign: null }, content: [{ type: 'emoji', attrs: { fallbackImage: 'https://cdn/foo.png', name: 'plane', emoji: '✈️' } }] }, ], }, ]), From 288619b902180fef9ae117776ec80f74a52b1949 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 9 Jul 2026 13:12:40 +0530 Subject: [PATCH 05/10] fix(vue): resolve type error in rich-text-renderer by casting to Component Fixes DX-487 --- packages/vue/src/rich-text-renderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vue/src/rich-text-renderer.ts b/packages/vue/src/rich-text-renderer.ts index 891c99cd2..ded581158 100644 --- a/packages/vue/src/rich-text-renderer.ts +++ b/packages/vue/src/rich-text-renderer.ts @@ -119,7 +119,7 @@ function renderNode(node: SbRichTextNode, options: SbVueRichTextRenderContext, k const Custom = resolveComponentOverride(node.type, options.components); if (Custom) { - return h(Custom, { key, ...node, context: options }, node.content + return h(Custom as Component, { key, ...node, context: options }, node.content ? { default: () => renderChildren(node.content!, options), } From 2f97c23f50a0464d832eac7fa2ee48803b483464 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 9 Jul 2026 13:12:52 +0530 Subject: [PATCH 06/10] chore: add .prettierrc config --- .prettierrc | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..438006db6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "bracketSpacing": true, + "printWidth": 80, + "semi": true, + "singleQuote": true, + "trailingComma": "all" +} From 75d417c63bc058b31cf0bb98e4a1c53f2bcdc929 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 9 Jul 2026 13:33:42 +0530 Subject: [PATCH 07/10] chore(richtext): html parser test updated to match correct behaviour --- packages/richtext/src/extensions/marks.ts | 129 +++++++++++------ .../richtext/src/extensions/richtext-attrs.ts | 137 +++++------------- packages/richtext/src/html-parser.test.ts | 87 ++++++----- 3 files changed, 177 insertions(+), 176 deletions(-) diff --git a/packages/richtext/src/extensions/marks.ts b/packages/richtext/src/extensions/marks.ts index 786aee81f..591f15ad5 100644 --- a/packages/richtext/src/extensions/marks.ts +++ b/packages/richtext/src/extensions/marks.ts @@ -9,22 +9,27 @@ import Subscript from '@tiptap/extension-subscript'; import Superscript from '@tiptap/extension-superscript'; import Underline from '@tiptap/extension-underline'; -import { - mapToAttribute, - supportedAttributesByTagName, -} from './utils'; -import type { AnchorAttrs, ExtensionOptions, StyledAttrs, TextStyleAttrs } from './richtext-attrs'; +import { mapToAttribute, supportedAttributesByTagName } from './utils'; +import type { + AnchorAttrs, + ExtensionOptions, + StyledAttrs, + TextStyleAttrs, +} from './richtext-attrs'; export { Bold, Code, Italic, Strike, Subscript, Superscript, Underline }; -export function buildHighlightExtension(options?: ExtensionOptions<'highlight'>) { +export function buildHighlightExtension( + options?: ExtensionOptions<'highlight'>, +) { const parser = options?.attributeParsers; return Highlight.extend({ addAttributes() { return { color: { default: null, - parseHTML: parser?.color ?? mapToAttribute(undefined, 'color'), + parseHTML: + parser?.color ?? mapToAttribute(undefined, 'background-color'), }, }; }, @@ -32,7 +37,9 @@ export function buildHighlightExtension(options?: ExtensionOptions<'highlight'>) } const parseCustomLinkAttributes = (element: HTMLElement) => { const defaultLinkAttributes = supportedAttributesByTagName.a; - const customAttributeNames = element.getAttributeNames().filter(n => !defaultLinkAttributes.includes(n)); + const customAttributeNames = element + .getAttributeNames() + .filter(n => !defaultLinkAttributes.includes(n)); const customAttributes: Record = {}; for (const attributeName of customAttributeNames) { customAttributes[attributeName] = element.getAttribute(attributeName); @@ -79,12 +86,17 @@ export function buildAnchorExtension(options?: ExtensionOptions<'anchor'>) { name: 'anchor', addAttributes() { return { - id: { default: null, parseHTML: parser?.id ?? mapToAttribute(['id', 'data-id']) }, + id: { + default: null, + parseHTML: parser?.id ?? mapToAttribute(['id', 'data-id']), + }, }; }, - parseHTML: options?.parseHTML ?? (() => { - return [{ tag: 'span[id]' }]; - }), + parseHTML: + options?.parseHTML + ?? (() => { + return [{ tag: 'span[id]' }]; + }), renderHTML({ HTMLAttributes }) { const { id } = HTMLAttributes as AnchorAttrs; return ['span', { id }, 0]; @@ -96,49 +108,69 @@ export function buildStyledExtension(options?: ExtensionOptions<'textStyle'>) { const parser = options?.attributeParsers; return Mark.create({ name: 'styled', - parseHTML: options?.parseHTML ?? (() => { - return [{ tag: 'span', consuming: false, getAttrs: (element: HTMLElement) => { - // Only match spans with inline style containing color - const className = element.getAttribute('class'); - if (className) { - return null; - } - return false; - } }]; - }), + parseHTML: + options?.parseHTML + ?? (() => { + return [ + { + tag: 'span', + consuming: false, + getAttrs: (element: HTMLElement) => { + // Only match spans with inline style containing color + const className = element.getAttribute('class'); + if (className) { + return null; + } + return false; + }, + }, + ]; + }), addAttributes() { return { - class: { default: null, parseHTML: parser?.class ?? mapToAttribute('class') }, + class: { + default: null, + parseHTML: parser?.class ?? mapToAttribute('class'), + }, }; }, renderHTML({ HTMLAttributes }) { const { class: className } = HTMLAttributes as StyledAttrs; - return [ - 'span', - { class: className }, - 0, - ]; + return ['span', { class: className }, 0]; }, }); } -export function buildTextStyleExtension(options?: ExtensionOptions<'textStyle'>) { +export function buildTextStyleExtension( + options?: ExtensionOptions<'textStyle'>, +) { const parser = options?.attributeParsers; return Mark.create({ name: 'textStyle', - parseHTML: options?.parseHTML ?? (() => { - return [{ tag: 'span', consuming: false, getAttrs: (element: HTMLElement) => { - // Only match spans with inline style containing color - const style = element.getAttribute('style'); - if (style && /color/i.test(style)) { - return null; - } - return false; - } }]; - }), + parseHTML: + options?.parseHTML + ?? (() => { + return [ + { + tag: 'span', + consuming: false, + getAttrs: (element: HTMLElement) => { + // Only match spans with inline style containing color + const style = element.getAttribute('style'); + if (style && /color/i.test(style)) { + return null; + } + return false; + }, + }, + ]; + }), addAttributes() { return { - color: { default: null, parseHTML: parser?.color ?? mapToAttribute(undefined, 'color') }, + color: { + default: null, + parseHTML: parser?.color ?? mapToAttribute(undefined, 'color'), + }, }; }, renderHTML({ HTMLAttributes }) { @@ -178,12 +210,19 @@ export const Reporter = Mark.create({ return false; } - const unsupportedAttributes = element.getAttributeNames().filter((attr) => { - const supportedAttrs = tagName in supportedAttributesByTagName ? supportedAttributesByTagName[tagName] : []; - return !supportedAttrs.includes(attr); - }); + const unsupportedAttributes = element + .getAttributeNames() + .filter((attr) => { + const supportedAttrs + = tagName in supportedAttributesByTagName + ? supportedAttributesByTagName[tagName] + : []; + return !supportedAttrs.includes(attr); + }); for (const attr of unsupportedAttributes) { - console.warn(`[StoryblokRichText] - \`${attr}\` "${element.getAttribute(attr)}" on \`<${tagName}>\` can not be transformed to rich text.`); + console.warn( + `[StoryblokRichText] - \`${attr}\` "${element.getAttribute(attr)}" on \`<${tagName}>\` can not be transformed to rich text.`, + ); } return false; diff --git a/packages/richtext/src/extensions/richtext-attrs.ts b/packages/richtext/src/extensions/richtext-attrs.ts index 098e3b9fe..888f6ca35 100644 --- a/packages/richtext/src/extensions/richtext-attrs.ts +++ b/packages/richtext/src/extensions/richtext-attrs.ts @@ -1,110 +1,53 @@ import type { MarkSpec, NodeSpec } from 'prosemirror-model'; import type { SbBlokData } from '../static/types'; import type { TiptapMarkName, TiptapNodeName } from '../static/types.generated'; +import type { + RichtextFieldValueAnchorMark, + RichtextFieldValueBlokNode, + RichtextFieldValueCodeBlockNode, + RichtextFieldValueEmojiNode, + RichtextFieldValueHeadingNode, + RichtextFieldValueHighlightMark, + RichtextFieldValueImageNode, + RichtextFieldValueLinkMark, + RichtextFieldValueOrderedListNode, + RichtextFieldValueParagraphNode, + RichtextFieldValueStyledMark, + RichtextFieldValueTableCellNode, + RichtextFieldValueTableHeaderNode, + RichtextFieldValueTextStyleMark, +} from '../generated/overlay/types.gen'; /** For node and mark that do not have any attribute support */ export type NoAttrs = Record; -/** Node Attributes */ - -export interface ParagraphAttrs { - textAlign: 'left' | 'center' | 'right' | 'justify' | null; - [key: string]: unknown; -} - -export interface HeadingAttrs { - textAlign: 'left' | 'center' | 'right' | 'justify' | null; - level?: 1 | 2 | 3 | 4 | 5 | 6; - [key: string]: unknown; -} - -export interface CodeBlockAttrs { - class: string | null; - [key: string]: unknown; -} - -export interface OrderedListAttrs { - order?: number; - [key: string]: unknown; -} - -export interface TableCellAttrs { - colspan?: number; - rowspan?: number; - colwidth?: number[] | null; - backgroundColor?: string | null; - [key: string]: unknown; -} - -export interface TableHeaderAttrs { - colspan?: number; - rowspan?: number; - colwidth?: number[] | null; - [key: string]: unknown; -} - -export interface ImageAttrs { - id: number | null; - alt: string | null; - src: string; - title: string | null; - source: string | null; - copyright: string | null; - meta_data: { - alt: string | null; - title: string | null; - source: string | null; - copyright: string | null; - } | null; - [key: string]: unknown; -} - -export interface EmojiAttrs { - name: string; - emoji: string; - fallbackImage: string; - [key: string]: unknown; -} - -export interface BlokAttrs { - id: string | null; +/** Node Attributes — derived from OpenAPI-generated types so they stay in sync automatically. */ + +export type ParagraphAttrs = RichtextFieldValueParagraphNode['attrs'] & Record; +export type HeadingAttrs = RichtextFieldValueHeadingNode['attrs'] & Record; +export type CodeBlockAttrs = RichtextFieldValueCodeBlockNode['attrs'] & Record; +export type OrderedListAttrs = RichtextFieldValueOrderedListNode['attrs'] & Record; +export type TableCellAttrs = RichtextFieldValueTableCellNode['attrs'] & Record; +export type TableHeaderAttrs = RichtextFieldValueTableHeaderNode['attrs'] & Record; +export type ImageAttrs = RichtextFieldValueImageNode['attrs'] & Record; +export type EmojiAttrs = RichtextFieldValueEmojiNode['attrs'] & Record; + +/** + * BlokAttrs keeps `body` typed as `SbBlokData[]` (the repo-wide blok type) rather + * than the inline shape from the generated spec, which is structurally equivalent + * but less convenient to work with. + */ +export type BlokAttrs = Omit & { body: SbBlokData[] | null; - [key: string]: unknown; -} +} & Record; -/** Mark Attributes */ +/** Mark Attributes — derived from OpenAPI-generated types. */ -export interface LinkAttrs { - href: string | null; - uuid: string | null; - anchor: string | null; - target: '_self' | '_blank' | '_parent' | '_top' | null; - linktype: 'story' | 'url' | 'email' | 'asset' | null; - custom?: Record; - [key: string]: unknown; -} - -export interface HighlightAttrs { - color: string; - [key: string]: unknown; -} - -export interface TextStyleAttrs { - color?: string | null; - id?: string | null; - class?: string | null; - [key: string]: unknown; -} - -export interface AnchorAttrs { - id: string; - [key: string]: unknown; -} - -export interface StyledAttrs { - class: string | null; - [key: string]: unknown; -} +export type LinkAttrs = RichtextFieldValueLinkMark['attrs'] & Record; +export type HighlightAttrs = RichtextFieldValueHighlightMark['attrs'] & Record; +export type TextStyleAttrs = RichtextFieldValueTextStyleMark['attrs'] & Record; +export type AnchorAttrs = RichtextFieldValueAnchorMark['attrs'] & Record; +export type StyledAttrs = RichtextFieldValueStyledMark['attrs'] & Record; /** Attribute Maps */ diff --git a/packages/richtext/src/html-parser.test.ts b/packages/richtext/src/html-parser.test.ts index 11d9b4bb2..2933fb82c 100644 --- a/packages/richtext/src/html-parser.test.ts +++ b/packages/richtext/src/html-parser.test.ts @@ -3,17 +3,26 @@ import { htmlToStoryblokRichtext } from './html-parser'; import { renderRichText } from './render-richtext'; import { mapToAttribute } from './extensions/utils'; import { doc } from './test-utils/helpers'; -import { linkFixtures, markFixtures, nodeFixtures, tableFixtures } from './test-utils'; +import { + linkFixtures, + markFixtures, + nodeFixtures, + tableFixtures, +} from './test-utils'; describe('hTML → Richtext (strict): Input handling', () => { it('returns doc with empty paragraph for empty string', () => { const document = htmlToStoryblokRichtext(''); - expect(document).toMatchObject(doc({ type: 'paragraph' })); + expect(document).toMatchObject( + doc({ type: 'paragraph', attrs: { textAlign: null } }), + ); }); it('returns doc with empty paragraph for whitespace-only input', () => { const document = htmlToStoryblokRichtext(' \n\t '); - expect(document).toMatchObject(doc({ type: 'paragraph' })); + expect(document).toMatchObject( + doc({ type: 'paragraph', attrs: { textAlign: null } }), + ); }); }); @@ -66,7 +75,8 @@ describe('hTML → Richtext (strict): Table', () => { describe('hTML → Richtext (strict): Custom parsers', () => { it('parses emoji with custom extension', () => { - const html = '
🚀
'; + const html + = '
🚀
'; const result = htmlToStoryblokRichtext(html, { parsers: { emoji: { @@ -82,22 +92,27 @@ describe('hTML → Richtext (strict): Custom parsers', () => { }, }, }); - expect(result).toEqual(doc({ - type: 'paragraph', - attrs: { textAlign: null }, - content: [{ - type: 'emoji', - attrs: { - name: 'rocket', - emoji: '🚀', - fallbackImage: 'https://cdn.example.com/rocket.png', - }, - }], - })); + expect(result).toEqual( + doc({ + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { + type: 'emoji', + attrs: { + name: 'rocket', + emoji: '🚀', + fallbackImage: 'https://cdn.example.com/rocket.png', + }, + }, + ], + }), + ); }); it('parses code block with custom language extraction', () => { - const html = '
const greeting: string = "Hello";
'; + const html + = '
const greeting: string = "Hello";
'; const result = htmlToStoryblokRichtext(html, { parsers: { code_block: { @@ -112,11 +127,13 @@ describe('hTML → Richtext (strict): Custom parsers', () => { }, }, }); - expect(result).toEqual(doc({ - type: 'code_block', - attrs: { class: 'typescript' }, - content: [{ type: 'text', text: 'const greeting: string = "Hello";' }], - })); + expect(result).toEqual( + doc({ + type: 'code_block', + attrs: { class: 'typescript' }, + content: [{ type: 'text', text: 'const greeting: string = "Hello";' }], + }), + ); }); it('parses image with custom meta_data extraction', () => { @@ -143,22 +160,24 @@ describe('hTML → Richtext (strict): Custom parsers', () => { }, }, }); - expect(result).toEqual(doc({ - type: 'image', - attrs: { - id: null, - src: 'https://a.storyblok.com/f/12345/800x600/image.jpg', - alt: 'A beautiful landscape', - title: 'Mountain View', - source: 'Unsplash', - copyright: '© 2024 Photographer Name', - meta_data: { + expect(result).toEqual( + doc({ + type: 'image', + attrs: { + id: null, + src: 'https://a.storyblok.com/f/12345/800x600/image.jpg', alt: 'A beautiful landscape', title: 'Mountain View', source: 'Unsplash', copyright: '© 2024 Photographer Name', + meta_data: { + alt: 'A beautiful landscape', + title: 'Mountain View', + source: 'Unsplash', + copyright: '© 2024 Photographer Name', + }, }, - }, - })); + }), + ); }); }); From b6b04e1172760cff07b3a91063b46e5615750fde Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 9 Jul 2026 13:35:24 +0530 Subject: [PATCH 08/10] chore: migrate pnpm overrides from package.json to pnpm-workspace.yaml pnpm v10 no longer reads the "pnpm" field in package.json. Move overrides to pnpm-workspace.yaml as required by the new config. --- package.json | 5 ----- pnpm-workspace.yaml | 2 ++ 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5456f9220..a35870473 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,6 @@ "resolutions": { "cypress": "^14.3.3" }, - "pnpm": { - "overrides": { - "happy-dom": "^20.8.8" - } - }, "dependencies": { "cypress": "^14.3.3" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 00729571a..c0b47188f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,5 @@ packages: onlyBuiltDependencies: - cypress - esbuild +overrides: + happy-dom: "^20.8.8" From cc5a9efb142a525186e86b27e349cf72d005fa7f Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 9 Jul 2026 13:45:15 +0530 Subject: [PATCH 09/10] feat(cli): add OpenAPI-derived richtext type generation Add scripts/generate.ts and commit src/generated/overlay/types.gen.ts mirroring the same setup as @storyblok/richtext. Includes the generate:openapi nx target and @storyblok/openapi-codegen devDep. --- packages/cli/package.json | 27 ++ packages/cli/scripts/generate.ts | 25 ++ .../cli/src/generated/overlay/types.gen.ts | 330 ++++++++++++++++++ pnpm-lock.yaml | 12 +- 4 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 packages/cli/scripts/generate.ts create mode 100644 packages/cli/src/generated/overlay/types.gen.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index d1590e383..ef4721aac 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,6 +36,7 @@ "dist/**" ], "scripts": { + "generate:openapi": "node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/generate.ts", "build": "unbuild", "release": "release-it", "dev": "pnpm run build && node dist/index.mjs", @@ -47,6 +48,31 @@ "test:ui": "vitest --ui", "coverage": "vitest run --coverage" }, + "nx": { + "targets": { + "generate:openapi": { + "inputs": [ + "{projectRoot}/scripts/generate.ts", + "openapiCodegen" + ], + "outputs": [ + "{projectRoot}/src/generated" + ], + "cache": true, + "dependsOn": [ + "@storyblok/openapi-codegen:build" + ] + }, + "build": { + "dependsOn": [ + "^build" + ], + "outputs": [ + "{projectRoot}/dist/**" + ] + } + } + }, "dependencies": { "@inquirer/prompts": "^7.5.1", "@storyblok/js": "workspace:*", @@ -72,6 +98,7 @@ "devDependencies": { "@release-it/conventional-changelog": "10.0.0", "@storyblok/eslint-config": "workspace:*", + "@storyblok/openapi-codegen": "workspace:*", "@types/cli-progress": "^3.11.6", "@types/inquirer": "^9.0.8", "@types/node": "^24.11.0", diff --git a/packages/cli/scripts/generate.ts b/packages/cli/scripts/generate.ts new file mode 100644 index 000000000..a32b25c42 --- /dev/null +++ b/packages/cli/scripts/generate.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env -S node --experimental-strip-types --no-warnings=ExperimentalWarning +/** + * Generates `storyblok` CLI's OpenAPI-derived richtext types from the pinned + * overlay spec cache. Produces `RichtextDoc`, `RichTextNode`, and + * `RichTextMark` in `src/generated/` — the types for the richtext document + * format consumed by story content. + * + * Re-run after `pnpm --filter @storyblok/openapi-codegen pull[:update]`. + */ + +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generate } from '@storyblok/openapi-codegen'; +import { execSync } from 'node:child_process'; + +const PKG_ROOT = resolve(fileURLToPath(import.meta.url), '../..'); + +await generate({ + outDir: resolve(PKG_ROOT, 'src/generated'), + include: ['RichtextDoc', 'RichTextNode', 'RichTextMark'], +}); +const GENERATED_PATH = resolve(PKG_ROOT, 'src/generated'); +execSync(`pnpm eslint ${GENERATED_PATH} --fix`, { + stdio: 'inherit', +}); diff --git a/packages/cli/src/generated/overlay/types.gen.ts b/packages/cli/src/generated/overlay/types.gen.ts new file mode 100644 index 000000000..1ebca18db --- /dev/null +++ b/packages/cli/src/generated/overlay/types.gen.ts @@ -0,0 +1,330 @@ +// Generated by @storyblok/openapi-codegen. Do not edit by hand. + +export type RichtextDoc = RichtextFieldValueRoot; + +export type RichTextNode = RichtextFieldValueRichTextNode; + +export type RichTextMark = RichtextFieldValueRichTextMark; + +/** + * Richtext field type - structured rich text document (ProseMirror/Tiptap format) + */ +export interface RichtextFieldValueRoot { + /** + * Root node type — always "doc" + */ + type: 'doc'; + /** + * Top-level richtext nodes + */ + content: Array; +} + +/** + * A richtext document node + */ +export type RichtextFieldValueRichTextNode = RichtextFieldValueParagraphNode | RichtextFieldValueTextNode | RichtextFieldValueHeadingNode | RichtextFieldValueBlockquoteNode | RichtextFieldValueBulletListNode | RichtextFieldValueOrderedListNode | RichtextFieldValueListItemNode | RichtextFieldValueCodeBlockNode | RichtextFieldValueHardBreakNode | RichtextFieldValueHorizontalRuleNode | RichtextFieldValueImageNode | RichtextFieldValueEmojiNode | RichtextFieldValueTableNode | RichtextFieldValueTableRowNode | RichtextFieldValueTableCellNode | RichtextFieldValueTableHeaderNode | RichtextFieldValueBlokNode; + +/** + * Inline formatting mark applied to a text node + */ +export type RichtextFieldValueRichTextMark = RichtextFieldValueLinkMark | RichtextFieldValueBoldMark | RichtextFieldValueItalicMark | RichtextFieldValueStrikeMark | RichtextFieldValueUnderlineMark | RichtextFieldValueCodeMark | RichtextFieldValueSuperscriptMark | RichtextFieldValueSubscriptMark | RichtextFieldValueHighlightMark | RichtextFieldValueTextStyleMark | RichtextFieldValueAnchorMark | RichtextFieldValueStyledMark; + +export interface RichtextFieldValueParagraphNode { + type: 'paragraph'; + attrs: { + /** + * Text alignment + */ + textAlign: 'left' | 'center' | 'right' | 'justify' | null; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueTextNode { + type: 'text'; + /** + * The text content + */ + text: string; + marks?: Array; +} + +export interface RichtextFieldValueHeadingNode { + type: 'heading'; + attrs: { + /** + * Heading level (h1–h6) + */ + level?: 1 | 2 | 3 | 4 | 5 | 6 | null; + /** + * Text alignment + */ + textAlign: 'left' | 'center' | 'right' | 'justify' | null; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueBlockquoteNode { + type: 'blockquote'; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueBulletListNode { + type: 'bullet_list'; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueOrderedListNode { + type: 'ordered_list'; + attrs: { + /** + * Starting number for the ordered list + */ + order?: number; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueListItemNode { + type: 'list_item'; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueCodeBlockNode { + type: 'code_block'; + attrs: { + /** + * Language class (e.g. "language-typescript") + */ + class: string | null; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueHardBreakNode { + type: 'hard_break'; + marks?: Array; +} + +export interface RichtextFieldValueHorizontalRuleNode { + type: 'horizontal_rule'; +} + +export interface RichtextFieldValueImageNode { + type: 'image'; + attrs: { + /** + * Storyblok asset ID + */ + id: number | null; + /** + * Image URL + */ + src: string; + /** + * Alternative text + */ + alt: string | null; + title: string | null; + source: string | null; + copyright: string | null; + /** + * Asset metadata + */ + meta_data: { + alt: string | null; + title: string | null; + source: string | null; + copyright: string | null; + } | null; + }; + marks?: Array; +} + +export interface RichtextFieldValueEmojiNode { + type: 'emoji'; + attrs: { + /** + * Emoji name/slug + */ + name: string; + /** + * Emoji character + */ + emoji: string; + /** + * URL to a fallback image for unsupported environments + */ + fallbackImage: string; + }; + content?: Array; + marks?: Array; +} + +export interface RichtextFieldValueTableNode { + type: 'table'; + content?: Array; +} + +export interface RichtextFieldValueTableRowNode { + type: 'tableRow'; + content?: Array; +} + +export interface RichtextFieldValueTableCellNode { + type: 'tableCell'; + attrs: { + colspan?: number; + rowspan?: number; + /** + * Column widths in pixels + */ + colwidth?: Array | null; + backgroundColor?: string | null; + }; + content?: Array; +} + +export interface RichtextFieldValueTableHeaderNode { + type: 'tableHeader'; + attrs: { + colspan?: number; + rowspan?: number; + /** + * Column widths in pixels + */ + colwidth?: Array | null; + }; + content?: Array; +} + +export interface RichtextFieldValueBlokNode { + type: 'blok'; + attrs: { + /** + * Blok instance ID + */ + id: string | null; + /** + * Array of embedded component instances + */ + body: Array<{ + _uid: string; + component: string; + _editable?: string; + [key: string]: string | number | boolean | { + [key: string]: unknown; + } | string | undefined; + }> | null; + }; +} + +export interface RichtextFieldValueLinkMark { + type: 'link'; + /** + * Link attributes + */ + attrs: { + /** + * Link URL + */ + href: string | null; + /** + * UUID of the linked story (for internal links) + */ + uuid: string | null; + /** + * Anchor/fragment identifier + */ + anchor: string | null; + /** + * Link target attribute + */ + target: '_self' | '_blank' | '_parent' | '_top' | null; + /** + * Type of link + */ + linktype: 'story' | 'url' | 'email' | 'asset' | null; + /** + * Custom link attributes + */ + custom?: { + [key: string]: unknown; + }; + }; +} + +export interface RichtextFieldValueBoldMark { + type: 'bold'; +} + +export interface RichtextFieldValueItalicMark { + type: 'italic'; +} + +export interface RichtextFieldValueStrikeMark { + type: 'strike'; +} + +export interface RichtextFieldValueUnderlineMark { + type: 'underline'; +} + +export interface RichtextFieldValueCodeMark { + type: 'code'; +} + +export interface RichtextFieldValueSuperscriptMark { + type: 'superscript'; +} + +export interface RichtextFieldValueSubscriptMark { + type: 'subscript'; +} + +export interface RichtextFieldValueHighlightMark { + type: 'highlight'; + attrs: { + /** + * Highlight color (CSS color value) + */ + color: string; + }; +} + +export interface RichtextFieldValueTextStyleMark { + type: 'textStyle'; + attrs: { + color?: string | null; + id?: string | null; + class?: string | null; + }; +} + +export interface RichtextFieldValueAnchorMark { + type: 'anchor'; + attrs: { + /** + * Anchor identifier + */ + id: string; + }; +} + +export interface RichtextFieldValueStyledMark { + type: 'styled'; + attrs: { + /** + * CSS class name + */ + class: string | null; + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a5326999..a517b35c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,6 @@ settings: overrides: cypress: ^14.3.3 - happy-dom: ^20.8.8 importers: @@ -400,6 +399,9 @@ importers: '@storyblok/eslint-config': specifier: workspace:* version: link:../eslint-config + '@storyblok/openapi-codegen': + specifier: workspace:* + version: link:../../tools/openapi-codegen '@types/cli-progress': specifier: ^3.11.6 version: 3.11.6 @@ -5089,7 +5091,7 @@ packages: '@testing-library/vue': ^7.0.0 || ^8.0.1 '@vitest/ui': '*' '@vue/test-utils': ^2.4.2 - happy-dom: ^20.8.8 + happy-dom: '*' jsdom: '*' playwright-core: ^1.43.1 vitest: ^3.2.0 @@ -7195,7 +7197,7 @@ packages: peerDependencies: '@tiptap/core': ^3.22.3 '@tiptap/pm': ^3.22.3 - happy-dom: ^20.8.8 + happy-dom: ^20.8.9 '@tiptap/pm@3.22.3': resolution: {integrity: sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==} @@ -15958,7 +15960,7 @@ packages: '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 '@vitest/browser': 3.2.4 '@vitest/ui': 3.2.4 - happy-dom: ^20.8.8 + happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': @@ -15988,7 +15990,7 @@ packages: '@vitest/browser-preview': 4.0.18 '@vitest/browser-webdriverio': 4.0.18 '@vitest/ui': 4.0.18 - happy-dom: ^20.8.8 + happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': From 68b2d5d6f3522445bbaa60bc357c52788b1c7dcd Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 9 Jul 2026 13:45:50 +0530 Subject: [PATCH 10/10] refactor(cli): replace hand-written StoryblokRichtext with generated type Drop the loose hand-written interface and re-export RichtextDoc (and RichTextNode, RichTextMark) from src/generated/overlay/types.gen.ts. StoryblokRichtext is kept as a backward-compatible alias for RichtextDoc. --- packages/cli/src/types/storyblok.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/types/storyblok.ts b/packages/cli/src/types/storyblok.ts index 5a2ca14a6..012be11ed 100644 --- a/packages/cli/src/types/storyblok.ts +++ b/packages/cli/src/types/storyblok.ts @@ -125,10 +125,7 @@ export interface StoryblokTable { }>; } -export interface StoryblokRichtext { - type: string; - content?: StoryblokRichtext[]; - marks?: StoryblokRichtext[]; - attrs?: Record; - text?: string; -} +export type { RichtextDoc, RichTextMark, RichTextNode } from '../generated/overlay/types.gen'; + +// Backward-compatible alias +export type { RichtextDoc as StoryblokRichtext } from '../generated/overlay/types.gen';