mdld-parse 0.7.2 → 0.7.4

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/src/render.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import { parse } from './parse.js';
2
2
  import {
3
- DEFAULT_CONTEXT,
4
- DataFactory,
5
- expandIRI,
6
- shortenIRI,
7
- parseSemanticBlock,
8
- hash
3
+ DataFactory,
4
+ expandIRI,
5
+ shortenIRI,
6
+ parseSemanticBlock,
7
+ hash
9
8
  } from './utils.js';
9
+ import {
10
+ escapeHtml,
11
+ getIndentLevel,
12
+ processPredicates
13
+ } from './shared.js';
14
+ import { DEFAULT_CONTEXT } from './constants.js';
10
15
 
11
16
  /**
12
17
  * Render MD-LD to HTML+RDFa
@@ -19,472 +24,430 @@ import {
19
24
  * @returns {Object} Render result with HTML and metadata
20
25
  */
21
26
  export function render(mdld, options = {}) {
22
- // Phase 1: Parse MD-LD (reuse parser)
23
- const parsed = parse(mdld, { context: options.context || {} });
27
+ // Phase 1: Parse MD-LD (reuse parser)
28
+ const parsed = parse(mdld, { context: options.context || {} });
24
29
 
25
- // Phase 2: Build render state
26
- const state = buildRenderState(parsed, options, mdld);
30
+ // Phase 2: Build render state
31
+ const state = buildRenderState(parsed, options, mdld);
27
32
 
28
- // Phase 3: Render blocks to HTML
29
- const html = renderBlocks(parsed.origin.blocks, state);
33
+ // Phase 3: Render blocks to HTML
34
+ const html = renderBlocks(parsed.origin.blocks, state);
30
35
 
31
- // Phase 4: Wrap with RDFa context
32
- const wrapped = wrapWithRDFaContext(html, state.ctx);
36
+ // Phase 4: Wrap with RDFa context
37
+ const wrapped = wrapWithRDFaContext(html, state.ctx);
33
38
 
34
- return {
35
- html: wrapped,
36
- context: state.ctx,
37
- metadata: {
38
- blockCount: parsed.origin.blocks.size,
39
- quadCount: parsed.quads.length,
40
- renderTime: Date.now()
41
- }
42
- };
39
+ return {
40
+ html: wrapped,
41
+ context: state.ctx,
42
+ metadata: {
43
+ blockCount: parsed.origin.blocks.size,
44
+ quadCount: parsed.quads.length,
45
+ renderTime: Date.now()
46
+ }
47
+ };
43
48
  }
44
49
 
45
50
  /**
46
51
  * Build render state following parser pattern
47
52
  */
48
53
  function buildRenderState(parsed, options, mdld) {
49
- // Use the parser's context which already includes document prefixes
50
- const ctx = parsed.context || { ...DEFAULT_CONTEXT, ...(options.context || {}) };
51
-
52
- return {
53
- ctx,
54
- df: options.dataFactory || DataFactory,
55
- baseIRI: options.baseIRI || '',
56
- sourceText: mdld, // Store original text for content extraction
57
- output: [],
58
- currentSubject: null,
59
- documentSubject: null,
60
- blockStack: [],
61
- carrierStack: []
62
- };
54
+ // Use the parser's context which already includes document prefixes
55
+ const ctx = parsed.context || { ...DEFAULT_CONTEXT, ...(options.context || {}) };
56
+
57
+ return {
58
+ ctx,
59
+ df: options.dataFactory || DataFactory,
60
+ baseIRI: options.baseIRI || '',
61
+ sourceText: mdld, // Store original text for content extraction
62
+ output: [],
63
+ currentSubject: null,
64
+ documentSubject: null,
65
+ blockStack: [],
66
+ carrierStack: []
67
+ };
63
68
  }
64
69
 
65
70
  /**
66
71
  * Render blocks to HTML with RDFa annotations
67
72
  */
