@uniweb/content-reader 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,25 +17,116 @@ A JavaScript library for converting Markdown content into ProseMirror-compatible
17
17
 
18
18
  ### Extended Syntax
19
19
 
20
- - **Enhanced Images**: Support for image roles (content, background, icon, gallery) using prefix syntax:
20
+ #### Curly Brace Attributes
21
21
 
22
- ```markdown
23
- ![Alt text](icon:path/to/icon.svg)
24
- ![Alt text](background:path/to/bg.jpg)
25
- ```
22
+ Add rich attributes to images and links using `{...}` syntax:
26
23
 
27
- - **Enhanced Links**: Button variants with predefined styles:
24
+ ```markdown
25
+ ![Alt](./image.jpg){role=hero width=800 loading=lazy}
26
+ [Link](https://example.com){target=_blank rel=noopener}
27
+ [Button](https://example.com){.button variant=secondary icon=arrow}
28
+ ```
29
+
30
+ **Supported attribute formats:**
31
+ - `key=value` - Standard attribute
32
+ - `key="value with spaces"` - Quoted value
33
+ - `.className` - CSS class (multiple allowed)
34
+ - `#idName` - Element ID
35
+ - `booleanAttr` - Boolean attribute (sets to `true`)
36
+
37
+ #### Image Attributes
38
+
39
+ ```markdown
40
+ # Basic image with role and dimensions
41
+ ![Hero](./hero.jpg){role=hero width=1200 height=600}
42
+
43
+ # Video with poster and playback options
44
+ ![Intro Video](./intro.mp4){role=video poster=./poster.jpg autoplay muted loop}
45
+
46
+ # PDF with preview thumbnail
47
+ ![User Guide](./guide.pdf){role=pdf preview=./preview.jpg}
48
+
49
+ # Styling attributes
50
+ ![Background](./bg.jpg){fit=cover position=center loading=lazy}
51
+
52
+ # Classes and IDs
53
+ ![Logo](./logo.svg){.featured .rounded #main-logo}
54
+ ```
55
+
56
+ | Attribute | Description |
57
+ |-----------|-------------|
58
+ | `role` | Semantic role: `image`, `icon`, `hero`, `video`, `pdf`, etc. |
59
+ | `width`, `height` | Dimensions (pixels) |
60
+ | `loading` | Loading behavior: `lazy`, `eager` |
61
+ | `poster` | Poster image for videos |
62
+ | `preview` | Preview image for PDFs/documents |
63
+ | `autoplay`, `muted`, `loop`, `controls` | Video playback options |
64
+ | `fit` | Object-fit: `cover`, `contain`, `fill`, etc. |
65
+ | `position` | Object-position value |
66
+ | `.class`, `#id` | CSS class and ID |
67
+
68
+ #### Link Attributes
69
+
70
+ ```markdown
71
+ # External link with target
72
+ [External Link](https://example.com){target=_blank rel="noopener noreferrer"}
73
+
74
+ # Download link
75
+ [Download PDF](./document.pdf){download}
76
+
77
+ # Link with custom filename for download
78
+ [Get Report](./data.pdf){download="annual-report.pdf"}
79
+ ```
80
+
81
+ | Attribute | Description |
82
+ |-----------|-------------|
83
+ | `target` | Link target: `_blank`, `_self`, etc. |
84
+ | `rel` | Link relationship: `noopener`, `noreferrer`, etc. |
85
+ | `download` | Download attribute (boolean or filename) |
86
+ | `.class` | CSS class |
87
+
88
+ #### Button Attributes
89
+
90
+ Buttons can be created using the `.button` class or the legacy `button:` prefix:
91
+
92
+ ```markdown
93
+ # Using .button class (recommended)
94
+ [Get Started](https://example.com){.button variant=primary size=lg}
95
+ [Learn More](https://example.com){.button variant=secondary icon=arrow-right}
96
+
97
+ # Legacy prefix syntax (still supported)
98
+ [Button Text](button:https://example.com)
99
+ ```
28
100
 
29
- ```markdown
30
- [Button Text](button:https://example.com)
31
- ```
101
+ | Attribute | Description |
102
+ |-----------|-------------|
103
+ | `variant` | Style variant: `primary`, `secondary`, `outline`, `ghost` |
104
+ | `size` | Button size: `sm`, `md`, `lg` |
105
+ | `icon` | Icon name or path |
106
+ | `target`, `rel`, `download` | Same as links |
32
107
 
