patram 0.3.0 → 0.5.0

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.
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { Ansis } from 'ansis';
7
7
 
8
+ const MAX_TEXT_EVIDENCE_ROWS = 5;
9
+
8
10
  /**
9
11
  * Render field discovery output.
10
12
  *
@@ -109,21 +111,21 @@ function formatTextFieldSuggestion(field_suggestion, render_options) {
109
111
  );
110
112
 
111
113
  if (field_suggestion.evidence_references.length > 0) {
112
- lines.push(render_options.label(' evidence:'));
113
114
  lines.push(
114
- ...field_suggestion.evidence_references.map(
115
- (evidence_reference) =>
116
- `${render_options.label(' ')}${formatEvidenceReference(evidence_reference)}`,
115
+ ...formatTextEvidenceSection(
116
+ ' evidence:',
117
+ field_suggestion.evidence_references,
118
+ render_options,
117
119
  ),
118
120
  );
119
121
  }
120
122
 
121
123
  if (field_suggestion.conflicting_evidence.length > 0) {
122
- lines.push(render_options.label(' conflicting evidence:'));
123
124
  lines.push(
124
- ...field_suggestion.conflicting_evidence.map(
125
- (evidence_reference) =>
126
- `${render_options.label(' ')}${formatEvidenceReference(evidence_reference)}`,
125
+ ...formatTextEvidenceSection(
126
+ ' conflicting evidence:',
127
+ field_suggestion.conflicting_evidence,
128
+ render_options,
127
129
  ),
128
130
  );
129
131
  }
@@ -131,6 +133,40 @@ function formatTextFieldSuggestion(field_suggestion, render_options) {
131
133
  return lines;
132
134
  }
133
135
 
136
+ /**
137
+ * @param {string} section_title
138
+ * @param {import('./discover-fields.types.ts').FieldDiscoveryEvidenceReference[]} evidence_references
139
+ * @param {{ header: (value: string) => string, label: (value: string) => string }} render_options
140
+ * @returns {string[]}
141
+ */
142
+ function formatTextEvidenceSection(
143
+ section_title,
144
+ evidence_references,
145
+ render_options,
146
+ ) {
147
+ /** @type {string[]} */
148
+ const lines = [render_options.label(section_title)];
149
+ const visible_evidence_references = evidence_references.slice(
150
+ 0,
151
+ MAX_TEXT_EVIDENCE_ROWS,
152
+ );
153
+
154
+ lines.push(
155
+ ...visible_evidence_references.map(
156
+ (evidence_reference) =>
157
+ `${render_options.label(' ')}${formatEvidenceReference(evidence_reference)}`,
158
+ ),
159
+ );
160
+
161
+ if (evidence_references.length > MAX_TEXT_EVIDENCE_ROWS) {
162
+ const remaining_count = evidence_references.length - MAX_TEXT_EVIDENCE_ROWS;
163
+
164
+ lines.push(render_options.label(` ${remaining_count} more ...`));
165
+ }
166
+
167
+ return lines;
168
+ }
169
+
134
170
  /**
135
171
  * @param {import('./discover-fields.types.ts').FieldDiscoveryEvidenceReference} evidence_reference
136
172
  * @returns {string}
@@ -1,16 +1,16 @@
1
- /** @import * as $k$$l$output$j$view$k$types$k$ts from './output-view.types.ts'; */
2
- /* eslint-disable max-lines */
3
1
  /**
4
2
  * @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
5
3
  * @import { DerivedSummaryEvaluator } from './derived-summary.js';
6
4
  * @import { PatramRepoConfig } from './load-patram-config.types.ts';
7
5
  * @import { ParsedCliArguments } from './parse-cli-arguments.types.ts';
8
- * @import { OutputMetadataField, OutputStoredQueryItem, OutputView, ResolvedOutputMode, ShowOutputView } from './output-view.types.ts';
6
+ * @import { OutputDerivedSummary, OutputMetadataField, OutputNodeItem, OutputResolvedLinkItem, OutputResolvedLinkTarget, OutputStoredQueryItem, OutputView, ResolvedOutputMode, ShowOutputView } from './output-view.types.ts';
9
7
  */
8
+ /* eslint-disable max-lines */
10
9
 
11
10
  import { renderJsonOutput } from './render-json-output.js';