68
73
  function renderBlocks(blocks, state) {
69
- // Sort blocks by position
70
- const sortedBlocks = Array.from(blocks.values()).sort((a, b) => {
71
- return (a.range?.start || 0) - (b.range?.start || 0);
72
- });
73
-
74
- // Separate list blocks from other blocks
75
- const listBlocks = sortedBlocks.filter(block => block.carrierType === 'list');
76
- const otherBlocks = sortedBlocks.filter(block => block.carrierType !== 'list');
77
-
78
- // Render non-list blocks normally
79
- otherBlocks.forEach(block => {
80
- renderBlock(block, state);
81
- });
82
-
83
- // Render lists using Markdown approach with RDFa enrichment
84
- if (listBlocks.length > 0) {
85
- renderListsWithRDFa(listBlocks, state);
86
- }
87
-
88
- return state.output.join('');
74
+ // Sort blocks by position
75
+ const sortedBlocks = Array.from(blocks.values()).sort((a, b) => {
76
+ return (a.range?.start || 0) - (b.range?.start || 0);
77
+ });
78
+
79
+ // Separate list blocks from other blocks
80
+ const listBlocks = sortedBlocks.filter(block => block.carrierType === 'list');
81
+ const otherBlocks = sortedBlocks.filter(block => block.carrierType !== 'list');
82
+
83
+ // Render non-list blocks normally
84
+ otherBlocks.forEach(block => {
85
+ renderBlock(block, state);
86
+ });
87
+
88
+ // Render lists using Markdown approach with RDFa enrichment
89
+ if (listBlocks.length > 0) {
90
+ renderListsWithRDFa(listBlocks, state);
91
+ }
92
+
93
+ return state.output.join('');
89
94
  }
90
95
 
91
96
  /**
92
97
  * Render lists using Markdown structure with RDFa enrichment
93
98
  */
94
99
  function renderListsWithRDFa(listBlocks, state) {
95
- // Group list blocks by their context (consecutive blocks with similar context)
96
- const listGroups = groupListBlocksByContext(listBlocks, state.sourceText);
100
+ // Group list blocks by their context (consecutive blocks with similar context)
101
+ const listGroups = groupListBlocksByContext(listBlocks, state.sourceText);
97
102
 
98
- listGroups.forEach(group => {
99
- renderListGroup(group, state);
100
- });
103
+ listGroups.forEach(group => {
104
+ renderListGroup(group, state);
105
+ });
101
106
  }
102
107
 
103
108
  /**
104
109
  * Group list blocks by their structural hierarchy
105
110
  */