33
- - **Tables with Alignment**: Full support for aligned columns:
34
- ```markdown
35
- | Left | Center | Right |
36
- | :--- | :----: | ----: |
37
- | Text | Text | Text |
38
- ```
108
+ #### Legacy Prefix Syntax
109
+
110
+ The original prefix syntax is still supported for backward compatibility:
111
+
112
+ ```markdown
113
+ # Image with role prefix
114
+ ![Alt text](icon:path/to/icon.svg)
115
+ ![Alt text](hero:path/to/bg.jpg)
116
+
117
+ # Button with prefix
118
+ [Button Text](button:https://example.com)
119
+ ```
120
+
121
+ #### Tables with Alignment
122
+
123
+ Full support for aligned columns:
124
+
125
+ ```markdown
126
+ | Left | Center | Right |
127
+ | :--- | :----: | ----: |
128
+ | Text | Text | Text |
129
+ ```
39
130
 
40
131
  ### Developer-Friendly Features
41
132
 
@@ -48,7 +139,7 @@ A JavaScript library for converting Markdown content into ProseMirror-compatible
48
139
  ## Installation
49
140
 
50
141
  ```bash
51
- npm install @uniwebcms/content-reader
142
+ npm install @uniweb/content-reader
52
143
  ```
53
144
 
54
145
  ## Usage
@@ -56,7 +147,7 @@ npm install @uniwebcms/content-reader
56
147
  Basic usage:
57
148
 
58
149
  ```javascript
59
- const { markdownToProseMirror } = require("@uniwebcms/content-reader");
150
+ const { markdownToProseMirror } = require("@uniweb/content-reader");
60
151
 
61
152
  const markdown = `
62
153
  # Hello World
@@ -77,7 +168,7 @@ The library is designed to work seamlessly with TipTap editors:
77
168
 
78
169
  ```javascript
79
170
  import { Editor } from "@tiptap/core";
80
- import { markdownToProseMirror } from "@uniwebcms/content-reader";
171
+ import { markdownToProseMirror } from "@uniweb/content-reader";
81
172
 
82
173
  const editor = new Editor({
83
174
  content: markdownToProseMirror(markdown),
@@ -87,19 +178,34 @@ const editor = new Editor({
87
178
 
88
179
  ### Advanced Features
89
180
 
90
- #### Working with Image Roles
181
+ #### Working with Rich Media
182
+
183
+ The library supports extended syntax for images, videos, and documents:
184
+
185
+ ```javascript
186
+ const markdown = `
187
+ ![Hero Banner](./hero.jpg){role=hero width=1200 fit=cover}
188
+ ![Intro Video](./intro.mp4){role=video poster=./poster.jpg autoplay muted}
189
+ ![Documentation](./guide.pdf){role=pdf preview=./preview.jpg}
190
+ `;
191
+
192
+ const doc = markdownToProseMirror(markdown);
193
+ // Each media element will have rich attributes for component rendering
194
+ ```
195
+
196
+ #### Working with Buttons and Links
91
197
 
92
- The library supports extended image syntax for different display contexts:
198
+ Create styled buttons and links with attributes:
93
199
 
94
200
  ```javascript
95
201
  const markdown = `
96
- ![Header image](background:header.jpg)
97
- ![Profile photo](gallery:profile.jpg)
98
- ![Settings](icon:settings.svg)
202
+ [Get Started](https://example.com){.button variant=primary size=lg}
203
+ [Download](./file.pdf){download}
204
+ [External](https://example.com){target=_blank rel=noopener}
99
205
  `;
100
206
 
101
207
  const doc = markdownToProseMirror(markdown);
102
- // Each image will have a 'role' attribute in its output structure
208
+ // Links and buttons will have appropriate attributes for rendering
103
209
  ```
104
210
 
105
211
  #### Handling Tables with Alignment
@@ -137,7 +243,7 @@ We welcome contributions! Please see our contributing guidelines for details.
137
243
  1. Clone the repository:
138
244
 
139
245
  ```bash
