@refrakt-md/editor 0.8.3 → 0.8.5
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/app/dist/assets/{index-CQDCT-XT.js → index-BV3goZlJ.js} +1 -1
- package/app/dist/assets/{index-DezxtfNV.js → index-BXrXHIy0.js} +1 -1
- package/app/dist/assets/{index-COFbngzR.js → index-BnN-_mNn.js} +1 -1
- package/app/dist/assets/{index-B7e694w6.js → index-C1WrA8T3.js} +1 -1
- package/app/dist/assets/{index-CPEo_rvd.js → index-C1gBeCLd.js} +1 -1
- package/app/dist/assets/{index-3MvwKRVQ.js → index-CAmKzaYH.js} +1 -1
- package/app/dist/assets/{index-BjlNcvOf.js → index-CTFb0PXm.js} +1 -1
- package/app/dist/assets/{index-BGy7ixjW.js → index-CcdBReM4.js} +1 -1
- package/app/dist/assets/{index-CKfKYVw7.js → index-Cmi9Zmvg.js} +1 -1
- package/app/dist/assets/{index-CUmEjEeR.js → index-CoDOdTlL.js} +1 -1
- package/app/dist/assets/{index-DwfxgjnU.js → index-D6Wd1wiF.js} +1 -1
- package/app/dist/assets/{index-BEGy_i8o.js → index-DBEBbKSr.js} +1 -1
- package/app/dist/assets/{index-ChbH55h5.js → index-D_JLbBJP.js} +1 -1
- package/app/dist/assets/{index-BaLgiiKk.js → index-DhbHcMvG.js} +1 -1
- package/app/dist/assets/{index-DrI4IfXE.js → index-Dl9k73LF.js} +1 -1
- package/app/dist/assets/{index-CeV-Af4N.js → index-HSMIxxw0.js} +1 -1
- package/app/dist/assets/{index-D9-aYc3I.js → index-Nji8szQy.js} +1 -1
- package/app/dist/assets/{index-ogrpJNou.js → index-Y1_ZkrfC.js} +175 -174
- package/app/dist/assets/{index-BBljOYQu.js → index-n8MtxI3L.js} +1 -1
- package/app/dist/index.html +1 -1
- package/app/src/lib/api/client.ts +1 -0
- package/app/src/lib/editor/attribute-completion.ts +159 -0
- package/app/src/lib/editor/codemirror-theme.ts +115 -0
- package/app/src/lib/editor/content-model-resolver.ts +196 -0
- package/app/src/lib/editor/inline-markdown.ts +237 -0
- package/app/src/lib/editor/markdoc-highlight.ts +74 -0
- package/app/src/lib/editor/rune-palette.ts +95 -0
- package/app/src/lib/editor/section-mapper.ts +476 -0
- package/app/src/lib/state/editor.svelte.ts +151 -0
- package/app/src/lib/utils/frontmatter.ts +43 -0
- package/app/src/lib/utils/layout-parser.ts +197 -0
- package/dist/server.js +15 -32
- package/dist/server.js.map +1 -1
- package/package.json +10 -8
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as O,L as b,b as r,l as s,f as a,m as t,s as P,t as e,g as n}from"./index-
|
|
1
|
+
import{a as O,L as b,b as r,l as s,f as a,m as t,s as P,t as e,g as n}from"./index-Y1_ZkrfC.js";const S={__proto__:null,anyref:34,dataref:34,eqref:34,externref:34,i31ref:34,funcref:34,i8:34,i16:34,i32:34,i64:34,f32:34,f64:34},Q=n.deserialize({version:14,states:"!^Q]QPOOOqQPO'#CbOOQO'#Cd'#CdOOQO'#Cl'#ClOOQO'#Ch'#ChQ]QPOOOOQO,58|,58|OxQPO,58|OOQO-E6f-E6fOOQO1G.h1G.h",stateData:"!P~O_OSPOSQOS~OTPOVROXROYROZROaQO~OSUO~P]OSXO~P]O",goto:"xaPPPPPPbPbPPPhPPPrXROPTVQTOQVPTWTVXSOPTV",nodeNames:"⚠ LineComment BlockComment Module ) ( App Identifier Type Keyword Number String",maxTerm:17,nodeProps:[["isolate",-3,1,2,11,""],["openedBy",4,"("],["closedBy",5,")"],["group",-6,6,7,8,9,10,11,"Expression"]],skippedNodes:[0,1,2],repeatNodeCount:1,tokenData:"0o~R^XY}YZ}]^}pq}rs!Stu#pxy'Uyz(e{|(j}!O(j!Q!R(s!R![*p!]!^.^#T#o.{~!SO_~~!VVOr!Srs!ls#O!S#O#P!q#P;'S!S;'S;=`#j<%lO!S~!qOZ~~!tRO;'S!S;'S;=`!};=`O!S~#QWOr!Srs!ls#O!S#O#P!q#P;'S!S;'S;=`#j;=`<%l!S<%lO!S~#mP;=`<%l!S~#siqr%bst%btu%buv%bvw%bwx%bz{%b{|%b}!O%b!O!P%b!P!Q%b!Q![%b![!]%b!^!_%b!_!`%b!`!a%b!a!b%b!b!c%b!c!}%b#Q#R%b#R#S%b#S#T%b#T#o%b#p#q%b#r#s%b~%giV~qr%bst%btu%buv%bvw%bwx%bz{%b{|%b}!O%b!O!P%b!P!Q%b!Q![%b![!]%b!^!_%b!_!`%b!`!a%b!a!b%b!b!c%b!c!}%b#Q#R%b#R#S%b#S#T%b#T#o%b#p#q%b#r#s%b~'ZPT~!]!^'^~'aTO!]'^!]!^'p!^;'S'^;'S;=`(_<%lO'^~'sVOy'^yz(Yz!]'^!]!^'p!^;'S'^;'S;=`(_<%lO'^~(_OQ~~(bP;=`<%l'^~(jOS~~(mQ!Q!R(s!R![*p~(xUY~!O!P)[!Q![*p!g!h){#R#S+U#X#Y){#l#m+[~)aRY~!Q![)j!g!h){#X#Y){~)oSY~!Q![)j!g!h){#R#S*j#X#Y){~*OR{|*X}!O*X!Q![*_~*[P!Q![*_~*dQY~!Q![*_#R#S*X~*mP!Q![)j~*uTY~!O!P)[!Q![*p!g!h){#R#S+U#X#Y){~+XP!Q![*p~+_R!Q![+h!c!i+h#T#Z+h~+mVY~!O!P,S!Q![+h!c!i+h!r!s-P#R#S+[#T#Z+h#d#e-P~,XTY~!Q![,h!c!i,h!r!s-P#T#Z,h#d#e-P~,mUY~!Q![,h!c!i,h!r!s-P#R#S.Q#T#Z,h#d#e-P~-ST{|-c}!O-c!Q![-o!c!i-o#T#Z-o~-fR!Q![-o!c!i-o#T#Z-o~-tSY~!Q![-o!c!i-o#R#S-c#T#Z-o~.TR!Q![,h!c!i,h#T#Z,h~.aP!]!^.d~.iSP~OY.dZ;'S.d;'S;=`.u<%lO.d~.xP;=`<%l.d~/QiX~qr.{st.{tu.{uv.{vw.{wx.{z{.{{|.{}!O.{!O!P.{!P!Q.{!Q![.{![!].{!^!_.{!_!`.{!`!a.{!a!b.{!b!c.{!c!}.{#Q#R.{#R#S.{#S#T.{#T#o.{#p#q.{#r#s.{",tokenizers:[0],topRules:{Module:[0,3]},specialized:[{term:9,get:o=>S[o]||-1}],tokenPrec:0}),i=b.define({name:"wast",parser:Q.configure({props:[r.add({App:s({closing:")",align:!1})}),a.add({App:t,BlockComment(o){return{from:o.from+2,to:o.to-2}}}),P({Keyword:e.keyword,Type:e.typeName,Number:e.number,String:e.string,Identifier:e.variableName,LineComment:e.lineComment,BlockComment:e.blockComment,"( )":e.paren})]}),languageData:{commentTokens:{line:";;",block:{open:"(;",close:";)"}},closeBrackets:{brackets:["(",'"']}}});function d(){return new O(i)}export{d as wast,i as wastLanguage};
|
package/app/dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>refrakt editor</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-Y1_ZkrfC.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-CzvG5PZT.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CompletionContext,
|
|
3
|
+
type CompletionResult,
|
|
4
|
+
type CompletionSource,
|
|
5
|
+
} from '@codemirror/autocomplete';
|
|
6
|
+
import type { RuneInfo } from '../api/client.js';
|
|
7
|
+
|
|
8
|
+
type AggregatedData = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find the Markdoc tag context at the cursor position.
|
|
12
|
+
* Returns the tag name and whether we're completing an attribute value.
|
|
13
|
+
*/
|
|
14
|
+
function findTagContext(
|
|
15
|
+
doc: string,
|
|
16
|
+
pos: number,
|
|
17
|
+
): { tagName: string; attrName?: string; valueStart?: number } | null {
|
|
18
|
+
const before = doc.slice(Math.max(0, pos - 500), pos);
|
|
19
|
+
|
|
20
|
+
const lastClose = before.lastIndexOf('%}');
|
|
21
|
+
const lastOpen = before.lastIndexOf('{%');
|
|
22
|
+
|
|
23
|
+
if (lastOpen === -1 || (lastClose !== -1 && lastClose > lastOpen)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tagContent = before.slice(lastOpen + 2).trimStart();
|
|
28
|
+
|
|
29
|
+
if (tagContent.startsWith('/')) return null;
|
|
30
|
+
|
|
31
|
+
const nameMatch = tagContent.match(/^(\w[\w-]*)/);
|
|
32
|
+
if (!nameMatch) return null;
|
|
33
|
+
const tagName = nameMatch[1];
|
|
34
|
+
|
|
35
|
+
const afterName = tagContent.slice(nameMatch[0].length);
|
|
36
|
+
|
|
37
|
+
// Check if we're inside an attribute value: attr="...
|
|
38
|
+
const valueMatch = afterName.match(/(\w[\w-]*)="[^"]*$/);
|
|
39
|
+
if (valueMatch) {
|
|
40
|
+
const quoteIdx = before.lastIndexOf('="');
|
|
41
|
+
return {
|
|
42
|
+
tagName,
|
|
43
|
+
attrName: valueMatch[1],
|
|
44
|
+
valueStart: Math.max(0, pos - 500) + quoteIdx + 2,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { tagName };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns dynamic completion values for a given rune + attribute combination.
|
|
53
|
+
* Currently supports:
|
|
54
|
+
* - sandbox.context → design-context scope names
|
|
55
|
+
* - design-context.scope → existing scope names (to warn of collisions)
|
|
56
|
+
*/
|
|
57
|
+
function getDynamicValues(tagName: string, attrName: string, aggregated: AggregatedData): string[] {
|
|
58
|
+
const design = aggregated['design'] as { contexts?: Record<string, unknown> } | undefined;
|
|
59
|
+
if (!design?.contexts) return [];
|
|
60
|
+
const scopes = Object.keys(design.contexts);
|
|
61
|
+
if ((tagName === 'sandbox' && attrName === 'context') ||
|
|
62
|
+
(tagName === 'design-context' && attrName === 'scope')) {
|
|
63
|
+
return scopes;
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Creates a CompletionSource for Markdoc tag attribute names and values.
|
|
70
|
+
* Accepts getter functions so runes and aggregated data are read at query time.
|
|
71
|
+
*/
|
|
72
|
+
export function attributeCompletionSource(
|
|
73
|
+
getRunes: () => RuneInfo[],
|
|
74
|
+
getAggregated?: () => AggregatedData,
|
|
75
|
+
): CompletionSource {
|
|
76
|
+
return (context: CompletionContext): CompletionResult | null => {
|
|
77
|
+
const doc = context.state.doc.toString();
|
|
78
|
+
const pos = context.pos;
|
|
79
|
+
|
|
80
|
+
const tagCtx = findTagContext(doc, pos);
|
|
81
|
+
if (!tagCtx) return null;
|
|
82
|
+
|
|
83
|
+
const runes = getRunes();
|
|
84
|
+
const runeMap = new Map<string, RuneInfo>();
|
|
85
|
+
for (const r of runes) {
|
|
86
|
+
runeMap.set(r.name, r);
|
|
87
|
+
for (const alias of r.aliases) {
|
|
88
|
+
runeMap.set(alias, r);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rune = runeMap.get(tagCtx.tagName);
|
|
93
|
+
if (!rune) return null;
|
|
94
|
+
|
|
95
|
+
// Completing attribute value
|
|
96
|
+
if (tagCtx.attrName && tagCtx.valueStart !== undefined) {
|
|
97
|
+
const attr = rune.attributes[tagCtx.attrName];
|
|
98
|
+
|
|
99
|
+
// Try static enum values first
|
|
100
|
+
if (attr?.values?.length) {
|
|
101
|
+
return {
|
|
102
|
+
from: tagCtx.valueStart,
|
|
103
|
+
options: attr.values.map((v) => ({
|
|
104
|
+
label: v,
|
|
105
|
+
type: 'enum',
|
|
106
|
+
})),
|
|
107
|
+
filter: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fall back to dynamic values from aggregated data
|
|
112
|
+
const dynamic = getAggregated
|
|
113
|
+
? getDynamicValues(tagCtx.tagName, tagCtx.attrName, getAggregated())
|
|
114
|
+
: [];
|
|
115
|
+
if (dynamic.length) {
|
|
116
|
+
return {
|
|
117
|
+
from: tagCtx.valueStart,
|
|
118
|
+
options: dynamic.map((v) => ({
|
|
119
|
+
label: v,
|
|
120
|
+
type: 'variable',
|
|
121
|
+
})),
|
|
122
|
+
filter: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Completing attribute name
|
|
130
|
+
const word = context.matchBefore(/\w*/);
|
|
131
|
+
if (!word) return null;
|
|
132
|
+
|
|
133
|
+
if (!context.explicit && word.from === word.to) return null;
|
|
134
|
+
|
|
135
|
+
// Get already-used attributes
|
|
136
|
+
const before = doc.slice(Math.max(0, pos - 500), pos);
|
|
137
|
+
const usedAttrs = new Set(
|
|
138
|
+
[...before.matchAll(/(\w[\w-]*)="/g)].map((m) => m[1]),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const options = Object.entries(rune.attributes)
|
|
142
|
+
.filter(([name]) => !usedAttrs.has(name))
|
|
143
|
+
.map(([name, attr]) => ({
|
|
144
|
+
label: name,
|
|
145
|
+
detail: attr.required ? 'required' : undefined,
|
|
146
|
+
type: 'property' as const,
|
|
147
|
+
apply: `${name}="`,
|
|
148
|
+
boost: attr.required ? 1 : 0,
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
if (options.length === 0) return null;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
from: word.from,
|
|
155
|
+
options,
|
|
156
|
+
filter: true,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { EditorView } from '@codemirror/view';
|
|
2
|
+
import { HighlightStyle } from '@codemirror/language';
|
|
3
|
+
import { tags } from '@lezer/highlight';
|
|
4
|
+
|
|
5
|
+
export const lightTheme = EditorView.theme(
|
|
6
|
+
{
|
|
7
|
+
'&': {
|
|
8
|
+
backgroundColor: '#f8fafc',
|
|
9
|
+
color: '#1a1a2e',
|
|
10
|
+
fontSize: '13px',
|
|
11
|
+
height: '100%',
|
|
12
|
+
},
|
|
13
|
+
'.cm-scroller': {
|
|
14
|
+
overflow: 'auto',
|
|
15
|
+
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
|
|
16
|
+
lineHeight: '1.6',
|
|
17
|
+
},
|
|
18
|
+
'.cm-content': {
|
|
19
|
+
padding: '1rem 0',
|
|
20
|
+
caretColor: '#1e293b',
|
|
21
|
+
},
|
|
22
|
+
'.cm-gutters': {
|
|
23
|
+
backgroundColor: '#f8fafc',
|
|
24
|
+
color: '#94a3b8',
|
|
25
|
+
border: 'none',
|
|
26
|
+
paddingRight: '0.5rem',
|
|
27
|
+
},
|
|
28
|
+
'.cm-activeLineGutter': {
|
|
29
|
+
backgroundColor: '#e2e8f0',
|
|
30
|
+
color: '#64748b',
|
|
31
|
+
},
|
|
32
|
+
'.cm-activeLine': {
|
|
33
|
+
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
|
34
|
+
},
|
|
35
|
+
'.cm-cursor': {
|
|
36
|
+
borderLeftColor: '#1e293b',
|
|
37
|
+
},
|
|
38
|
+
'&.cm-focused .cm-selectionBackground, ::selection': {
|
|
39
|
+
backgroundColor: 'rgba(14, 165, 233, 0.2)',
|
|
40
|
+
},
|
|
41
|
+
'.cm-selectionBackground': {
|
|
42
|
+
backgroundColor: 'rgba(14, 165, 233, 0.12)',
|
|
43
|
+
},
|
|
44
|
+
'&.cm-focused': {
|
|
45
|
+
outline: 'none',
|
|
46
|
+
},
|
|
47
|
+
// Autocomplete dropdown styling
|
|
48
|
+
'.cm-tooltip.cm-tooltip-autocomplete': {
|
|
49
|
+
border: '1px solid #e2e8f0',
|
|
50
|
+
borderRadius: '6px',
|
|
51
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.12)',
|
|
52
|
+
backgroundColor: '#ffffff',
|
|
53
|
+
overflow: 'hidden',
|
|
54
|
+
},
|
|
55
|
+
'.cm-tooltip.cm-tooltip-autocomplete ul': {
|
|
56
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
57
|
+
fontSize: '12px',
|
|
58
|
+
maxHeight: '280px',
|
|
59
|
+
},
|
|
60
|
+
'.cm-tooltip.cm-tooltip-autocomplete ul li': {
|
|
61
|
+
padding: '4px 8px',
|
|
62
|
+
borderBottom: '1px solid #f1f5f9',
|
|
63
|
+
},
|
|
64
|
+
'.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
|
|
65
|
+
backgroundColor: '#f0f9ff',
|
|
66
|
+
color: '#0369a1',
|
|
67
|
+
},
|
|
68
|
+
'.cm-tooltip.cm-completionInfo': {
|
|
69
|
+
border: '1px solid #e2e8f0',
|
|
70
|
+
borderRadius: '6px',
|
|
71
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
72
|
+
backgroundColor: '#ffffff',
|
|
73
|
+
padding: '6px 10px',
|
|
74
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
75
|
+
fontSize: '12px',
|
|
76
|
+
color: '#475569',
|
|
77
|
+
maxWidth: '300px',
|
|
78
|
+
},
|
|
79
|
+
'.cm-completionDetail': {
|
|
80
|
+
color: '#94a3b8',
|
|
81
|
+
fontStyle: 'normal',
|
|
82
|
+
marginLeft: '0.5em',
|
|
83
|
+
},
|
|
84
|
+
// Markdoc tag highlighting
|
|
85
|
+
'.cm-markdoc-tag': {
|
|
86
|
+
backgroundColor: 'rgba(217, 119, 6, 0.06)',
|
|
87
|
+
borderRadius: '2px',
|
|
88
|
+
},
|
|
89
|
+
'.cm-markdoc-bracket': {
|
|
90
|
+
color: '#94a3b8',
|
|
91
|
+
},
|
|
92
|
+
'.cm-markdoc-name': {
|
|
93
|
+
color: '#d97706',
|
|
94
|
+
fontWeight: '600',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{ dark: false },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
export const highlightTheme = HighlightStyle.define([
|
|
101
|
+
{ tag: tags.heading1, color: '#0369a1', fontWeight: 'bold', fontSize: '1.4em' },
|
|
102
|
+
{ tag: tags.heading2, color: '#0369a1', fontWeight: 'bold', fontSize: '1.2em' },
|
|
103
|
+
{ tag: tags.heading3, color: '#0369a1', fontWeight: 'bold', fontSize: '1.1em' },
|
|
104
|
+
{ tag: tags.heading, color: '#0369a1', fontWeight: 'bold' },
|
|
105
|
+
{ tag: tags.emphasis, color: '#9333ea', fontStyle: 'italic' },
|
|
106
|
+
{ tag: tags.strong, color: '#9333ea', fontWeight: 'bold' },
|
|
107
|
+
{ tag: tags.link, color: '#0ea5e9', textDecoration: 'underline' },
|
|
108
|
+
{ tag: tags.url, color: '#0ea5e9' },
|
|
109
|
+
{ tag: tags.quote, color: '#64748b' },
|
|
110
|
+
{ tag: tags.monospace, color: '#16a34a' },
|
|
111
|
+
{ tag: tags.processingInstruction, color: '#d97706' },
|
|
112
|
+
{ tag: tags.meta, color: '#94a3b8' },
|
|
113
|
+
{ tag: tags.comment, color: '#94a3b8' },
|
|
114
|
+
{ tag: tags.punctuation, color: '#94a3b8' },
|
|
115
|
+
]);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side content model resolver.
|
|
3
|
+
*
|
|
4
|
+
* Matches parsed ContentNodes (from parseContentTree) against a serialized
|
|
5
|
+
* content model to determine which fields are filled and which are empty.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ContentNode } from './block-parser.js';
|
|
9
|
+
import type {
|
|
10
|
+
SerializedContentModel,
|
|
11
|
+
SerializedFieldDef,
|
|
12
|
+
SerializedSequenceModel,
|
|
13
|
+
SerializedDelimitedZone,
|
|
14
|
+
} from '../api/client.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Output types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface ResolvedField {
|
|
21
|
+
/** Field name from the content model */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Expected node type pattern (e.g. 'paragraph', 'heading', 'list|fence') */
|
|
24
|
+
match: string;
|
|
25
|
+
/** Whether the field can be absent */
|
|
26
|
+
optional: boolean;
|
|
27
|
+
/** Whether the field consumes multiple consecutive matches */
|
|
28
|
+
greedy: boolean;
|
|
29
|
+
/** Whether content was matched to this field */
|
|
30
|
+
filled: boolean;
|
|
31
|
+
/** Matched content nodes (empty if unfilled) */
|
|
32
|
+
nodes: ContentNode[];
|
|
33
|
+
/** Human-readable description from field definition */
|
|
34
|
+
description?: string;
|
|
35
|
+
/** Markdoc template for inserting content */
|
|
36
|
+
template?: string;
|
|
37
|
+
/** If field emits child rune tags */
|
|
38
|
+
emitTag?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ResolvedZone {
|
|
42
|
+
name: string;
|
|
43
|
+
fields: ResolvedField[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ResolvedStructure =
|
|
47
|
+
| { type: 'sequence'; fields: ResolvedField[] }
|
|
48
|
+
| { type: 'delimited'; delimiter: string; zones: ResolvedZone[] }
|
|
49
|
+
| { type: 'sections'; description: string }
|
|
50
|
+
| { type: 'custom'; description: string };
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Node matching
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Check if a ContentNode matches a field's match pattern */
|
|
57
|
+
function nodeMatchesType(node: ContentNode, match: string): boolean {
|
|
58
|
+
if (match === 'any') return true;
|
|
59
|
+
|
|
60
|
+
// Pipe-separated alternatives: 'list|fence'
|
|
61
|
+
if (match.includes('|')) {
|
|
62
|
+
return match.split('|').some(m => nodeMatchesType(node, m));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// heading:N
|
|
66
|
+
if (match.startsWith('heading:')) {
|
|
67
|
+
const level = parseInt(match.slice(8), 10);
|
|
68
|
+
return node.type === 'heading' && node.headingLevel === level;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// list:ordered / list:unordered
|
|
72
|
+
if (match === 'list:ordered') {
|
|
73
|
+
return node.type === 'list' && node.listOrdered === true;
|
|
74
|
+
}
|
|
75
|
+
if (match === 'list:unordered') {
|
|
76
|
+
return node.type === 'list' && node.listOrdered !== true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// tag:NAME — ContentNodes use 'rune' type
|
|
80
|
+
if (match.startsWith('tag:')) {
|
|
81
|
+
const tagName = match.slice(4);
|
|
82
|
+
return node.type === 'rune' && node.runeName === tagName;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Simple type match (ContentNode types: rune, heading, paragraph, fence, list, quote, hr, image)
|
|
86
|
+
// Map content model match names to ContentNode type names
|
|
87
|
+
if (match === 'blockquote') return node.type === 'quote';
|
|
88
|
+
|
|
89
|
+
return node.type === match;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Sequence resolver
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function resolveSequence(nodes: ContentNode[], fields: SerializedFieldDef[]): ResolvedField[] {
|
|
97
|
+
const result: ResolvedField[] = [];
|
|
98
|
+
let cursor = 0;
|
|
99
|
+
|
|
100
|
+
for (const field of fields) {
|
|
101
|
+
const resolved: ResolvedField = {
|
|
102
|
+
name: field.name,
|
|
103
|
+
match: field.match,
|
|
104
|
+
optional: field.optional ?? false,
|
|
105
|
+
greedy: field.greedy ?? false,
|
|
106
|
+
filled: false,
|
|
107
|
+
nodes: [],
|
|
108
|
+
description: field.description,
|
|
109
|
+
template: field.template,
|
|
110
|
+
emitTag: field.emitTag,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (field.greedy) {
|
|
114
|
+
// Consume all consecutive matching nodes
|
|
115
|
+
while (cursor < nodes.length && nodeMatchesType(nodes[cursor], field.match)) {
|
|
116
|
+
resolved.nodes.push(nodes[cursor]);
|
|
117
|
+
cursor++;
|
|
118
|
+
}
|
|
119
|
+
resolved.filled = resolved.nodes.length > 0;
|
|
120
|
+
} else {
|
|
121
|
+
// Match the next node if it fits
|
|
122
|
+
if (cursor < nodes.length && nodeMatchesType(nodes[cursor], field.match)) {
|
|
123
|
+
resolved.nodes.push(nodes[cursor]);
|
|
124
|
+
resolved.filled = true;
|
|
125
|
+
cursor++;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
result.push(resolved);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Main resolver
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a content tree against a serialized content model.
|
|
141
|
+
* Returns a structure describing which fields are filled and which are empty.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveContentStructure(
|
|
144
|
+
nodes: ContentNode[],
|
|
145
|
+
model: SerializedContentModel,
|
|
146
|
+
): ResolvedStructure {
|
|
147
|
+
switch (model.type) {
|
|
148
|
+
case 'sequence':
|
|
149
|
+
return {
|
|
150
|
+
type: 'sequence',
|
|
151
|
+
fields: resolveSequence(nodes, model.fields),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
case 'delimited': {
|
|
155
|
+
// Split nodes at delimiter (hr) boundaries
|
|
156
|
+
const zones: ResolvedZone[] = [];
|
|
157
|
+
const chunks: ContentNode[][] = [[]];
|
|
158
|
+
for (const node of nodes) {
|
|
159
|
+
if (node.type === 'hr') {
|
|
160
|
+
chunks.push([]);
|
|
161
|
+
} else {
|
|
162
|
+
chunks[chunks.length - 1].push(node);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (model.zones) {
|
|
167
|
+
for (let i = 0; i < model.zones.length; i++) {
|
|
168
|
+
const zone = model.zones[i];
|
|
169
|
+
const chunk = chunks[i] ?? [];
|
|
170
|
+
zones.push({
|
|
171
|
+
name: zone.name,
|
|
172
|
+
fields: resolveSequence(chunk, zone.fields),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
} else if (model.dynamicZones && model.zoneModel) {
|
|
176
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
177
|
+
zones.push({
|
|
178
|
+
name: `zone-${i + 1}`,
|
|
179
|
+
fields: resolveSequence(chunks[i], model.zoneModel.fields),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { type: 'delimited', delimiter: model.delimiter, zones };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'sections':
|
|
188
|
+
return {
|
|
189
|
+
type: 'sections',
|
|
190
|
+
description: `Sections split by ${model.sectionHeading}${model.emitTag ? `, each emits ${model.emitTag}` : ''}`,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
case 'custom':
|
|
194
|
+
return { type: 'custom', description: model.description };
|
|
195
|
+
}
|
|
196
|
+
}
|