12
11
  import { renderPlainOutput } from './render-plain-output.js';
13
12
  import { renderRichOutput } from './render-rich-output.js';
13
+ import { resolveDocumentNodeId } from './build-graph-identity.js';
14
14
 
15
15
  /**
16
16
  * Shared command output views.
@@ -56,12 +56,15 @@ export function createOutputView(command_name, command_items, command_options) {
56
56
  * Create a shared output view for the show command.
57
57
  *
58
58
  * @param {{ path: string, rendered_source: string, resolved_links: Array<{ label: string, reference: number, target: { kind?: string, path: string, status?: string, title: string } }>, source: string }} show_output
59
- * @param {{ derived_summary_evaluator?: DerivedSummaryEvaluator, graph_nodes?: BuildGraphResult['nodes'], repo_config?: PatramRepoConfig }=} command_options
59
+ * @param {{ derived_summary_evaluator?: DerivedSummaryEvaluator, document_node_ids?: BuildGraphResult['document_node_ids'], graph_nodes?: BuildGraphResult['nodes'], repo_config?: PatramRepoConfig }=} command_options
60
60
  * @returns {ShowOutputView}
61
61
  */
62
62
  export function createShowOutputView(show_output, command_options = {}) {
63
- const shown_document_node =
64
- command_options.graph_nodes?.[`doc:${show_output.path}`];
63
+ const shown_document_node = resolveDocumentGraphNode(
64
+ command_options.graph_nodes,
65
+ command_options.document_node_ids,
66
+ show_output.path,
67
+ );
65
68
 
66
69
  return {
67
70
  command: 'show',
@@ -75,20 +78,9 @@ export function createShowOutputView(show_output, command_options = {}) {
75
78
  )
76
79
  : undefined,
77
80
  hints: [],
78
- items: show_output.resolved_links.map((resolved_link) => ({
79
- kind: 'resolved_link',
80
- label: resolved_link.label,
81
- reference: resolved_link.reference,
82
- target: createResolvedLinkTarget(
83
- resolved_link.target,
84
- command_options.repo_config?.fields ?? {},
85
- command_options.graph_nodes?.[`doc:${resolved_link.target.path}`]
86
- ? (command_options.derived_summary_evaluator?.evaluate(
87
- command_options.graph_nodes[`doc:${resolved_link.target.path}`],
88
- ) ?? null)
89
- : null,
90
- ),
91
- })),
81
+ items: show_output.resolved_links.map((resolved_link) =>
82
+ createResolvedLinkOutputItem(resolved_link, command_options),
83
+ ),
92
84
  path: show_output.path,
93
85
  rendered_source: show_output.rendered_source,
94
86
  source: show_output.source,
@@ -178,9 +170,9 @@ function createStoredQueriesOutputView(stored_queries) {
178
170
 
179
171
  /**
180
172
  * @param {GraphNode} graph_node
181
- * @param {import('./output-view.types.ts').OutputDerivedSummary | null} derived_summary
173
+ * @param {OutputDerivedSummary | null} derived_summary
182
174
  * @param {NonNullable<PatramRepoConfig['fields']>} field_definitions
183
- * @returns {$k$$l$output$j$view$k$types$k$ts.OutputNodeItem}
175
+ * @returns {OutputNodeItem}
184
176
  */
185
177
  function createOutputNodeItem(graph_node, derived_summary, field_definitions) {
186
178
  const title = getOutputNodeTitle(graph_node);
@@ -210,10 +202,16 @@ function createOutputNodeItem(graph_node, derived_summary, field_definitions) {
210
202
  /**
211
203
  * @param {{ kind?: string, path: string, status?: string, title: string }} target
212
204
  * @param {NonNullable<PatramRepoConfig['fields']>} field_definitions
213
- * @param {import('./output-view.types.ts').OutputDerivedSummary | null} derived_summary
214
- * @returns {$k$$l$output$j$view$k$types$k$ts.OutputResolvedLinkTarget}
205
+ * @param {OutputDerivedSummary | null} derived_summary
206
+ * @param {GraphNode | undefined} graph_node
207
+ * @returns {OutputResolvedLinkTarget}
215
208
  */
216
- function createResolvedLinkTarget(target, field_definitions, derived_summary) {
209
+ function createResolvedLinkTarget(
210
+ target,
211
+ field_definitions,
212
+ derived_summary,
213
+ graph_node,
214
+ ) {
217
215
  /** @type {Record<string, string | string[]>} */
218
216
  const fields = {};
219
217
 
@@ -221,11 +219,11 @@ function createResolvedLinkTarget(target, field_definitions, derived_summary) {
221
219
  fields.status = target.status;
222
220
  }
223
221
 
224
- /** @type {$k$$l$output$j$view$k$types$k$ts.OutputResolvedLinkTarget} */
222
+ /** @type {OutputResolvedLinkTarget} */
225
223
  const resolved_target = {
226
224
  derived_summary: derived_summary ?? undefined,
227
225
  fields,
228
- id: `doc:${target.path}`,
226
+ id: graph_node ? getOutputNodeId(graph_node) : `doc:${target.path}`,
229
227
  kind: target.kind ?? 'document',
230
228
  path: target.path,
231
229
  title: target.title,
@@ -286,6 +284,53 @@ function getOutputNodeId(graph_node) {
286
284
  );
287
285
  }
288
286
 
287
+ /**
288
+ * @param {{ label: string, reference: number, target: { kind?: string, path: string, status?: string, title: string } }} resolved_link
289
+ * @param {{ derived_summary_evaluator?: DerivedSummaryEvaluator, document_node_ids?: BuildGraphResult['document_node_ids'], graph_nodes?: BuildGraphResult['nodes'], repo_config?: PatramRepoConfig }} command_options
290
+ * @returns {OutputResolvedLinkItem}
291
+ */
292
+ function createResolvedLinkOutputItem(resolved_link, command_options) {
293
+ const target_graph_node = resolveDocumentGraphNode(
294
+ command_options.graph_nodes,
295
+ command_options.document_node_ids,
296
+ resolved_link.target.path,
297
+ );
298
+
299
+ return {
300
+ kind: 'resolved_link',
301
+ label: resolved_link.label,
302
+ reference: resolved_link.reference,
303
+ target: createResolvedLinkTarget(
304
+ resolved_link.target,
305
+ command_options.repo_config?.fields ?? {},
306
+ target_graph_node
307
+ ? (command_options.derived_summary_evaluator?.evaluate(
308
+ target_graph_node,
309
+ ) ?? null)
310
+ : null,
311
+ target_graph_node,
312
+ ),
313
+ };
314
+ }
315
+
316
+ /**
317
+ * @param {BuildGraphResult['nodes'] | undefined} graph_nodes
318
+ * @param {BuildGraphResult['document_node_ids'] | undefined} document_node_ids
319
+ * @param {string} document_path
320
+ * @returns {GraphNode | undefined}
321
+ */
322
+ function resolveDocumentGraphNode(
323
+ graph_nodes,
324
+ document_node_ids,
325
+ document_path,
326
+ ) {
327
+ if (!graph_nodes) {
328
+ return undefined;
329
+ }
330
+
331
+ return graph_nodes[resolveDocumentNodeId(document_node_ids, document_path)];
332
+ }
333
+
289
334
  /**
290
335
  * @param {GraphNode} graph_node
291
336
  * @param {NonNullable<PatramRepoConfig['fields']>} field_definitions
@@ -46,6 +46,16 @@ const SHIKI_LANGUAGE_NAMES = new Set([
46
46
  ...Object.keys(bundledLanguagesAlias),
47
47
  ]);
48
48
 
49
+ /**
50
+ * @typedef {{
51
+ * ansi: Ansis,
52
+ * next_reference: number,
53
+ * next_top_level_list: number,
54
+ * resolved_links: OutputResolvedLinkItem[],
55
+ * top_level_list_item_gaps: boolean[][]
56
+ * }} RichSourceRenderState
57
+ */
58
+
49
59
  /**
50
60
  * @param {ShowOutputView} output_view
51
61
  * @param {{ color_mode: CliColorMode, color_enabled: boolean }} render_options
@@ -70,11 +80,13 @@ async function renderRichMarkdownSource(output_view, ansi) {
70
80
  const markdown_tree = parseAST(output_view.source);
71
81
  /** @type {string[]} */
72
82
  const rendered_blocks = [];
73
- /** @type {{ ansi: Ansis, next_reference: number, resolved_links: OutputResolvedLinkItem[] }} */
83
+ /** @type {RichSourceRenderState} */
74
84
  const render_state = {
75
85
  ansi,
76
86
  next_reference: 1,
87
+ next_top_level_list: 0,
77
88
  resolved_links: output_view.items,
89
+ top_level_list_item_gaps: collectTopLevelListItemGaps(output_view.source),
78
90
  };
79
91
 
80
92
  for (const node of markdown_tree.nodes) {
@@ -107,7 +119,7 @@ async function renderRichSourceFile(source_path, source_text, ansi) {
107
119
 
108
120
  /**
109
121
  * @param {ComarkNode} node
110
- * @param {{ ansi: Ansis, next_reference: number, resolved_links: OutputResolvedLinkItem[] }} render_state
122
+ * @param {RichSourceRenderState} render_state
111
123
  * @param {number} indent_level
112
124
  * @returns {Promise<string>}
113
125
  */
@@ -131,8 +143,14 @@ async function renderBlockNode(node, render_state, indent_level) {
131
143
  return renderFencedCodeBlock(node, indent_level, render_state.ansi);
132
144
  }
133
145
 
134
- if (node_tag === 'ul' || node_tag === 'ol') {
135
- return renderListBlock(node_tag, node_children, render_state, indent_level);
146
+ if (isListTag(node_tag)) {
147
+ return renderListBlock(
148
+ /** @type {'ol' | 'ul'} */ (node_tag),
149
+ node_children,
150
+ render_state,
151
+ indent_level,
152
+ indent_level === 0 ? getNextTopLevelListItemGaps(render_state) : [],
153
+ );
136
154
  }
137
155
 
138
156
  if (node_tag === 'blockquote') {
@@ -152,7 +170,7 @@ async function renderBlockNode(node, render_state, indent_level) {
152
170
 
153
171
  /**
154
172
  * @param {ComarkNode[]} nodes
155
- * @param {{ ansi: Ansis, next_reference: number, resolved_links: OutputResolvedLinkItem[] }} render_state
173
+ * @param {RichSourceRenderState} render_state
156
174
  * @returns {string}
157
175
  */
158
176
  function renderInlineNodes(nodes, render_state) {
@@ -217,13 +235,23 @@ function renderInlineNodes(nodes, render_state) {
217
235
  /**
218
236
  * @param {'ol' | 'ul'} nodes_type
219
237
  * @param {ComarkNode[]} nodes
220
- * @param {{ ansi: Ansis, next_reference: number, resolved_links: OutputResolvedLinkItem[] }} render_state
238
+ * @param {RichSourceRenderState} render_state
221
239
  * @param {number} indent_level
240
+ * @param {boolean[]} list_item_gaps
222
241
  * @returns {Promise<string>}
223
242
  */
224
- async function renderListBlock(nodes_type, nodes, render_state, indent_level) {
243
+ async function renderListBlock(
244
+ nodes_type,
245
+ nodes,
246
+ render_state,
247
+ indent_level,
248
+ list_item_gaps,
249
+ ) {
225
250
  /** @type {string[]} */
226
251
  const rendered_items = [];
252
+ /** @type {ComarkElement | null} */
253
+ let previous_item = null;
254
+ let rendered_item_count = 0;
227
255
 
228
256
  for (let item_index = 0; item_index < nodes.length; item_index += 1) {
229
257
  const node = nodes[item_index];
@@ -232,6 +260,17 @@ async function renderListBlock(nodes_type, nodes, render_state, indent_level) {
232
260
  continue;
233
261
  }
234
262
 
263
+ if (
264
+ previous_item &&
265
+ shouldRenderListItemGap(
266
+ previous_item,
267
+ node,
268
+ list_item_gaps[rendered_item_count - 1] ?? false,
269
+ )
270
+ ) {
271
+ rendered_items.push('');
272
+ }
273
+
235
274
  rendered_items.push(
236
275
  await renderListItem(
237
276
  node,
@@ -241,6 +280,8 @@ async function renderListBlock(nodes_type, nodes, render_state, indent_level) {
241
280
  indent_level,
242
281
  ),
243
282
  );
283
+ previous_item = node;
284
+ rendered_item_count += 1;
244
285
  }
245
286
 
246
287
  return rendered_items.join('\n');
@@ -250,7 +291,7 @@ async function renderListBlock(nodes_type, nodes, render_state, indent_level) {
250
291
  * @param {ComarkElement} node
251
292
  * @param {number} item_number
252
293
  * @param {boolean} is_ordered
253
- * @param {{ ansi: Ansis, next_reference: number, resolved_links: OutputResolvedLinkItem[] }} render_state
294
+ * @param {RichSourceRenderState} render_state
254
295
  * @param {number} indent_level
255
296
  * @returns {Promise<string>}
256
297
  */
@@ -298,7 +339,7 @@ async function renderListItem(
298
339
 
299
340
  /**
300
341
  * @param {ComarkNode[]} nodes
301
- * @param {{ ansi: Ansis, next_reference: number, resolved_links: OutputResolvedLinkItem[] }} render_state
342
+ * @param {RichSourceRenderState} render_state
302
343
  * @param {number} indent_level
303
344
  * @returns {Promise<string>}
304
345
  */
@@ -461,7 +502,7 @@ function renderHeading(tag_name, nodes, ansi) {
461
502
 
462
503
  /**
463
504
  * @param {ComarkElement} node
464
- * @param {{ ansi: Ansis, next_reference: number, resolved_links: OutputResolvedLinkItem[] }} render_state
505
+ * @param {RichSourceRenderState} render_state
465
506
  * @returns {string}
466
507
  */
467
508
  function renderLinkNode(node, render_state) {
@@ -618,7 +659,11 @@ function renderCodeBlock(
618
659
  content_indent = 0,
619
660
  ) {
620
661
  const label = formatCodeBlockLabel(language_label, file_name);
621
- const content_width = measureCodeBlockWidth(label, source_lines);
662
+ const content_width = measureCodeBlockWidth(
663
+ label,
664
+ source_lines,
665
+ content_indent,
666
+ );
622
667
  /** @type {string[]} */
623
668
  const rendered_lines = [];
624
669
 
@@ -777,7 +822,7 @@ function extractTableRowCells(row_node) {
777
822
 
778
823
  /**
779
824
  * @param {ComarkElement} node
780
- * @param {{ ansi: Ansis, next_reference: number, resolved_links: OutputResolvedLinkItem[] }} render_state
825
+ * @param {RichSourceRenderState} render_state
781
826
  * @returns {{ block_nodes: ComarkElement[], lead_text: string | null }}
782
827
  */
783
828
  function collectListItemParts(node, render_state) {
@@ -836,6 +881,183 @@ function collectListItemParts(node, render_state) {
836
881
  };
837
882
  }
838
883
 
884
+ /**
885
+ * @param {{ next_top_level_list: number, top_level_list_item_gaps: boolean[][] }} render_state
886
+ * @returns {boolean[]}
887
+ */
888
+ function getNextTopLevelListItemGaps(render_state) {
889
+ const list_item_gaps =
890
+ render_state.top_level_list_item_gaps[render_state.next_top_level_list];
891
+
892
+ render_state.next_top_level_list += 1;
893
+
894
+ return list_item_gaps ?? [];
895
+ }
896
+
897
+ /**
898
+ * @param {string} source_text
899
+ * @returns {boolean[][]}
900
+ */
901
+ function collectTopLevelListItemGaps(source_text) {
902
+ const source_lines = source_text.split('\n');
903
+ /** @type {boolean[][]} */
904
+ const top_level_list_item_gaps = [];
905
+ /** @type {boolean[] | null} */
906
+ let current_list_item_gaps = null;
907
+ let saw_blank_line = false;
908
+ /** @type {{ character: '`' | '~', length: number } | null} */
909
+ let active_fence = null;
910
+
911
+ for (const source_line of source_lines) {
912
+ if (active_fence) {
913
+ if (isClosingTopLevelFenceLine(source_line, active_fence)) {
914
+ active_fence = null;
915
+ }
916
+
917
+ continue;
918
+ }
919
+
920
+ const top_level_fence = parseTopLevelFence(source_line);
921
+
922
+ if (top_level_fence) {
923
+ active_fence = top_level_fence;
924
+ current_list_item_gaps = null;
925
+ saw_blank_line = false;
926
+ continue;
927
+ }
928
+
929
+ if (source_line.trim().length === 0) {
930
+ saw_blank_line = current_list_item_gaps !== null;
931
+ continue;
932
+ }
933
+
934
+ if (isTopLevelListItemLine(source_line)) {
935
+ current_list_item_gaps = pushTopLevelListItemGap(
936
+ current_list_item_gaps,
937
+ top_level_list_item_gaps,
938
+ saw_blank_line,
939
+ );
940
+ saw_blank_line = false;
941
+ continue;
942
+ }
943
+
944
+ if (current_list_item_gaps === null) {
945
+ continue;
946
+ }
947
+
948
+ if (source_line.startsWith(' ') || source_line.startsWith('\t')) {
949
+ saw_blank_line = false;
950
+ continue;
951
+ }
952
+
953
+ current_list_item_gaps = null;
954
+ saw_blank_line = false;
955
+ }
956
+
957
+ return top_level_list_item_gaps;
958
+ }
959
+
960
+ /**
961
+ * @param {ComarkElement} previous_item
962
+ * @param {ComarkElement} next_item
963
+ * @param {boolean} has_blank_line_gap
964
+ * @returns {boolean}
965
+ */
966
+ function shouldRenderListItemGap(previous_item, next_item, has_blank_line_gap) {
967
+ if (!has_blank_line_gap) {
968
+ return false;
969
+ }
970
+
971
+ return (
972
+ isSimpleTopLevelListItem(previous_item) &&
973
+ isSimpleTopLevelListItem(next_item)
974
+ );
975
+ }
976
+
977
+ /**
978
+ * @param {ComarkElement} item_node
979
+ * @returns {boolean}
980
+ */
981
+ function isSimpleTopLevelListItem(item_node) {
982
+ const item_children = getElementChildren(item_node);
983
+
984
+ if (item_children.length !== 1) {
985
+ return false;
986
+ }
987
+
988
+ const paragraph_node = item_children[0];
989
+
990
+ if (
991
+ typeof paragraph_node === 'string' ||
992
+ getElementTag(paragraph_node) !== 'p'
993
+ ) {
994
+ return false;
995
+ }
996
+
997
+ return !extractInlineText(getElementChildren(paragraph_node)).includes('\n');
998
+ }
999
+
1000
+ /**
1001
+ * @param {string} source_line
1002
+ * @returns {boolean}
1003
+ */
1004
+ function isTopLevelListItemLine(source_line) {
1005
+ return /^([*+-]|\d+[.)])(?:\s+|$)/du.test(source_line);
1006
+ }
1007
+
1008
+ /**
1009
+ * @param {string} source_line
1010
+ * @returns {{ character: '`' | '~', length: number } | null}
1011
+ */
1012
+ function parseTopLevelFence(source_line) {
1013
+ const fence_match = /^(?<character>`|~)\k<character>{2,}/du.exec(source_line);
1014
+
1015
+ if (!fence_match?.groups?.character) {
1016
+ return null;
1017
+ }
1018
+
1019
+ return {
1020
+ character: /** @type {'`' | '~'} */ (fence_match.groups.character),
1021
+ length: fence_match[0].length,
1022
+ };
1023
+ }
1024
+
1025
+ /**
1026
+ * @param {string} source_line
1027
+ * @param {{ character: '`' | '~', length: number }} active_fence
1028
+ * @returns {boolean}
1029
+ */
1030
+ function isClosingTopLevelFenceLine(source_line, active_fence) {
1031
+ const closing_pattern = new RegExp(
1032
+ `^${active_fence.character}{${active_fence.length},}\\s*$`,
1033
+ 'u',
1034
+ );
1035
+
1036
+ return closing_pattern.test(source_line);
1037
+ }
1038
+
1039
+ /**
1040
+ * @param {boolean[] | null} current_list_item_gaps
1041
+ * @param {boolean[][]} top_level_list_item_gaps
1042
+ * @param {boolean} saw_blank_line
1043
+ * @returns {boolean[]}
1044
+ */
1045
+ function pushTopLevelListItemGap(
1046
+ current_list_item_gaps,
1047
+ top_level_list_item_gaps,
1048
+ saw_blank_line,
1049
+ ) {
1050
+ if (current_list_item_gaps === null) {
1051
+ current_list_item_gaps = [];
1052
+ top_level_list_item_gaps.push(current_list_item_gaps);
1053
+ return current_list_item_gaps;
1054
+ }
1055
+
1056
+ current_list_item_gaps.push(saw_blank_line);
1057
+
1058
+ return current_list_item_gaps;
1059
+ }
1060
+
839
1061
  /**
840
1062
  * @param {string} text
841
1063
  * @param {string} item_prefix
@@ -1001,6 +1223,14 @@ function isHeadingTag(tag_name) {
1001
1223
  return /^h[1-6]$/du.test(tag_name);
1002
1224
  }
1003
1225
 
1226
+ /**
1227
+ * @param {string} tag_name
1228
+ * @returns {boolean}
1229
+ */
1230
+ function isListTag(tag_name) {
1231
+ return tag_name === 'ol' || tag_name === 'ul';
1232
+ }
1233
+
1004
1234
  /**
1005
1235
  * @param {string} tag_name
1006
1236
  * @returns {number}
@@ -1136,13 +1366,14 @@ function renderCodeBlockLine(
1136
1366
  /**
1137
1367
  * @param {string} label
1138
1368
  * @param {string[]} source_lines
1369
+ * @param {number} content_indent
1139
1370
  * @returns {number}
1140
1371
  */
1141
- function measureCodeBlockWidth(label, source_lines) {
1372
+ function measureCodeBlockWidth(label, source_lines, content_indent = 0) {
1142
1373
  return Math.max(
1143
1374
  BLOCK_WIDTH - 2,
1144
1375
  stringWidth(label),
1145
- measureMaxLineWidth(source_lines),
1376
+ measureMaxLineWidth(source_lines) + content_indent,
1146
1377
  );
1147
1378
  }
1148
1379
 
@@ -1,4 +1,5 @@
1
1
  /**
2
+ * @import { ClassDefinition } from './patram-config.js';
2
3
  * @import { PatramRepoConfig } from './load-patram-config.types.ts';
3
4
  * @import { PatramConfig } from './patram-config.types.ts';
4
5
  */
@@ -74,7 +75,7 @@ export function resolvePatramGraphConfig(repo_config) {
74
75
  const graph_config = parsePatramConfig({
75
76
  classes: {
76
77
  ...BUILT_IN_PATRAM_CONFIG.classes,
77
- ...repo_config.classes,
78
+ ...collectGraphClassDefinitions(repo_config.classes),
78
79
  },
79
80
  mappings: {
80
81
  ...BUILT_IN_PATRAM_CONFIG.mappings,
@@ -88,7 +89,44 @@ export function resolvePatramGraphConfig(repo_config) {
88
89
 
89
90
  return {
90
91
  ...graph_config,
91
- class_schemas: repo_config.class_schemas,
92
+ classes: mergeResolvedClasses(graph_config.classes, repo_config.classes),
92
93
  fields: repo_config.fields,
93
94
  };
94
95
  }
96
+
97
+ /**
98
+ * @param {PatramRepoConfig['classes']} classes
99
+ * @returns {Record<string, ClassDefinition>}
100
+ */
101
+ function collectGraphClassDefinitions(classes) {
102
+ /** @type {Record<string, ClassDefinition>} */
103
+ const graph_class_definitions = {};
104
+
105
+ for (const [class_name, class_definition] of Object.entries(classes ?? {})) {
106
+ graph_class_definitions[class_name] = {
107
+ builtin: class_definition.builtin,
108
+ label: class_definition.label,
109
+ };
110
+ }
111
+
112
+ return graph_class_definitions;
113
+ }
114
+
115
+ /**
116
+ * @param {Record<string, ClassDefinition>} graph_classes
117
+ * @param {PatramRepoConfig['classes']} repo_classes
118
+ * @returns {PatramConfig['classes']}
119
+ */
120
+ function mergeResolvedClasses(graph_classes, repo_classes) {
121
+ /** @type {PatramConfig['classes']} */
122
+ const resolved_classes = {};
123
+
124
+ for (const [class_name, class_definition] of Object.entries(graph_classes)) {
125
+ resolved_classes[class_name] = {
126
+ ...class_definition,
127
+ schema: repo_classes?.[class_name]?.schema,
128
+ };
129
+ }
130
+
131
+ return resolved_classes;
132
+ }