106
111
  function groupListBlocksByContext(listBlocks, sourceText) {
107
- const groups = [];
108
-
109
- // Group consecutive list blocks
110
- let currentGroup = null;
111
-
112
- for (const block of listBlocks) {
113
- // Start new group for each top-level item (indent 0)
114
- const indent = getIndentLevel(block, sourceText);
112
+ const groups = [];
113
+
114
+ // Group consecutive list blocks
115
+ let currentGroup = null;
116
+
117
+ for (const block of listBlocks) {
118
+ // Start new group for each top-level item (indent 0)
119
+ const indent = getIndentLevel(block, sourceText);
120
+
121
+ if (indent === 0) {
122
+ // Close previous group
123
+ if (currentGroup) {
124
+ groups.push(currentGroup);
125
+ }
126
+
127
+ // Start new group with a generic name
128
+ currentGroup = {
129
+ contextName: 'Items',
130
+ blocks: [block]
131
+ };
132
+ } else {
133
+ // Add nested items to current group
134
+ if (currentGroup) {
135
+ currentGroup.blocks.push(block);
136
+ } else {
137
+ // This shouldn't happen, but handle it
138
+ currentGroup = {
139
+ contextName: 'Items',
140
+ blocks: [block]
141
+ };
142
+ }
143
+ }
144
+ }
115
145
 
116
- if (indent === 0) {
117
- // Close previous group
118
- if (currentGroup) {
146
+ if (currentGroup) {
119
147
  groups.push(currentGroup);
120
- }
121
-
122
- // Start new group with a generic name
123
- currentGroup = {
124
- contextName: 'Items',
125
- blocks: [block]
126
- };
127
- } else {
128
- // Add nested items to current group
129
- if (currentGroup) {
130
- currentGroup.blocks.push(block);
131
- } else {
132
- // This shouldn't happen, but handle it
133
- currentGroup = {
134
- contextName: 'Items',
135
- blocks: [block]
136
- };
137
- }
138
148
  }
139
- }
140
-
141
- if (currentGroup) {
142
- groups.push(currentGroup);
143
- }
144
149
 
145
- return groups;
150
+ return groups;
146
151
  }
147
152
 
148
153
  /**
149
154
  * Render a list group with proper Markdown structure and RDFa enrichment
150
155
  */
151
156
  function renderListGroup(group, state) {
152
- // Extract the list anchor text from the first block's position
153
- const firstBlock = group.blocks[0];
154
- const listAnchorText = extractListAnchorText(firstBlock, state.sourceText);
157
+ // Extract the list anchor text from the first block's position
158
+ const firstBlock = group.blocks[0];
159
+ const listAnchorText = extractListAnchorText(firstBlock, state.sourceText);
155
160
 
156
- // Render the list anchor as a paragraph if it exists
157
- if (listAnchorText) {
158
- state.output.push(`<p>${escapeHtml(listAnchorText)}</p>`);
159
- }
161
+ // Render the list anchor as a paragraph if it exists
162
+ if (listAnchorText) {
163
+ state.output.push(`<p>${escapeHtml(listAnchorText)}</p>`);
164
+ }
160
165
 
161
- // Render the list directly without the semantic-list wrapper
162
- state.output.push(`<ul>`);
166
+ // Render the list directly without the semantic-list wrapper
167
+ state.output.push(`<ul>`);
163
168
 
164
- // Render list items preserving Markdown structure
165
- const markdownList = group.blocks.map(block =>
166
- state.sourceText.substring(block.range.start, block.range.end)
167
- ).join('\n');
169
+ // Render list items preserving Markdown structure
170
+ const markdownList = group.blocks.map(block =>
171
+ state.sourceText.substring(block.range.start, block.range.end)
172
+ ).join('\n');
168
173
 
169
- // Parse markdown list and enrich with RDFa
170
- const htmlList = parseMarkdownList(markdownList, group.blocks, state);
171
- state.output.push(htmlList);
174
+ // Parse markdown list and enrich with RDFa
175
+ const htmlList = parseMarkdownList(markdownList, group.blocks, state);
176
+ state.output.push(htmlList);
172
177
 
173
- state.output.push(`</ul>`);
178
+ state.output.push(`</ul>`);
174
179
  }
175
180
 
176
181
  /**
177
182
  * Extract list anchor text (the paragraph before the list)
178
183
  */
179
184
  function extractListAnchorText(firstBlock, sourceText) {
180
- if (!firstBlock.range || !sourceText) return null;
185
+ if (!firstBlock.range || !sourceText) return null;
181
186
 
182
- // Look backwards from the first list item to find the list anchor
183
- const startPos = firstBlock.range.start;
187
+ // Look backwards from the first list item to find the list anchor
188
+ const startPos = firstBlock.range.start;
184
189
 
185
- // Search backwards for a line that has semantic annotation but no value carrier
186
- let searchPos = startPos;
187
- let foundAnchor = null;
190
+ // Search backwards for a line that has semantic annotation but no value carrier
191
+ let searchPos = startPos;
192
+ let foundAnchor = null;
188
193
 
189
- while (searchPos > 0 && !foundAnchor) {
190
- // Find the start of the current line
191
- let lineStart = searchPos - 1;
192
- while (lineStart > 0 && sourceText[lineStart - 1] !== '\n') {
193
- lineStart--;
194
- }
194
+ while (searchPos > 0 && !foundAnchor) {
195
+ // Find the start of the current line
196
+ let lineStart = searchPos - 1;
197
+ while (lineStart > 0 && sourceText[lineStart - 1] !== '\n') {
198
+ lineStart--;
199
+ }
195
200
 
196
- // Extract the line
197
- let lineEnd = searchPos;
198
- while (lineEnd < sourceText.length && sourceText[lineEnd] !== '\n') {
199
- lineEnd++;
200
- }
201
+ // Extract the line
202
+ let lineEnd = searchPos;
203
+ while (lineEnd < sourceText.length && sourceText[lineEnd] !== '\n') {
204
+ lineEnd++;
205
+ }
201
206
 
202
- const line = sourceText.substring(lineStart, lineEnd).trim();
207
+ const line = sourceText.substring(lineStart, lineEnd).trim();
203
208
 
204
- // Check if this looks like a list anchor (has semantic annotation but no value carrier)
205
- if (line.includes('{') && !line.match(/^-\s/)) {
206
- foundAnchor = line;
207
- break;
208
- }
209
+ // Check if this looks like a list anchor (has semantic annotation but no value carrier)
210
+ if (line.includes('{') && !line.match(/^-\s/)) {
211
+ foundAnchor = line;
212
+ break;
213
+ }
209
214
 
210
- // Continue searching backwards
211
- searchPos = lineStart - 1;
212
- }
215
+ // Continue searching backwards
216
+ searchPos = lineStart - 1;
217
+ }
213
218
 
214
- if (foundAnchor) {
215
- // Clean the line by removing MD-LD annotations
216
- const cleanLine = foundAnchor.replace(/\s*\{[^}]+\}\s*$/, '');
217
- return cleanLine;
218
- }
219
+ if (foundAnchor) {
220
+ // Clean the line by removing MD-LD annotations
221
+ const cleanLine = foundAnchor.replace(/\s*\{[^}]+\}\s*$/, '');
222
+ return cleanLine;
223
+ }
219
224
 
