From efbc571654f2686b7ef9e37bddcdafef9e13b21c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 9 Sep 2026 16:50:25 +1200 Subject: [PATCH 1/2] Preserve node metadata when duplicating Assisted-By: devx/7ef83274-928f-499d-b668-48449b1ff60e --- cmark-gfm | 2 +- ext/markly/cmark-gfm-extension_api.h | 10 ++ ext/markly/cmark-gfm.h | 6 ++ ext/markly/extensions/table.c | 24 +++++ ext/markly/html.c | 5 +- ext/markly/markly.c | 16 ++++ ext/markly/node.c | 138 +++++++++++++++++++++++++++ ext/markly/syntax_extension.c | 5 + ext/markly/syntax_extension.h | 1 + lib/markly/node.rb | 10 +- releases.md | 4 + test/markly/node.rb | 8 ++ test/markly/node/inline_code_info.rb | 8 ++ 13 files changed, 225 insertions(+), 12 deletions(-) diff --git a/cmark-gfm b/cmark-gfm index 63aeddc..4b9fd22 160000 --- a/cmark-gfm +++ b/cmark-gfm @@ -1 +1 @@ -Subproject commit 63aeddc311871911a011e6c472705a8568095c76 +Subproject commit 4b9fd2253fb4ff02f53acddfd80a27185a307376 diff --git a/ext/markly/cmark-gfm-extension_api.h b/ext/markly/cmark-gfm-extension_api.h index bb92a59..3aeded7 100644 --- a/ext/markly/cmark-gfm-extension_api.h +++ b/ext/markly/cmark-gfm-extension_api.h @@ -264,6 +264,10 @@ typedef void (*cmark_opaque_free_func) (cmark_syntax_extension *extension, cmark_mem *mem, cmark_node *node); +typedef void (*cmark_opaque_copy_func)(cmark_syntax_extension *extension, + cmark_mem *mem, cmark_node *node, + cmark_node *source); + /** Free a cmark_syntax_extension. */ CMARK_GFM_EXPORT @@ -406,6 +410,12 @@ CMARK_GFM_EXPORT void cmark_syntax_extension_set_opaque_free_func(cmark_syntax_extension *extension, cmark_opaque_free_func func); +/** See the documentation for 'cmark_syntax_extension' + */ +CMARK_GFM_EXPORT +void cmark_syntax_extension_set_opaque_copy_func( + cmark_syntax_extension *extension, cmark_opaque_copy_func func); + /** See the documentation for 'cmark_syntax_extension' */ CMARK_GFM_EXPORT diff --git a/ext/markly/cmark-gfm.h b/ext/markly/cmark-gfm.h index c0fe686..054e981 100644 --- a/ext/markly/cmark-gfm.h +++ b/ext/markly/cmark-gfm.h @@ -196,6 +196,12 @@ CMARK_GFM_EXPORT cmark_node *cmark_node_new_with_mem_and_ext(cmark_node_type typ cmark_mem *mem, cmark_syntax_extension *extension); +/** Creates an independent deep copy of 'node' and all its children. + * User data is not copied. Returns NULL when extension-specific node data + * cannot be copied. + */ +CMARK_GFM_EXPORT cmark_node *cmark_node_clone(cmark_node *node); + /** Frees the memory allocated for a node and any children. */ CMARK_GFM_EXPORT void cmark_node_free(cmark_node *node); diff --git a/ext/markly/extensions/table.c b/ext/markly/extensions/table.c index e8359f2..1df8018 100644 --- a/ext/markly/extensions/table.c +++ b/ext/markly/extensions/table.c @@ -842,6 +842,29 @@ static void opaque_free(cmark_syntax_extension *self, cmark_mem *mem, cmark_node } } +static void opaque_copy(cmark_syntax_extension *self, cmark_mem *mem, + cmark_node *node, cmark_node *source) { + if (node->type == CMARK_NODE_TABLE) { + node_table *table = (node_table *)node->as.opaque; + node_table *source_table = (node_table *)source->as.opaque; + + table->n_columns = source_table->n_columns; + table->n_rows = source_table->n_rows; + table->n_nonempty_cells = source_table->n_nonempty_cells; + + if (source_table->alignments) { + table->alignments = mem->calloc(source_table->n_columns, sizeof(uint8_t)); + memcpy(table->alignments, source_table->alignments, + source_table->n_columns * sizeof(uint8_t)); + } + } else if (node->type == CMARK_NODE_TABLE_ROW) { + *(node_table_row *)node->as.opaque = *(node_table_row *)source->as.opaque; + } else if (node->type == CMARK_NODE_TABLE_CELL) { + mem->free(node->as.opaque); + node->as.cell_index = source->as.cell_index; + } +} + static int escape(cmark_syntax_extension *self, cmark_node *node, int c) { return node->type != CMARK_NODE_TABLE && @@ -867,6 +890,7 @@ cmark_syntax_extension *create_table_extension(void) { cmark_syntax_extension_set_html_render_func(self, html_render); cmark_syntax_extension_set_opaque_alloc_func(self, opaque_alloc); cmark_syntax_extension_set_opaque_free_func(self, opaque_free); + cmark_syntax_extension_set_opaque_copy_func(self, opaque_copy); cmark_syntax_extension_set_commonmark_escape_func(self, escape); CMARK_NODE_TABLE = cmark_syntax_extension_add_node(0); CMARK_NODE_TABLE_ROW = cmark_syntax_extension_add_node(0); diff --git a/ext/markly/html.c b/ext/markly/html.c index 104a618..6257b0a 100644 --- a/ext/markly/html.c +++ b/ext/markly/html.c @@ -303,7 +303,7 @@ static int S_render_node(cmark_html_renderer *renderer, cmark_node *node, case CMARK_NODE_PARAGRAPH: parent = cmark_node_parent(node); - grandparent = cmark_node_parent(parent); + grandparent = parent ? cmark_node_parent(parent) : NULL; if (grandparent != NULL && grandparent->type == CMARK_NODE_LIST) { tight = grandparent->as.list.tight; } else { @@ -316,7 +316,8 @@ static int S_render_node(cmark_html_renderer *renderer, cmark_node *node, cmark_html_render_sourcepos(node, html, options); cmark_strbuf_putc(html, '>'); } else { - if (parent->type == CMARK_NODE_FOOTNOTE_DEFINITION && node->next == NULL) { + if (parent && parent->type == CMARK_NODE_FOOTNOTE_DEFINITION && + node->next == NULL) { cmark_strbuf_putc(html, ' '); S_put_footnote_backref(renderer, html, parent); } diff --git a/ext/markly/markly.c b/ext/markly/markly.c index 0fc3c73..6fba9f4 100644 --- a/ext/markly/markly.c +++ b/ext/markly/markly.c @@ -271,6 +271,21 @@ static VALUE Markly_Node_new(VALUE self, VALUE type) { return Markly_Node_wrap(node); } +/* + * Duplicate the current node and all its children. + */ +static VALUE Markly_Node_duplicate(VALUE self) { + cmark_node *node; + TypedData_Get_Struct(self, cmark_node, &Markly_Node_Type, node); + + cmark_node *copy = cmark_node_clone(node); + if (copy == NULL) { + rb_raise(Markly_Error, "could not duplicate node"); + } + + return Markly_Node_wrap(copy); +} + static VALUE Markly_Node_replace(VALUE self, VALUE other) { cmark_node *current_node = NULL, *replacement_node = NULL; @@ -1273,6 +1288,7 @@ static void Init_Markly_Node(VALUE Markly) { rb_undef_alloc_func(Markly_Node); rb_define_singleton_method(Markly_Node, "new", Markly_Node_new, 1); Markly_Node_Fence = rb_struct_define_under(Markly_Node, "Fence", "character", "length", "indent", NULL); + rb_define_method(Markly_Node, "_dup", Markly_Node_duplicate, 0); rb_define_method(Markly_Node, "replace", Markly_Node_replace, 1); diff --git a/ext/markly/node.c b/ext/markly/node.c index 4147aa5..f7f0892 100644 --- a/ext/markly/node.c +++ b/ext/markly/node.c @@ -145,6 +145,144 @@ cmark_node *cmark_node_new(cmark_node_type type) { return cmark_node_new_with_ext(type, NULL); } +static cmark_chunk S_clone_chunk(cmark_mem *mem, const cmark_chunk *source) { + cmark_chunk clone = {NULL, source->len, 1}; + clone.data = (unsigned char *)mem->calloc(source->len + 1, 1); + if (source->len > 0) { + memcpy(clone.data, source->data, source->len); + } + return clone; +} + +static cmark_node *S_clone_node(cmark_node *node) { + cmark_mem *mem = NODE_MEM(node); + cmark_node *clone = cmark_node_new_with_mem_and_ext( + (cmark_node_type)node->type, mem, node->extension); + + if (!clone) { + return NULL; + } + + cmark_strbuf_set(&clone->content, node->content.ptr, node->content.size); + clone->start_line = node->start_line; + clone->start_column = node->start_column; + clone->end_line = node->end_line; + clone->end_column = node->end_column; + clone->internal_offset = node->internal_offset; + clone->flags = node->flags; + clone->footnote = node->footnote; + + switch (node->type) { + case CMARK_NODE_HEADING: + clone->as.heading = node->as.heading; + break; + case CMARK_NODE_LIST: + case CMARK_NODE_ITEM: + clone->as.list = node->as.list; + break; + case CMARK_NODE_CODE_BLOCK: + case CMARK_NODE_FRONT_MATTER: + case CMARK_NODE_CODE: + clone->as.code = node->as.code; + clone->as.code.info = S_clone_chunk(mem, &node->as.code.info); + clone->as.code.literal = S_clone_chunk(mem, &node->as.code.literal); + break; + case CMARK_NODE_TEXT: + case CMARK_NODE_HTML_INLINE: + case CMARK_NODE_HTML_BLOCK: + case CMARK_NODE_FOOTNOTE_REFERENCE: + case CMARK_NODE_FOOTNOTE_DEFINITION: + clone->as.literal = S_clone_chunk(mem, &node->as.literal); + break; + case CMARK_NODE_LINK: + case CMARK_NODE_IMAGE: + clone->as.link.url = S_clone_chunk(mem, &node->as.link.url); + clone->as.link.title = S_clone_chunk(mem, &node->as.link.title); + break; + case CMARK_NODE_CUSTOM_BLOCK: + case CMARK_NODE_CUSTOM_INLINE: + clone->as.custom.on_enter = S_clone_chunk(mem, &node->as.custom.on_enter); + clone->as.custom.on_exit = S_clone_chunk(mem, &node->as.custom.on_exit); + break; + default: + break; + } + + if (node->extension && node->extension->opaque_alloc_func) { + if (!node->extension->opaque_copy_func) { + cmark_node_free(clone); + return NULL; + } + + node->extension->opaque_copy_func(node->extension, mem, clone, node); + } + + for (cmark_node *child = node->first_child; child; child = child->next) { + cmark_node *child_clone = S_clone_node(child); + if (!child_clone) { + cmark_node_free(clone); + return NULL; + } + + if (!cmark_node_append_child(clone, child_clone)) { + cmark_node_free(child_clone); + cmark_node_free(clone); + return NULL; + } + } + + return clone; +} + +static cmark_node *S_find_clone(cmark_node *source, cmark_node *clone, + cmark_node *target) { + if (source == target) { + return clone; + } + + cmark_node *source_child = source->first_child; + cmark_node *clone_child = clone->first_child; + while (source_child && clone_child) { + cmark_node *result = S_find_clone(source_child, clone_child, target); + if (result) { + return result; + } + source_child = source_child->next; + clone_child = clone_child->next; + } + + return NULL; +} + +static void S_clone_footnote_links(cmark_node *source, cmark_node *clone, + cmark_node *source_root, + cmark_node *clone_root) { + if (source->parent_footnote_def) { + clone->parent_footnote_def = + S_find_clone(source_root, clone_root, source->parent_footnote_def); + } + + cmark_node *source_child = source->first_child; + cmark_node *clone_child = clone->first_child; + while (source_child && clone_child) { + S_clone_footnote_links(source_child, clone_child, source_root, clone_root); + source_child = source_child->next; + clone_child = clone_child->next; + } +} + +cmark_node *cmark_node_clone(cmark_node *node) { + if (!node) { + return NULL; + } + + cmark_node *clone = S_clone_node(node); + if (clone) { + S_clone_footnote_links(node, clone, node, clone); + } + return clone; +} + static void free_node_as(cmark_node *node) { switch (node->type) { case CMARK_NODE_CODE_BLOCK: diff --git a/ext/markly/syntax_extension.c b/ext/markly/syntax_extension.c index d24fe43..d9bfc94 100644 --- a/ext/markly/syntax_extension.c +++ b/ext/markly/syntax_extension.c @@ -143,6 +143,11 @@ void cmark_syntax_extension_set_opaque_free_func(cmark_syntax_extension *extensi extension->opaque_free_func = func; } +void cmark_syntax_extension_set_opaque_copy_func( + cmark_syntax_extension *extension, cmark_opaque_copy_func func) { + extension->opaque_copy_func = func; +} + void cmark_syntax_extension_set_commonmark_escape_func(cmark_syntax_extension *extension, cmark_commonmark_escape_func func) { extension->commonmark_escape_func = func; diff --git a/ext/markly/syntax_extension.h b/ext/markly/syntax_extension.h index a5fe11e..5d46a7b 100644 --- a/ext/markly/syntax_extension.h +++ b/ext/markly/syntax_extension.h @@ -28,6 +28,7 @@ struct cmark_syntax_extension { cmark_postprocess_func postprocess_func; cmark_opaque_alloc_func opaque_alloc_func; cmark_opaque_free_func opaque_free_func; + cmark_opaque_copy_func opaque_copy_func; cmark_commonmark_escape_func commonmark_escape_func; }; diff --git a/lib/markly/node.rb b/lib/markly/node.rb index 0a46571..ea007fd 100644 --- a/lib/markly/node.rb +++ b/lib/markly/node.rb @@ -20,15 +20,7 @@ class Node # # @returns [Markly::Node] The duplicated node tree. def dup - # This is a bit crazy, but it's the best I can come up with right now: - node = Markly.parse(self.to_markdown) - - # If we aren't duplicating a document, we return `first_child` as the root will be a document node: - if self.type == :document - return node - else - return node.first_child - end + _dup end # Walk the node tree recursively. diff --git a/releases.md b/releases.md index 20c7934..40334fd 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Preserve complete node and extension metadata when duplicating node trees. + ## v0.17.0 - Add opt-in language prefixes for inline code spans with `Markly::INLINE_CODE_INFO`, expose code metadata through `Node#code_info`, and provide `Node#code_language` as a convenient language accessor. diff --git a/test/markly/node.rb b/test/markly/node.rb index 8ae47c4..202c621 100644 --- a/test/markly/node.rb +++ b/test/markly/node.rb @@ -25,6 +25,14 @@ expect(dup.to_html).to be == document.first_child.to_html expect(dup).not.to be_equal(document.first_child) end + + it "preserves extension metadata" do + document = Markly.parse("| Left | Right |\n| :--- | ---: |\n| A | B |\n", extensions: [:table]) + dup = document.dup + + expect(dup.first_child.table_alignments).to be == [:left, :right] + expect(dup.to_html(extensions: [:table])).to be == document.to_html(extensions: [:table]) + end end with "#type" do diff --git a/test/markly/node/inline_code_info.rb b/test/markly/node/inline_code_info.rb index 438c530..8b809d8 100644 --- a/test/markly/node/inline_code_info.rb +++ b/test/markly/node/inline_code_info.rb @@ -33,6 +33,14 @@ expect(document.to_commonmark).to be == "ruby:`Object.new`\n" end + it "preserves the language when duplicated" do + copy = document.dup + code = copy.first_child.first_child + + expect(code.code_info).to be == "ruby" + expect(copy.to_html).to be == document.to_html + end + it "can update and clear the language" do code.code_info = "c++" expect(code.code_info).to be == "c++" From 208a5883de52745f32a3e35bede299dcc77b316b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 9 Sep 2026 16:59:52 +1200 Subject: [PATCH 2/2] Update cmark-gfm submodule Assisted-By: devx/7ef83274-928f-499d-b668-48449b1ff60e --- cmark-gfm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmark-gfm b/cmark-gfm index 4b9fd22..4fa8814 160000 --- a/cmark-gfm +++ b/cmark-gfm @@ -1 +1 @@ -Subproject commit 4b9fd2253fb4ff02f53acddfd80a27185a307376 +Subproject commit 4fa8814e0d409d04ed83c44b4889b60e81a57b0f