140
- git clone https://github.com/uniwebcms/content-reader.git
246
+ git clone https://github.com/uniweb/content-reader.git
141
247
  ```
142
248
 
143
249
  2. Install dependencies:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/content-reader",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Markdown to ProseMirror document structure converter",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -7,6 +7,10 @@ import { marked } from "marked";
7
7
  import { parseMarkdownContent } from "./parser/index.js";
8
8
  import { getBaseSchema } from "./schema/index.js";
9
9
  import { isValidUniwebMarkdown } from "./utils.js";
10
+ import { getMarkedExtensions } from "./parser/marked-extensions.js";
11
+
12
+ // Configure marked with our custom extensions for attribute syntax
13
+ marked.use(getMarkedExtensions());
10
14
 
11
15
  /**
12
16
  * Convert markdown content to ProseMirror document structure
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @fileoverview Parse curly brace attributes from markdown
3
+ *
4
+ * Supports syntax like: {role=hero width=1200 .class #id autoplay}
5
+ *
6
+ * Attribute types:
7
+ * - key=value → { key: "value" }
8
+ * - key="value" → { key: "value" } (quoted, allows spaces)
9
+ * - key='value' → { key: "value" } (single quoted)
10
+ * - .className → added to classes array
11
+ * - #idName → { id: "idName" }
12
+ * - booleanKey → { booleanKey: true }
13
+ */
14
+
15
+ /**
16
+ * Parse an attribute string like "role=hero width=1200 .featured autoplay"
17
+ *
18
+ * @param {string} attrString - The attribute string (without curly braces)
19
+ * @returns {Object} Parsed attributes object
20
+ */
21
+ export function parseAttributeString(attrString) {
22
+ if (!attrString || typeof attrString !== 'string') {
23
+ return {}
24
+ }
25
+
26
+ const attrs = {}
27
+ const classes = []
28
+
29
+ // Regex to match different attribute patterns
30
+ // Handles: key="value", key='value', key=value, .class, #id, boolean
31
+ const pattern = /(?:([a-zA-Z_][\w-]*)=(?:"([^"]*)"|'([^']*)'|([^\s}]+))|\.([a-zA-Z_][\w-]*)|#([a-zA-Z_][\w-]*)|([a-zA-Z_][\w-]*)(?=\s|$))/g
32
+
33
+ let match
34
+ while ((match = pattern.exec(attrString)) !== null) {
35
+ const [, key, quotedDouble, quotedSingle, unquoted, className, idName, booleanKey] = match
36
+
37
+ if (key) {
38
+ // key=value attribute
39
+ attrs[key] = quotedDouble ?? quotedSingle ?? unquoted
40
+ } else if (className) {
41
+ // .className
42
+ classes.push(className)
43
+ } else if (idName) {
44
+ // #id
45
+ attrs.id = idName
46
+ } else if (booleanKey) {
47
+ // boolean attribute (no value)
48
+ attrs[booleanKey] = true
49
+ }
50
+ }
51
+
52
+ // Add classes array if any were found
53
+ if (classes.length > 0) {
54
+ attrs.class = classes.join(' ')
55
+ }
56
+
57
+ return attrs
58
+ }
59
+
60
+ /**
61
+ * Extract attributes block from the end of a string
62
+ *
63
+ * @param {string} text - Text that may end with {attributes}
64
+ * @returns {{ text: string, attrs: Object }} The text without attrs and parsed attrs
65
+ */
66
+ export function extractTrailingAttributes(text) {
67
+ if (!text || typeof text !== 'string') {
68
+ return { text: text || '', attrs: {} }
69
+ }
70
+
71
+ // Match {attributes} at the end of the string
72
+ // Handles nested braces in values by matching balanced braces
73
+ const match = text.match(/\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\s*$/)
74
+
75
+ if (!match) {
76
+ return { text, attrs: {} }
77
+ }
78
+
79
+ const attrString = match[1]
80
+ const textWithoutAttrs = text.slice(0, match.index).trimEnd()
81
+
82
+ return {
83
+ text: textWithoutAttrs,
84
+ attrs: parseAttributeString(attrString)
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Check if a string contains an attribute block
90
+ *
91
+ * @param {string} text - Text to check
92
+ * @returns {boolean} True if text contains {attributes}
93
+ */
94
+ export function hasAttributes(text) {
95
+ return /\{[^{}]+\}\s*$/.test(text)
96
+ }
97
+
98
+ /**
99
+ * Merge multiple attribute objects, with later objects taking precedence
100
+ *
101
+ * @param {...Object} attrObjects - Attribute objects to merge
102
+ * @returns {Object} Merged attributes
103
+ */
104
+ export function mergeAttributes(...attrObjects) {
105
+ const result = {}
106
+ const allClasses = []
107
+
108
+ for (const attrs of attrObjects) {
109
+ if (!attrs) continue
110
+
111
+ for (const [key, value] of Object.entries(attrs)) {
112
+ if (key === 'class') {
113
+ // Accumulate classes
114
+ allClasses.push(value)
115
+ } else {
116
+ result[key] = value
117
+ }
118
+ }
119
+ }
120
+
121
+ if (allClasses.length > 0) {
122
+ result.class = allClasses.join(' ')
123
+ }
124
+
125
+ return result
126
+ }
127
+
128
+ /**
129
+ * Normalize attribute names to camelCase
130
+ *
131
+ * @param {Object} attrs - Attributes with potentially kebab-case keys
132
+ * @returns {Object} Attributes with camelCase keys
133
+ */
134
+ export function normalizeAttributeNames(attrs) {
135
+ const result = {}
136
+
137
+ for (const [key, value] of Object.entries(attrs)) {
138
+ // Convert kebab-case to camelCase
139
+ const camelKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
140
+ result[camelKey] = value
141
+ }
142
+
143
+ return result
144
+ }
@@ -137,7 +137,7 @@ function parseBlock(token, schema) {
137
137
  if (token.type === "hr") {
138
138
  return {
139
139
  type: "divider",
140
- attrs: { style: "dot", size: "normal" },
140
+ attrs: { style: "line", size: "normal" },
141
141
  };
142
142
  }
143
143
 
@@ -63,8 +63,29 @@ function parseInline(token, schema, removeNewLine = false) {
63
63
  }
64
64
 
65
65
  if (token.type === "link") {
66
- const isButton = token.href.startsWith("button:");
67
- const href = isButton ? token.href.substring(7) : token.href;
66
+ // Check for button: prefix or .button class in attrs
67
+ const hasButtonPrefix = token.href.startsWith("button:");
68
+ const hasButtonClass = token.attrs?.class?.includes("button");
69
+ const isButton = hasButtonPrefix || hasButtonClass;
70
+
71
+ const href = hasButtonPrefix ? token.href.substring(7) : token.href;
72
+
73
+ // Extract known link/button attributes from curly brace attrs
74
+ const {
75
+ variant = "primary",
76
+ download,
77
+ target,
78
+ rel,
79
+ size,
80
+ icon,
81
+ ...otherAttrs
82
+ } = token.attrs || {};
83
+
84
+ // Remove 'button' from class if present (it's used as a type indicator)
85
+ let className = otherAttrs.class;
86
+ if (className) {
87
+ className = className.replace(/\bbutton\b/, "").trim() || undefined;
88
+ }
68
89
 
69
90
  return [
70
91
  {
@@ -75,7 +96,13 @@ function parseInline(token, schema, removeNewLine = false) {
75
96
  attrs: {
76
97
  href,
77
98
  title: token.title || null,
78
- ...(isButton && { variant: "primary" }),
99
+ ...(isButton && { variant }),
100
+ ...(download !== undefined && { download }),
101
+ ...(target && { target }),
102
+ ...(rel && { rel }),
103
+ ...(size && { size }),
104
+ ...(icon && { icon }),
105
+ ...(className && { class: className }),
79
106
  },
80
107
  },
81
108
  ],
@@ -87,16 +114,35 @@ function parseInline(token, schema, removeNewLine = false) {
87
114
  if (token.type === "image") {
88
115
  let role, src;
89
116
 
90
- // Find the first colon to handle role:url format correctly
117
+ // Find the first colon to handle role:url format correctly (legacy syntax)
91
118
  if (token.href.includes(":") && !token.href.startsWith("http")) {
92
119
  const colonIndex = token.href.indexOf(":");
93
120
  role = token.href.substring(0, colonIndex);
94
121
  src = token.href.substring(colonIndex + 1);
95
122
  } else {
96
- role = "image";
97
123
  src = token.href;
98
124
  }
99
125
 
126
+ // Extract known image attributes from curly brace attrs
127
+ const {
128
+ role: attrRole,
129
+ width,
130
+ height,
131
+ loading,
132
+ poster, // For videos: explicit poster image
133
+ preview, // For PDFs/documents: preview image
134
+ autoplay,
135
+ muted,
136
+ loop,
137
+ controls,
138
+ fit, // object-fit: cover, contain, etc.
139
+ position, // object-position
140
+ ...otherAttrs
141
+ } = token.attrs || {};
142
+
143
+ // Attribute role takes precedence over prefix role
144
+ const finalRole = attrRole || role || "image";
145
+
100
146
  return [
101
147
  {
102
148
  type: "image",
@@ -104,7 +150,26 @@ function parseInline(token, schema, removeNewLine = false) {
104
150
  src,
105
151
  caption: token.title || null,
106
152
  alt: text || null,
107
- role,
153
+ role: finalRole,
154
+ // Dimension attributes
155
+ ...(width && { width: parseInt(width, 10) || width }),
156
+ ...(height && { height: parseInt(height, 10) || height }),
157
+ // Loading behavior
158
+ ...(loading && { loading }),
159
+ // Media attributes (for video/document roles)
160
+ ...(poster && { poster }),
161
+ ...(preview && { preview }),
162
+ // Video-specific attributes
163
+ ...(autoplay !== undefined && { autoplay }),
164
+ ...(muted !== undefined && { muted }),
165
+ ...(loop !== undefined && { loop }),
166
+ ...(controls !== undefined && { controls }),
167
+ // Styling attributes
168
+ ...(fit && { fit }),
169
+ ...(position && { position }),
170
+ // Any other custom attributes
171
+ ...(otherAttrs.class && { class: otherAttrs.class }),
172
+ ...(otherAttrs.id && { id: otherAttrs.id }),
108
173
  },
109
174
  },
110
175
  ];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @fileoverview Marked extensions for enhanced markdown syntax
3
+ *
4
+ * Adds support for curly brace attributes on images and links:
5
+ * - ![alt](src){attrs}
6
+ * - [text](href){attrs}
7
+ */
8
+
9
+ import { parseAttributeString } from './attributes.js'
10
+
11
+ /**
12
+ * Regex patterns for matching markdown elements with optional attributes
13
+ */
14
+ const PATTERNS = {
15
+ // Image: ![alt](src "title"){attrs}
16
+ // Captures: alt, src, title (optional), attrs (optional)
17
+ image: /^!\[([^\]]*)\]\(([^)"'\s]+)(?:\s+["']([^"']*)["'])?\)(?:\{([^}]*)\})?/,
18
+
19
+ // Link: [text](href "title"){attrs}
20
+ // Captures: text, href, title (optional), attrs (optional)
21
+ link: /^\[([^\]]+)\]\(([^)"'\s]+)(?:\s+["']([^"']*)["'])?\)(?:\{([^}]*)\})?/,
22
+ }
23
+
24
+ /**
25
+ * Create a marked extension for images with attribute support
26
+ *
27
+ * @returns {Object} Marked tokenizer extension
28
+ */
29
+ export function createImageExtension() {
30
+ return {
31
+ name: 'image',
32
+ level: 'inline',
33
+ start(src) {
34
+ return src.indexOf('![')
35
+ },
36
+ tokenizer(src) {
37
+ const match = PATTERNS.image.exec(src)
38
+ if (!match) return
39
+
40
+ const [raw, alt, href, title, attrString] = match
41
+
42
+ // Parse attributes from curly braces
43
+ const attrs = attrString ? parseAttributeString(attrString) : {}
44
+
45
+ return {
46
+ type: 'image',
47
+ raw,
48
+ text: alt || '',
49
+ href,
50
+ title: title || null,
51
+ attrs, // Extended: parsed attributes
52
+ }
53
+ },
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Create a marked extension for links with attribute support
59
+ *
60
+ * @returns {Object} Marked tokenizer extension
61
+ */
62
+ export function createLinkExtension() {
63
+ return {
64
+ name: 'link',
65
+ level: 'inline',
66
+ start(src) {
67
+ // Don't match images (starting with !)
68
+ const idx = src.indexOf('[')
69
+ if (idx > 0 && src[idx - 1] === '!') {
70
+ // Find next [ that's not part of an image
71
+ const nextIdx = src.indexOf('[', idx + 1)
72
+ return nextIdx >= 0 ? nextIdx : -1
73
+ }
74
+ return idx
75
+ },
76
+ tokenizer(src) {
77
+ // Don't match if this is an image
78
+ if (src.startsWith('![')) return
79
+
80
+ const match = PATTERNS.link.exec(src)
81
+ if (!match) return
82
+
83
+ const [raw, text, href, title, attrString] = match
84
+
85
+ // Parse attributes from curly braces
86
+ const attrs = attrString ? parseAttributeString(attrString) : {}
87
+
88
+ return {
89
+ type: 'link',
90
+ raw,
91
+ text,
92
+ href,
93
+ title: title || null,
94
+ attrs, // Extended: parsed attributes
95
+ // Include tokens for nested content
96
+ tokens: [],
97
+ }
98
+ },
99
+ childTokens: ['tokens'],
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Get all custom marked extensions
105
+ *
106
+ * @returns {Object} Marked extensions configuration
107
+ */
108
+ export function getMarkedExtensions() {
109
+ return {
110
+ extensions: [
111
+ createImageExtension(),
112
+ createLinkExtension(),
113
+ ],
114
+ }
115
+ }
116
+
117
+ export default getMarkedExtensions
@@ -2,7 +2,6 @@
2
2
  * @fileoverview Parse markdown tables
3
3
  */
4
4
 
5
- import { marked } from "marked";
6
5
  import { parseInline } from "./inline.js";
7
6
 
8
7
  /**
@@ -21,7 +20,7 @@ function getColumnAlignment(colDef) {
21
20
 
22
21
  /**
23
22
  * Parse table row content
24
- * @param {Object} token - Row token
23
+ * @param {Array} row - Row cells (objects with { text, tokens } in marked v11+)
25
24
  * @param {boolean} isHeader - Whether this is a header row
26
25
  * @param {Array} alignments - Column alignments
27
26
  * @param {Object} schema - ProseMirror schema
@@ -30,23 +29,27 @@ function getColumnAlignment(colDef) {
30
29
  function parseTableRow(row, isHeader, alignments, schema) {
31
30
  return {
32
31
  type: "tableRow",
33
- content: row.map((cell, index) => ({
34
- type: "tableCell",
35
- attrs: {
36
- colspan: 1,
37
- rowspan: 1,
38
- align: alignments[index] || null,
39
- header: isHeader,
40
- },
41
- content: [
42
- {
43
- type: "paragraph",
44
- content: marked.Lexer.lexInline(cell).flatMap((t) =>
45
- parseInline(t, schema)
46
- ),
32
+ content: row.map((cell, index) => {
33
+ // In marked v11+, cells are objects with { text, tokens }
34
+ // Use the pre-parsed tokens array directly
35
+ const tokens = cell.tokens || [];
36
+
37
+ return {
38
+ type: "tableCell",
39
+ attrs: {
40
+ colspan: 1,
41
+ rowspan: 1,
42
+ align: alignments[index] || null,
43
+ header: isHeader,
47
44
  },
48
- ],
49
- })),
45
+ content: [
46
+ {
47
+ type: "paragraph",
48
+ content: tokens.flatMap((t) => parseInline(t, schema)),
49
+ },
50
+ ],
51
+ };
52
+ }),
50
53
  };
51
54
  }
52
55
 
@@ -32,10 +32,30 @@ const baseNodes = {
32
32
 
33
33
  image: {
34
34
  attrs: {
35
+ // Core attributes
35
36
  src: {},
36
37
  caption: { default: null },
37
38
  alt: { default: null },
38
- role: { default: "content" },
39
+ role: { default: "image" }, // image, icon, hero, video, pdf, etc.
40
+ // Dimension attributes
41
+ width: { default: null },
42
+ height: { default: null },
43
+ // Loading behavior
44
+ loading: { default: null }, // lazy, eager
45
+ // Media attributes (for video/document roles)
46
+ poster: { default: null }, // Explicit poster image for videos
47
+ preview: { default: null }, // Preview image for PDFs/documents
48
+ // Video-specific attributes
49
+ autoplay: { default: null },
50
+ muted: { default: null },
51
+ loop: { default: null },
52
+ controls: { default: null },
53
+ // Styling attributes
54
+ fit: { default: null }, // object-fit: cover, contain, fill, etc.
55
+ position: { default: null }, // object-position
56
+ // Generic attributes
57
+ class: { default: null },
58
+ id: { default: null },
39
59
  },
40
60
  // group: "block inline",
41
61
  },
@@ -114,13 +134,25 @@ const baseMarks = {
114
134
  attrs: {
115
135
  href: {},
116
136
  title: { default: null },
137
+ // Extended attributes
138
+ target: { default: null }, // _blank, _self, etc.
139
+ rel: { default: null }, // noopener, noreferrer, etc.
140
+ download: { default: null }, // Download attribute (true or filename)
141
+ class: { default: null },
117
142
  },
118
143
  },
119
144
  button: {
120
145
  attrs: {
121
146
  href: {},
122
147
  title: { default: null },
123
- variant: { default: "primary" },
148
+ variant: { default: "primary" }, // primary, secondary, outline, ghost, etc.
149
+ // Extended attributes
150
+ size: { default: null }, // sm, md, lg
151
+ icon: { default: null }, // Icon name or path
152
+ target: { default: null },
153
+ rel: { default: null },
154
+ download: { default: null },
155
+ class: { default: null },
124
156
  },
125
157
  },
126
158
  code: {
@@ -179,75 +179,63 @@ describe("Basic Markdown Parsing", () => {
179
179
 
180
180
  describe("Extended Syntax", () => {
181
181
  test("parses images without role", () => {
182
- const markdown = "![Title](path/to/image.svg)";
182
+ // Images are extracted from paragraphs to root level for component rendering
183
+ const markdown = "![Alt Text](path/to/image.svg)";
183
184
  const result = markdownToProseMirror(markdown);
184
185
 
185
186
  expect(result).toEqual({
186
187
  type: "doc",
187
188
  content: [
188
189
  {
189
- type: "paragraph",
190
- content: [
191
- {
192
- type: "image",
193
- attrs: {
194
- src: "path/to/image.svg",
195
- title: "Title",
196
- alt: null,
197
- role: "image",
198
- },
199
- },
200
- ],
190
+ type: "image",
191
+ attrs: {
192
+ src: "path/to/image.svg",
193
+ caption: null,
194
+ alt: "Alt Text",
195
+ role: "image",
196
+ },
201
197
  },
202
198
  ],
203
199
  });
204
200
  });
205
201
 
206
202
  test("parses images with roles", () => {
207
- const markdown = '![Title](icon:path/to/image.svg "Alt text")';
203
+ // Images are extracted from paragraphs to root level for component rendering
204
+ const markdown = '![Alt Text](icon:path/to/image.svg "Caption text")';
208
205
  const result = markdownToProseMirror(markdown);
209
206
 
210
207
  expect(result).toEqual({
211
208
  type: "doc",
212
209
  content: [
213
210
  {
214
- type: "paragraph",
215
- content: [
216
- {
217
- type: "image",
218
- attrs: {
219
- src: "path/to/image.svg",
220
- title: "Title",
221
- alt: "Alt text",
222
- role: "icon",
223
- },
224
- },
225
- ],
211
+ type: "image",
212
+ attrs: {
213
+ src: "path/to/image.svg",
214
+ caption: "Caption text",
215
+ alt: "Alt Text",
216
+ role: "icon",
217
+ },
226
218
  },
227
219
  ],
228
220
  });
229
221
  });
230
222
 
231
- test("parses images without URL", () => {
232
- const markdown = "![Title](https://test.com)";
223
+ test("parses images with URL", () => {
224
+ // Images are extracted from paragraphs to root level for component rendering
225
+ const markdown = "![Alt Text](https://test.com)";
233
226
  const result = markdownToProseMirror(markdown);
234
227
 
235
228
  expect(result).toEqual({
236
229
  type: "doc",
237
230
  content: [
238
231
  {
239
- type: "paragraph",
240
- content: [
241
- {
242
- type: "image",
243
- attrs: {
244
- src: "https://test.com",
245
- title: "Title",
246
- alt: null,
247
- role: "image",
248
- },
249
- },
250
- ],
232
+ type: "image",
233
+ attrs: {
234
+ src: "https://test.com",
235
+ caption: null,
236
+ alt: "Alt Text",
237
+ role: "image",
238
+ },
251
239
  },
252
240
  ],
253
241
  });
@@ -341,3 +329,244 @@ describe("Extended Syntax", () => {
341
329
  });
342
330
  });
343
331
  });
332
+
333
+ describe("Curly Brace Attributes", () => {
334
+ test("parses image with role attribute", () => {
335
+ const markdown = "![Hero Image](./hero.jpg){role=hero}";
336
+ const result = markdownToProseMirror(markdown);
337
+
338
+ expect(result.content[0]).toEqual({
339
+ type: "image",
340
+ attrs: expect.objectContaining({
341
+ src: "./hero.jpg",
342
+ alt: "Hero Image",
343
+ role: "hero",
344
+ }),
345
+ });
346
+ });
347
+
348
+ test("parses image with multiple attributes", () => {
349
+ const markdown = '![Photo](./photo.jpg "A beautiful photo"){width=800 height=600 loading=lazy}';
350
+ const result = markdownToProseMirror(markdown);
351
+
352
+ expect(result.content[0]).toEqual({
353
+ type: "image",
354
+ attrs: expect.objectContaining({
355
+ src: "./photo.jpg",
356
+ alt: "Photo",
357
+ caption: "A beautiful photo",
358
+ width: 800,
359
+ height: 600,
360
+ loading: "lazy",
361
+ }),
362
+ });
363
+ });
364
+
365
+ test("parses video with poster attribute", () => {
366
+ const markdown = "![Intro Video](./intro.mp4){role=video poster=./poster.jpg autoplay muted loop}";
367
+ const result = markdownToProseMirror(markdown);
368
+
369
+ expect(result.content[0]).toEqual({
370
+ type: "image",
371
+ attrs: expect.objectContaining({
372
+ src: "./intro.mp4",
373
+ role: "video",
374
+ poster: "./poster.jpg",
375
+ autoplay: true,
376
+ muted: true,
377
+ loop: true,
378
+ }),
379
+ });
380
+ });
381
+
382
+ test("parses PDF with preview attribute", () => {
383
+ const markdown = "![User Guide](./guide.pdf){role=pdf preview=./guide-preview.jpg}";
384
+ const result = markdownToProseMirror(markdown);
385
+
386
+ expect(result.content[0]).toEqual({
387
+ type: "image",
388
+ attrs: expect.objectContaining({
389
+ src: "./guide.pdf",
390
+ role: "pdf",
391
+ preview: "./guide-preview.jpg",
392
+ }),
393
+ });
394
+ });
395
+
396
+ test("parses image with class and id", () => {
397
+ const markdown = "![Logo](./logo.svg){.featured #main-logo}";
398
+ const result = markdownToProseMirror(markdown);
399
+
400
+ expect(result.content[0]).toEqual({
401
+ type: "image",
402
+ attrs: expect.objectContaining({
403
+ src: "./logo.svg",
404
+ class: "featured",
405
+ id: "main-logo",
406
+ }),
407
+ });
408
+ });
409
+
410
+ test("parses link with target attribute", () => {
411
+ const markdown = '[External Link](https://example.com){target=_blank rel="noopener noreferrer"}';
412
+ const result = markdownToProseMirror(markdown);
413
+
414
+ expect(result.content[0].content[0]).toEqual({
415
+ type: "text",
416
+ text: "External Link",
417
+ marks: [
418
+ {
419
+ type: "link",
420
+ attrs: expect.objectContaining({
421
+ href: "https://example.com",
422
+ target: "_blank",
423
+ rel: "noopener noreferrer",
424
+ }),
425
+ },
426
+ ],
427
+ });
428
+ });
429
+
430
+ test("parses download link", () => {
431
+ const markdown = "[Download PDF](./document.pdf){download}";
432
+ const result = markdownToProseMirror(markdown);
433
+
434
+ expect(result.content[0].content[0]).toEqual({
435
+ type: "text",
436
+ text: "Download PDF",
437
+ marks: [
438
+ {
439
+ type: "link",
440
+ attrs: expect.objectContaining({
441
+ href: "./document.pdf",
442
+ download: true,
443
+ }),
444
+ },
445
+ ],
446
+ });
447
+ });
448
+
449
+ test("parses button with .button class", () => {
450
+ const markdown = "[Get Started](https://example.com){.button variant=secondary size=lg}";
451
+ const result = markdownToProseMirror(markdown);
452
+
453
+ expect(result.content[0].content[0]).toEqual({
454
+ type: "text",
455
+ text: "Get Started",
456
+ marks: [
457
+ {
458
+ type: "button",
459
+ attrs: expect.objectContaining({
460
+ href: "https://example.com",
461
+ variant: "secondary",
462
+ size: "lg",
463
+ }),
464
+ },
465
+ ],
466
+ });
467
+ });
468
+
469
+ test("parses button with icon", () => {
470
+ const markdown = "[Learn More](https://example.com){.button icon=arrow-right}";
471
+ const result = markdownToProseMirror(markdown);
472
+
473
+ expect(result.content[0].content[0]).toEqual({
474
+ type: "text",
475
+ text: "Learn More",
476
+ marks: [
477
+ {
478
+ type: "button",
479
+ attrs: expect.objectContaining({
480
+ href: "https://example.com",
481
+ icon: "arrow-right",
482
+ }),
483
+ },
484
+ ],
485
+ });
486
+ });
487
+
488
+ test("attribute role overrides prefix role (legacy compatibility)", () => {
489
+ // If both prefix and attribute role are present, attribute takes precedence
490
+ const markdown = "![Alt](icon:./image.svg){role=hero}";
491
+ const result = markdownToProseMirror(markdown);
492
+
493
+ expect(result.content[0].attrs.role).toBe("hero");
494
+ });
495
+
496
+ test("parses image with fit and position attributes", () => {
497
+ const markdown = "![Background](./bg.jpg){fit=cover position=center}";
498
+ const result = markdownToProseMirror(markdown);
499
+
500
+ expect(result.content[0]).toEqual({
501
+ type: "image",
502
+ attrs: expect.objectContaining({
503
+ src: "./bg.jpg",
504
+ fit: "cover",
505
+ position: "center",
506
+ }),
507
+ });
508
+ });
509
+
510
+ test("parses video with controls attribute", () => {
511
+ const markdown = "![Demo Video](./demo.mp4){role=video controls muted}";
512
+ const result = markdownToProseMirror(markdown);
513
+
514
+ expect(result.content[0]).toEqual({
515
+ type: "image",
516
+ attrs: expect.objectContaining({
517
+ src: "./demo.mp4",
518
+ role: "video",
519
+ controls: true,
520
+ muted: true,
521
+ }),
522
+ });
523
+ });
524
+
525
+ test("parses download link with custom filename", () => {
526
+ const markdown = '[Get Report](./data.pdf){download="annual-report.pdf"}';
527
+ const result = markdownToProseMirror(markdown);
528
+
529
+ expect(result.content[0].content[0]).toEqual({
530
+ type: "text",
531
+ text: "Get Report",
532
+ marks: [
533
+ {
534
+ type: "link",
535
+ attrs: expect.objectContaining({
536
+ href: "./data.pdf",
537
+ download: "annual-report.pdf",
538
+ }),
539
+ },
540
+ ],
541
+ });
542
+ });
543
+
544
+ test("parses image with multiple classes", () => {
545
+ const markdown = "![Gallery](./photo.jpg){.featured .rounded .shadow}";
546
+ const result = markdownToProseMirror(markdown);
547
+
548
+ expect(result.content[0]).toEqual({
549
+ type: "image",
550
+ attrs: expect.objectContaining({
551
+ src: "./photo.jpg",
552
+ class: "featured rounded shadow",
553
+ }),
554
+ });
555
+ });
556
+
557
+ test("parses booleans in different positions", () => {
558
+ const markdown = "![Video](./clip.mp4){muted role=video autoplay loop}";
559
+ const result = markdownToProseMirror(markdown);
560
+
561
+ expect(result.content[0]).toEqual({
562
+ type: "image",
563
+ attrs: expect.objectContaining({
564
+ src: "./clip.mp4",
565
+ role: "video",
566
+ muted: true,
567
+ autoplay: true,
568
+ loop: true,
569
+ }),
570
+ });
571
+ });
572
+ });