220
- return null;
225
+ return null;
221
226
  }
222
227
 
223
228
  /**
224
229
  * Parse markdown list and enrich with RDFa attributes
225
230
  */
226
231
  function parseMarkdownList(markdownList, blocks, state) {
227
- const lines = markdownList.split('\n').filter(line => line.trim());
228
- let html = '';
229
- let currentLevel = 0;
230
- let openLi = false;
231
-
232
- lines.forEach((line, index) => {
233
- const indent = line.match(/^(\s*)/)[1].length;
234
- const content = line.trim();
235
-
236
- if (content.startsWith('-')) {
237
- const level = Math.floor(indent / 2); // 2 spaces per level
238
- const itemContent = content.substring(1).trim();
239
-
240
- // Find corresponding block for RDFa attributes
241
- // Try exact match first, then try without MD-LD annotations
242
- const cleanLine = itemContent.replace(/\s*\{[^}]+\}\s*$/, '');
243
- let block = blocks.find(b =>
244
- b.range && state.sourceText.substring(b.range.start, b.range.end).trim() === line
245
- );
246
-
247
- // If no exact match, try matching by clean content
248
- if (!block) {
249
- block = blocks.find(b => {
250
- if (!b.range) return false;
251
- const blockText = state.sourceText.substring(b.range.start, b.range.end).trim();
252
- const blockCleanContent = blockText.replace(/^-\s*/, '').replace(/\s*\{[^}]+\}\s*$/, '');
253
- return blockCleanContent === cleanLine;
254
- });
255
- }
256
-
257
- // Clean content by removing MD-LD annotations
258
- const cleanContent = itemContent.replace(/\s*\{[^}]+\}\s*$/, '');
259
-
260
- // Close lists if going to a higher level
261
- while (currentLevel > level) {
262
- if (openLi) {
263
- html += '</li>';
264
- openLi = false;
232
+ const lines = markdownList.split('\n').filter(line => line.trim());
233
+ let html = '';
234
+ let currentLevel = 0;
235
+ let openLi = false;
236
+
237
+ lines.forEach((line, index) => {
238
+ const indent = line.match(/^(\s*)/)[1].length;
239
+ const content = line.trim();
240
+
241
+ if (content.startsWith('-')) {
242
+ const level = Math.floor(indent / 2); // 2 spaces per level
243
+ const itemContent = content.substring(1).trim();
244
+
245
+ // Find corresponding block for RDFa attributes
246
+ // Try exact match first, then try without MD-LD annotations
247
+ const cleanLine = itemContent.replace(/\s*\{[^}]+\}\s*$/, '');
248
+ let block = blocks.find(b =>
249
+ b.range && state.sourceText.substring(b.range.start, b.range.end).trim() === line
250
+ );
251
+
252
+ // If no exact match, try matching by clean content
253
+ if (!block) {
254
+ block = blocks.find(b => {
255
+ if (!b.range) return false;
256
+ const blockText = state.sourceText.substring(b.range.start, b.range.end).trim();
257
+ const blockCleanContent = blockText.replace(/^-\s*/, '').replace(/\s*\{[^}]+\}\s*$/, '');
258
+ return blockCleanContent === cleanLine;
259
+ });
260
+ }
261
+
262
+ // Clean content by removing MD-LD annotations
263
+ const cleanContent = itemContent.replace(/\s*\{[^}]+\}\s*$/, '');
264
+
265
+ // Close lists if going to a higher level
266
+ while (currentLevel > level) {
267
+ if (openLi) {
268
+ html += '</li>';
269
+ openLi = false;
270
+ }
271
+ html += '</ul>';
272
+ currentLevel--;
273
+ }
274
+
275
+ // Open lists if going deeper
276
+ while (currentLevel < level) {
277
+ if (openLi) {
278
+ html += '<ul>';
279
+ openLi = false;
280
+ } else {
281
+ html += '<ul>';
282
+ }
283
+ currentLevel++;
284
+ }
285
+
286
+ // Close previous li if open
287
+ if (openLi) {
288
+ html += '</li>';
289
+ openLi = false;
290
+ }
291
+
292
+ const attrs = block ? buildRDFaAttrsFromBlock(block, state.ctx) : '';
293
+ html += `<li${attrs}>${escapeHtml(cleanContent)}`;
294
+ openLi = true;
265
295
  }
266
- html += '</ul>';
267
- currentLevel--;
268
- }
269
-
270
- // Open lists if going deeper
271
- while (currentLevel < level) {
272
- if (openLi) {
273
- html += '<ul>';
274
- openLi = false;
275
- } else {
276
- html += '<ul>';
277
- }
278
- currentLevel++;
279
- }
296
+ });
280
297
 
281
- // Close previous li if open
282
- if (openLi) {
298
+ // Close any remaining open li and lists
299
+ if (openLi) {
283
300
  html += '</li>';
284
- openLi = false;
285
- }
286
-
287
- const attrs = block ? buildRDFaAttrsFromBlock(block, state.ctx) : '';
288
- html += `<li${attrs}>${escapeHtml(cleanContent)}`;
289
- openLi = true;
290
301
  }
291
- });
292
-
293
- // Close any remaining open li and lists
294
- if (openLi) {
295
- html += '</li>';
296
- }
297
- while (currentLevel > 0) {
298
- html += '</ul>';
299
- currentLevel--;
300
- }
301
-
302
- return html;
303
- }
304
-
305
- /**
306
- * Get indent level from source text
307
- */
308
- function getIndentLevel(block, sourceText) {
309
- if (!block.range || !sourceText) return 0;
302
+ while (currentLevel > 0) {
303
+ html += '</ul>';
304
+ currentLevel--;
305
+ }
310
306
 
311
- const text = sourceText.substring(block.range.start, block.range.end);
312
- const indentMatch = text.match(/^(\s*)/);
313
- return indentMatch ? indentMatch[1].length : 0;
307
+ return html;
314
308
  }
315
309
 
316
310
  /**
317
311
  * Render a single block
318
312
  */
319
313
  function renderBlock(block, state) {
320
- const attrs = buildRDFaAttrsFromBlock(block, state.ctx);
321
-
322
- switch (block.type || block.carrierType) {
323
- case 'heading':
324
- const level = block.text ? block.text.match(/^#+/)?.[0]?.length || 1 : 1;
325
- const tag = `h${level}`;
326
- state.output.push(`<${tag}${attrs}>`);
327
- renderBlockContent(block, state);
328
- state.output.push(`</${tag}>`);
329
- break;
330
-
331
- case 'para':
332
- state.output.push(`<p${attrs}>`);
333
- renderBlockContent(block, state);
334
- state.output.push(`</p>`);
335
- break;
336
-
337
- case 'list':
338
- // List blocks are handled separately in renderListsWithRDFa
339
- break;
340
-
341
- case 'quote':
342
- state.output.push(`<blockquote${attrs}>`);
343
- renderBlockContent(block, state);
344
- state.output.push(`</blockquote>`);
345
- break;
346
-
347
- case 'code':
348
- const language = block.info || '';
349
- state.output.push(`<pre><code${attrs}${language ? ` class="language-${escapeHtml(language)}"` : ''}>`);
350
- state.output.push(escapeHtml(block.text || ''));
351
- state.output.push(`</code></pre>`);
352
- break;
353
-
354
- default:
355
- // Default rendering as paragraph
356
- state.output.push(`<div${attrs}>`);
357
- renderBlockContent(block, state);
358
- state.output.push(`</div>`);
359
- }
314
+ const attrs = buildRDFaAttrsFromBlock(block, state.ctx);
315
+
316
+ switch (block.type || block.carrierType) {
317
+ case 'heading':
318
+ const level = block.text ? block.text.match(/^#+/)?.[0]?.length || 1 : 1;
319
+ const tag = `h${level}`;
320
+ state.output.push(`<${tag}${attrs}>`);
321
+ renderBlockContent(block, state);
322
+ state.output.push(`</${tag}>`);
323
+ break;
324
+
325
+ case 'para':
326
+ state.output.push(`<p${attrs}>`);
327
+ renderBlockContent(block, state);
328
+ state.output.push(`</p>`);
329
+ break;
330
+
331
+ case 'list':
332
+ // List blocks are handled separately in renderListsWithRDFa
333
+ break;
334
+
335
+ case 'quote':
336
+ state.output.push(`<blockquote${attrs}>`);
337
+ renderBlockContent(block, state);
338
+ state.output.push(`</blockquote>`);
339
+ break;
340
+
341
+ case 'code':
342
+ const language = block.info || '';
343
+ state.output.push(`<pre><code${attrs}${language ? ` class="language-${escapeHtml(language)}"` : ''}>`);
344
+ state.output.push(escapeHtml(block.text || ''));
345
+ state.output.push(`</code></pre>`);
346
+ break;
347
+
348
+ default:
349
+ // Default rendering as paragraph
350
+ state.output.push(`<div${attrs}>`);
351
+ renderBlockContent(block, state);
352
+ state.output.push(`</div>`);
353
+ }
360
354
  }
361
355
 
362
356
  /**
363
357
  * Render block content with inline carriers
364
358
  */
365
359
  function renderBlockContent(block, state) {
366
- // Extract text from source using range information
367
- if (block.range && state.sourceText) {
368
- let text = state.sourceText.substring(block.range.start, block.range.end);
369
-
370
- // Remove semantic block annotations from the text
371
- if (block.attrsRange) {
372
- const beforeAttrs = text.substring(0, block.attrsRange.start - block.range.start);
373
- const afterAttrs = text.substring(block.attrsRange.end - block.range.start);
374
- text = beforeAttrs + afterAttrs;
375
- }
360
+ // Extract text from source using range information
361
+ if (block.range && state.sourceText) {
362
+ let text = state.sourceText.substring(block.range.start, block.range.end);
363
+
364
+ // Remove semantic block annotations from the text
365
+ if (block.attrsRange) {
366
+ const beforeAttrs = text.substring(0, block.attrsRange.start - block.range.start);
367
+ const afterAttrs = text.substring(block.attrsRange.end - block.range.start);
368
+ text = beforeAttrs + afterAttrs;
369
+ }
376
370
 
377
- // For headings, extract text content from the heading
378
- if (block.carrierType === 'heading') {
379
- // Remove heading markers (#) and trim
380
- const content = text.replace(/^#+\s*/, '').trim();
381
- state.output.push(escapeHtml(content));
382
- } else {
383
- state.output.push(escapeHtml(text.trim()));
371
+ // For headings, extract text content from the heading
372
+ if (block.carrierType === 'heading') {
373
+ // Remove heading markers (#) and trim
374
+ const content = text.replace(/^#+\s*/, '').trim();
375
+ state.output.push(escapeHtml(content));
376
+ } else {
377
+ state.output.push(escapeHtml(text.trim()));
378
+ }
384
379
  }
385
- }
386
380
  }
387
381
 
388
382
  /**
389
383
  * Build RDFa attributes from block
390
384
  */
391
385
  function buildRDFaAttrsFromBlock(block, ctx) {
392
- const attrs = [];
393
-
394
- // Subject
395
- if (block.subject && block.subject !== 'RESET' && !block.subject.startsWith('=#') && !block.subject.startsWith('+')) {
396
- const expanded = expandIRI(block.subject, ctx);
397
- const shortened = shortenIRI(expanded, ctx);
398
- attrs.push(`about="${escapeHtml(shortened)}"`);
399
- }
400
-
401
- // Types
402
- if (block.types && block.types.length > 0) {
403
- const types = block.types.map(t => {
404
- const iri = typeof t === 'string' ? t : t.iri;
405
- const expanded = expandIRI(iri, ctx);
406
- return shortenIRI(expanded, ctx);
407
- }).join(' ');
408
- attrs.push(`typeof="${escapeHtml(types)}"`);
409
- }
410
-
411
- // Predicates
412
- if (block.predicates && block.predicates.length > 0) {
413
- const literalProps = [];
414
- const objectProps = [];
415
- const reverseProps = [];
416
-
417
- block.predicates.forEach(pred => {
418
- const iri = typeof pred === 'string' ? pred : pred.iri;
419
- const expanded = expandIRI(iri, ctx);
420
- const shortened = shortenIRI(expanded, ctx);
421
- const form = typeof pred === 'string' ? '' : (pred.form || '');
422
-
423
- if (form === '!') {
424
- reverseProps.push(shortened);
425
- } else if (form === '?') {
426
- objectProps.push(shortened);
427
- } else {
428
- literalProps.push(shortened);
429
- }
430
- });
386
+ const attrs = [];
431
387
 
432
- if (literalProps.length > 0) {
433
- attrs.push(`property="${escapeHtml(literalProps.join(' '))}"`);
388
+ // Subject
389
+ if (block.subject && block.subject !== 'RESET' && !block.subject.startsWith('=#') && !block.subject.startsWith('+')) {
390
+ const expanded = expandIRI(block.subject, ctx);
391
+ const shortened = shortenIRI(expanded, ctx);
392
+ attrs.push(`about="${escapeHtml(shortened)}"`);
434
393
  }
435
- if (objectProps.length > 0) {
436
- attrs.push(`rel="${escapeHtml(objectProps.join(' '))}"`);
394
+
395
+ // Types
396
+ if (block.types && block.types.length > 0) {
397
+ const types = block.types.map(t => {
398
+ const iri = typeof t === 'string' ? t : t.iri;
399
+ const expanded = expandIRI(iri, ctx);
400
+ return shortenIRI(expanded, ctx);
401
+ }).join(' ');
402
+ attrs.push(`typeof="${escapeHtml(types)}"`);
437
403
  }
438
- if (reverseProps.length > 0) {
439
- attrs.push(`rev="${escapeHtml(reverseProps.join(' '))}"`);
404
+
405
+ // Predicates using shared utility
406
+ if (block.predicates && block.predicates.length > 0) {
407
+ const { literalProps, objectProps, reverseProps } = processPredicates(block.predicates, ctx);
408
+
409
+ if (literalProps.length > 0) {
410
+ attrs.push(`property="${escapeHtml(literalProps.join(' '))}"`);
411
+ }
412
+ if (objectProps.length > 0) {
413
+ attrs.push(`rel="${escapeHtml(objectProps.join(' '))}"`);
414
+ }
415
+ if (reverseProps.length > 0) {
416
+ attrs.push(`rev="${escapeHtml(reverseProps.join(' '))}"`);
417
+ }
440
418
  }
441
- }
442
419
 
443
- return attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
420
+ return attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
444
421
  }
445
422
 
446
423
  /**
447
424
  * Generate prefix declarations for RDFa
448
425
  */
449
426
  function generatePrefixDeclarations(ctx) {
450
- const prefixes = [];
427
+ const prefixes = [];
451
428
 
452
- for (const [prefix, iri] of Object.entries(ctx)) {
453
- if (prefix !== '@vocab') {
454
- prefixes.push(`${prefix}: ${iri}`);
429
+ for (const [prefix, iri] of Object.entries(ctx)) {
430
+ if (prefix !== '@vocab') {
431
+ prefixes.push(`${prefix}: ${iri}`);
432
+ }
455
433
  }
456
- }
457
434
 
458
- return prefixes.length > 0 ? ` prefix="${prefixes.join(' ')}"` : '';
435
+ return prefixes.length > 0 ? ` prefix="${prefixes.join(' ')}"` : '';
459
436
  }
460
437
 
461
438
  /**
462
439
  * Generate vocabulary declaration
463
440
  */
464
441
  function generateVocabDeclaration(ctx) {
465
- return ctx['@vocab'] ? ` vocab="${ctx['@vocab']}"` : '';
442
+ return ctx['@vocab'] ? ` vocab="${ctx['@vocab']}"` : '';
466
443
  }
467
444
 
468
445
  /**
469
446
  * Wrap HTML with RDFa context declarations
470
447
  */
471
448
  function wrapWithRDFaContext(html, ctx) {
472
- const prefixDecl = generatePrefixDeclarations(ctx);
473
- const vocabDecl = generateVocabDeclaration(ctx);
449
+ const prefixDecl = generatePrefixDeclarations(ctx);
450
+ const vocabDecl = generateVocabDeclaration(ctx);
474
451
 
475
- return `<div${prefixDecl}${vocabDecl}>${html}</div>`;
476
- }
477
-
478
- /**
479
- * Escape HTML special characters
480
- */
481
- function escapeHtml(text) {
482
- const map = {
483
- '&': '&amp;',
484
- '<': '&lt;',
485
- '>': '&gt;',
486
- '"': '&quot;',
487
- "'": '&#39;'
488
- };
489
- return String(text || '').replace(/[&<>"']/g, m => map[m]);
452
+ return `<div${prefixDecl}${vocabDecl}>${html}</div>`;
490
453
  }