Confluence nested bodied macros before a Cloud migration: find them, triage them, fix them
Mihai Perdum
Author
22 min readSeptember 18, 2026
Key takeaways
END STATE: every page in your Confluence Data Center with a bodied macro inside another bodied macro is on a list, sorted into combinations Cloud accepts, combinations it wraps in a legacy content macro, and combinations you restructure before the migration, with a plan file and a rollback path.
The Cloud editor rule, in Atlassian's words: a macro with a body can't be nested inside another macro. Migrated pages used to land in the legacy editor where the nest still rendered; the legacy editor was deprecated in three phases ending 1 April 2026, so today every migrated page converts on first view or edit.
Conversion does not drop a nest, it wraps it: the ADF carries an extension of type com.atlassian.confluence.migration with key legacy-content, a reason string, the storage XML of the inner macro, and the inner macro as an ADF document. Read that back over REST and you can count the wrappers before anyone opens a page.
Not every nest breaks. Since March 2024 panels take code blocks, media and actions, expands take lists, panels, code and quotes; an expand inside an excerpt or inside a panel still fails. Atlassian's own differences page still says code cannot go inside a panel, and a live probe today says it can. Triage on what you measure, not on one page.
The discovery SQL matches any macro-in-macro, allowed or not, and cannot tell a real nest from a code block that happens to contain the text of one. Walk the storage tree instead: recurse into rich-text bodies only, record outer, inner, depth and path, and decide per pair.
Repair is split-around-child: the outer macro becomes a prefix half, the inner macro promoted to a sibling, and a cloned suffix half with a fresh macro id, iterated until the tree is stable. Some containers cannot be split; mark those unfixable rather than promoting blindly, and keep the pre-run version for rollback.
Nested bodied macros are the one Confluence Data Center content pattern a Cloud migration cannot carry across intact, and the migration assistant's readiness insight gives you two numbers and no list. The rule on the Cloud side is one sentence on Atlassian's differences page: "In the new editor, a macro with a body can't be nested inside another macro." Everything else about the subject is detail, and the detail is where migrations go wrong, because the rule has exceptions that changed in 2024, a safety net that was removed in 2026, and a failure mode that is only visible when someone tries to edit the page.
This tutorial is the pre-migration side: find every nest on the Data Center, sort them by what Cloud will do with them, probe the ones you are unsure of, and restructure the ones that will break. It is written for the person who owns the migration and will run the scripts, so it shows the probes and the parts of the script that carry the logic, not a full tool. The post-migration side, repairing what arrived broken, is its own tutorial on this site and is not repeated here.
Every Atlassian statement below was fetched on 14 September 2026 and is quoted; every read-back from Cloud is from a probe run the same day on a test site and quoted, trimmed; every script fragment is the pattern from a real engagement with nothing that identifies it. Two of Atlassian's knowledge base pages on this subject are behind a login today, and the tutorial says so where it matters.
Note
Prerequisites
Read access to the Confluence Data Center database, or a database administrator who will run one query for you. The discovery probe is SQL against the content tables.
A Confluence Cloud site you can create and delete pages on, with an API token. The probe in step 4 creates two pages and removes them. Use a sandbox, not the production site.
Node.js 18 or later for the script fragments, and the fast-xml-parser package. The fragments parse Confluence storage format, which is XML.
Your migration assistant's readiness report open beside you. The "Confluence pages with unsupported nested bodied macros" insight gives two numbers for your site, the count of unsupported combinations and the count of pages affected; it does not say which combinations or which pages. That is what this tutorial produces.
The dates, because they decide your options. The legacy editor was deprecated in three phases: "Phase 1 (January 21, 2026): New pages can't be created in the legacy editor", "Phase 2 (rolling out from January 21 - February 18, 2026): Viewing or editing a legacy page will automatically convert it to the cloud editor", "Phase 3 (April 1, 2026): The legacy editor is fully deprecated; all content is viewed and edited in the cloud editor and pages can't be reverted back to legacy editor." All three are past.
1
Understand what Cloud does with a nest, and why the answer changed in 2024 and again in 2026.
2
Identify nested macros in Confluence Data Center with the database probe, and know what it over-reports.
3
Walk the storage tree to get outer, inner, depth and path per nest, with the fragment that does it.
4
Probe Cloud over REST with the combinations you are unsure of, and read the legacy content wrapper back in ADF.
5
Triage each pair into accepted, wrapped, or restructure, against the list of what the editor takes.
6
Recognise the error your users will paste and what Atlassian's fixes for it mean after April 2026.
7
Repair with split-around-child, with the excluded containers, the fallback strategies and the plan file.
8
Verify by reading ADF back and counting the wrappers, then keep the rollback path.
Why nested bodied macros break in Confluence Cloud
The differences page states the rule and its consequence together: "In the new editor, a macro with a body can't be nested inside another macro. For example, you can't put a code macro inside a panel macro. When an eligible page is converted to the cloud editor, your nested and incompatible macros will be inserted into the legacy content macro." The word that matters is bodied. A macro with a rich-text body, an expand, a panel, an excerpt, an info box, a section, a column, is the kind that can contain another; a nest is one of those inside another of those.
For the first years of Cloud migrations this was survivable because migrated pages did not use the new editor. Atlassian: "When you migrate, your existing pages will use the legacy editor, which is very similar to the editor in Confluence Server and Data Center. Users will have the option to convert existing pages to the new editor on a page-by-page basis." The tracker ticket for third-party nesting, CONFCLOUD-70746, still carries the status update of November 2024: "While nesting bodied macros in the new Cloud editor is not currently supported, existing nested content created in Confluence Data Center will continue to render in Cloud." Render, in the legacy editor.
That editor is gone. Atlassian announced its deprecation in June 2025 and ran it in the three phases above; since 1 April 2026 "all content is viewed and edited in the cloud editor and pages can't be reverted back to legacy editor." A page migrated today converts the first time anyone views or edits it, and conversion is where the nest meets the rule. Conversion does not delete the nest. "When a page is converted, all compatible content is automatically converted to use the cloud editor elements. Incompatible content such as nested macros, will be added to the page inside the legacy content macro."
The legacy content macro is the thing to understand before you decide anything. Atlassian's page on it: "The legacy content macro lets you keep incompatible legacy content on a cloud editor page. It supports a few of the macros and nested content configurations that aren't available in the cloud editor." And its limits, each of which is a reason to restructure before rather than after: "Unsupported content – such as nested macros or entire legacy macros – can't be copied and pasted outside the legacy content macro. You can only copy the text." "Anyone reading your page will not be able to add an inline comment to content inside the legacy content macro." "Rovo AI features like summarize will ignore all the text inside certain blocks inside the legacy content macro." "Smart links will render as plain URL inside the legacy content macro." "You also will not be able to add your own legacy content macro into the cloud editor – it is only used by Confluence's automatic page conversion process." And the one that decides how you edit such pages: "using collaborative editing with the legacy content macro could cause unpredictable results, including loss of data. Make sure only one person is editing before you publish."
So the migration question is not whether nests survive; they survive, wrapped. Whether they can then be edited is where Atlassian's own pages disagree: the legacy content macro page says the wrapper "allows the content to be edited and reconfigured without losing any data", while its error knowledge base article and CONFCLOUD-77947 say the editor blocks some wrapped nests with the error in step 6. What is documented without dispute is that wrapped content cannot be copied out except as text, cannot take inline comments, and is ignored by Rovo summarise for certain blocks. The question is which nests you want to arrive that way, and the answer for a large site is almost none.
The demand for a real fix is on record. The ticket for nesting bodied macros in the new editor, CONFCLOUD-78513, was created on 28 October 2019, closed in 2021, reopened to Under Consideration in September 2022, and carries 1,198 votes as of today; the older key CONFCLOUD-68323 that forum threads cite resolves to the same ticket, so do not count it twice. Its description: "There's currently no way to nest an bodied macro (like the Expand Macro) into another bodied Macro."
How you know it worked: you can say, for your site, which editor your migrated pages will open in, and the answer is the cloud editor, on first view, with nests wrapped. If anyone on the project still plans around the legacy editor, the plan is out of date by five months.
Identifying nested macros in Confluence Data Center: the BODYCONTENT probe
Atlassian published a knowledge base article titled "Identifying nested macros in Confluence Data Center/Server" and another titled "Migrating from Confluence Server/Data Center to Cloud - Nested-bodied macros". Both redirect to a login today. For the migrating article the author explained why in the Atlassian Migration Program thread on discovering nests at scale: "I have restricted its visibility until we have a clear version to help understand which nesting combinations will not work in Cloud for migrators." He created the identifying article afterwards, and it is gated today for a reason the thread does not state. A query was re-posted in that thread in reply to a request for the article's; it is the only reachable copy and there is no way today to check it against the article, so it is attributed here to the thread and given in the PostgreSQL form it was posted in:
sql
1SELECT ct.contentid, ct.title, s.spacename
2FROM bodycontent b
3JOIN content ct ON(ct.contentid = b.contentid)4JOIN spaces s ON s.spaceid = ct.spaceid
5WHERE ct.contenttype IN('PAGE','BLOGPOST')6AND ct.content_status ='current'7AND ct.prevver ISnull8AND b.body ~'<ac:structured-macro[^>]*ac:name="([^">]*)"(?:(?!</ac:structured-macro>).)*<ac:structured-macro[^>]*ac:name="([^">]*)"(?:(?!</ac:structured-macro>).)*<ac:(?:rich-text-body|plain-text-body)';
What it does: for every current, latest-version page or blog post, it matches a body where a second <ac:structured-macro opens before the first one closes and a body element follows. That is any macro inside any macro. Run it and you have your first list, with the space name beside each page, which is what the migration program needs on day one.
Know what it over-reports, because the list will be longer than the problem. First, it matches nests the Cloud editor accepts today; the KB author said as much in the thread: "the article itself focuses on any sort of nesting and what we want to do is to ensure that customers understand which combinations still work." Second, it matches plain-text-body as well as rich-text-body, and a plain-text body is CDATA: a code block whose text happens to contain the words <ac:structured-macro is a hit, and it is not a nest. Third, it counts a page once however many nests it holds. The list is a superset; step 3 turns it into the real one.
What the thread also records is how the people who answered it actually did discovery. A partner: "When nesting is a major problem, it has usually been due to tab macros ... (e.g. Navitabs). We provide lists of pages based off (poor) CQL macro searches and they identified critical pages ... Most of these were adjusted on Server/DC to migrate without issue, others were updated in a Test Cloud and copied to Prod Cloud. Remaining pages were left up to users post-migration." That is the honest shape of most migrations: a list, a fix on the source for the pages that matter, and a residue handed to users. The rest of this tutorial is how to make the residue small.
How you know it worked: a CSV of page id, title and space, and a count you can compare against the readiness insight's "Total number of pages affected". Your count will be higher, because the insight counts only "currently known unsupported combinations" and this query counts every combination.
Walk the storage tree: outer, inner, depth, path
The database query says which pages; the migration needs which macro is inside which, how deep, and where. Storage format is XML, so parse it as XML rather than regex it. The engagement pattern uses fast-xml-parser with order preserved and attributes kept:
js
1import{XMLParser}from"fast-xml-parser";23const parser =newXMLParser({4preserveOrder:true,5ignoreAttributes:false,6attributeNamePrefix:"@_",7allowBooleanAttributes:true,8parseTagValue:false,9});1011constisStructuredMacro=(node)=>Object.keys(node).some((k)=> k ==="ac:structured-macro");12constgetMacroName=(node)=> node[":@"]?.["@_ac:name"];
The detector is a walk that carries the chain of macro ancestors down with it and records a finding whenever it meets a macro with a macro above it. Two decisions in it are the whole point. It recurses into ac:rich-text-body and not into ac:plain-text-body, because the plain body is CDATA and a literal macro tag inside a code block is text, not structure; that removes the SQL probe's false positives. And it recurses through non-macro elements, because "macros can be buried inside <p>, <div>, <td>, layout-cells":
js
1functionwalk(nodes, ancestors, findings, path =[]){2for(const[i, node]of nodes.entries()){3const tag =Object.keys(node).find((k)=> k !==":@");4if(!tag || tag ==="#text")continue;5const here =[...path,`${tag}[${i}]`];6if(tag ==="ac:structured-macro"){7const name =getMacroName(node);8if(ancestors.length){9 findings.push({outerMacro: ancestors.at(-1),innerMacro: name,depth: ancestors.length,path: here.join("/")});10}11for(const child of node[tag]){12const ctag =Object.keys(child).find((k)=> k !==":@");13if(ctag ==="ac:rich-text-body")walk(child[ctag],[...ancestors, name], findings,[...here, ctag]);14// ac:plain-text-body is CDATA: never walked, so a code block quoting a macro is not a nest15}16}elseif(Array.isArray(node[tag])){17walk(node[tag], ancestors, findings, here);18}19}20return findings;21}2223exportconstdetect=(tree)=>walk(tree,[],[]);
A finding is { outerMacro, innerMacro, depth, path }, and the pair is what you triage on. Depth tells you whether the fix is one split or several; the path is what the plan file records so the repair can find the exact node again.
To feed it, pull the storage body per page. On the Data Center side that is the same bodycontent table. On the Cloud side, before or after migration, it is the content API; the engagement pattern searches by CQL for pages carrying a candidate bodied macro rather than reading every page, with a candidate list of info, expand, panel, note, warning, tip, details, excerpt, column, section, layout and status:
js
1constDEFAULT_CANDIDATE_MACROS=["expand","info","panel","note","warning","tip","details","excerpt","column","section","layout","status"];2constbuildCql=(spaceKey)=>`type=page AND macro in (${DEFAULT_CANDIDATE_MACROS.map((m)=>`"${m}"`).join(",")}) AND space="${spaceKey}"`;
The v1 search endpoint, /rest/api/content/search?cql=, is what the engagement client uses for that, and a client that follows the cursor, falls back to start-based paging when the cursor is missing, and de-duplicates by id is the difference between a list you trust and one you do not.
How you know it worked: per page, a findings array, and across the site a tally by pair. Sort the tally by count. The top pairs are your triage list, and the plan file in step 7 tallies them as nested found, fixable, excluded and unfixable.
Probe Cloud over REST: what a nest looks like in ADF
Before deciding what to do with a pair, ask Cloud. Create a page with the nest in storage format on a sandbox site, read it back in Atlassian Document Format, and look at what the conversion produced. This is the probe that the rest of the tutorial rests on, run today on a test tenant, then deleted and purged.
The page is created through the v2 API with a storage body. The read-back asks for atlas_doc_format:
That block is trimmed. That is the shape of an unsupported nest after conversion, and nothing in Atlassian's documentation or in the engagement corpus describes it; the probe is the source. The outer excerpt is an ordinary bodied extension. The inner expand has become an extension node of type com.atlassian.confluence.migration and key legacy-content, carrying three parameters: a reason string in plain English, cxhtml holding the storage XML of the inner macro, and nestedContent, an ADF document of what the inner macro would have been. The same page with an expand inside an info panel came back with the same wrapper and the reason "An expand macro in a panel macro can't be created or edited in the new editor."
Two other combinations on the probe pages tell you the rule has exceptions. An expand containing a panel containing a code block came back as plain expand, panel and codeBlock nodes, no wrapper at all, with two lossy details worth knowing: the panel's title parameter became a bold paragraph inside the panel, and the code block's bash language became shell. And a section containing a column containing a note came back as layoutSection, layoutColumn and panel, the deprecated section-and-column pair converted natively into a layout.
Three cautions about what the probe does and does not show. It is the API's conversion of a storage-format page, which carried no editor property; it is not a page that went through the Phase 2 auto-conversion in the UI, and whether the editor shows an error for these pages was not tested through a browser. The cxhtml parameter is the whole reason a wrapped nest can be restored: the original XML is still there. And a read-back in storage format returned exactly what was sent, so storage format tells you nothing about what Cloud will do; only ADF does.
How you know it worked: for each pair on your triage list, one probe page, one ADF read-back, and one of three results: native nodes, a legacy-content wrapper with its reason string, or a native conversion into different nodes. Delete the probe pages afterwards and confirm with a CQL search that none remain.
Triage: which nested bodied macro combinations the new Confluence Cloud editor accepts
The rule has exceptions, and they are dated. In March 2024 Atlassian's editor team published the nesting enhancements: under "A more flexible panel", "Nest media, code blocks, actions, dividers and decisions in panels"; "Add numbered or bulleted lists in quotes, including lists with actions"; and under "Enhanced nested expands", "Soon, you can nest the following inside a nested expand (e.g. an expand in a table)", with the list items lists (bullet and numbered), actions, code blocks, panels, quotes, dividers, decisions. The ticket CONFCLOUD-78513 carried the same list on 5 March 2024 with "These changes will be rolling out over the coming weeks to all accounts".
So the accepted list, as of that note and today's probe: a panel takes media, code blocks, actions, dividers and decisions; an expand takes lists, actions, code blocks, panels, quotes, dividers and decisions; a quote takes lists. What still fails, from Atlassian's error knowledge base and from the probe: an expand inside an excerpt and an expand inside a panel; the deprecated section and column macros are named in the knowledge base's 2024 diagnosis and in CONFCLOUD-77947, though today's probe converted section, column and note natively, so section and column are a probe-it pair rather than a known failure. The diagnosis: "Nested Macros structure: Excerpt > Expand > Section > Columns > Filter by Label; Using deprecated macros Section and Columns." Its compatibility table is the most precise statement Atlassian makes: "Layout > Expand ✔️ / Layout > Excerpt ✔️ / Layout > Excerpt > Expand ❌ / Layout > Expand > Insert Excerpt ✔️".
One disagreement to carry into your triage rather than resolve. Atlassian's differences page still says, twice, that "you can't put a code macro inside a panel macro". The March 2024 note says panels take code blocks, and today's probe put a code block inside a panel inside an expand and got native nodes back. The differences page is older than the enhancement; the probe is what your site will do. Triage on the probe.
The readiness insight is where the migration assistant sits in this. It "checks for nested bodied macro (NBM) combinations in your Confluence pages that may not work as expected after migration to Cloud", it shows "Number of unsupported NBM combinations detected" and "Total number of pages affected", and it says of its own numbers: "These numbers are based on currently known unsupported combinations. There may be additional combinations that require deeper assessment." Its recommendations are "Contact Atlassian Support" for "a detailed assessment of your specific NBM combinations" and "Plan for content restructuring". It does not list the combinations. Your tally from step 3 and your probes from step 4 are that list.
Third-party bodied macros are their own category. "Additionally, many third-party cloud apps have been updated to support nested body macros", says the differences page, and the ticket for third-party nesting, CONFCLOUD-70746, is Under Consideration with 304 votes. The engagement pattern records a limit here: Forge-wrapped bodied extensions, bodiedExtension in ADF with an ecosystem extension type, are not handled by a storage-format tool at all. And the Forge developer community has a thread from May 2025 where a migrated Forge macro "is not shown" in the legacy editor and after conversion "does show up. However, it has a weird narrow layout", with vendors replying that they paused their Forge migrations over it. If a vendor macro is in your nesting chain, the vendor's own migration page is the source, not this tutorial.
The triage itself is a table with one row per pair and one of three verdicts: accepted, wrapped, restructure. Accepted pairs need nothing. Wrapped pairs arrive with the limits above, uncopyable except as text, uncommentable, ignored by Rovo summarise for certain blocks, and editable or not depending on which Atlassian page turns out to be right for that pair; for pages nobody edits, that may be acceptable, and for anything living, it is not. Restructure pairs are the work of step 7, done on the Data Center before the migration, where the pages are still in an editor that can hold them.
How you know it worked: every pair in the tally has a verdict and an evidence column: the March 2024 note, the knowledge base table, or your own probe's reason string. A verdict without evidence is a guess, and the differences page's stale example is the proof that even Atlassian's own text can be one.
Unable to edit: confluenceADFMigrationUnsupportedContentInternalExtension after conversion
This is the string your users will paste into a ticket, and it deserves its own section because searching it lands on exactly one Atlassian article. That article, updated 26 September 2025: "When editing a page using the new editor, users can observe that some macros show the error message confluenceADFMigrationUnsupportedContentInternalExtension, blocking the edition." Environment: "Pages migrated from Confluence On-Prem and using the new Editor." Cause: "This error message confluenceADFMigrationUnsupportedContentInternalExtension is presented when a non-supported macro is used on the new Editor." and "On Confluence Cloud, there is a limitation when using nested macros (one macro inside the other)". The closed tracker ticket CONFCLOUD-77947 gives the reproduction in two steps: "Create a page with a Section macro and Column macro with additional content in the Column macro. Save and convert the page."
Read the error for what it says: it is an edit-time error, not a render error. The page shows; the editor refuses. That is the legacy content wrapper from step 4 meeting the editor.
Atlassian's article offers three fixes, and the first one no longer exists. Fix one was to flip the page back to the legacy editor through its editor content property: read GET /rest/api/content/<id>/property/editor, then PUT the same path with {"value":["v1"],"version":{"number":<n+1>,"minorEdit":true}}. That was written before Phase 3. Since 1 April 2026 "pages can't be reverted back to legacy editor", so treat the property flip as history. Fix two is the real one: "Move the content out of the Section/Columns macro and delete the unsupported macros from the page; The new editor does not support the nested macros Excerpt > Expand yet ... Move the content of the Expand macro out of the Excerpt to resolve the warning". Fix three is the workaround for the one combination people keep asking for, an excerpt that expands: "use the Include macro, separating only what would be the excerpt content to a separate page".
If you are reading this after the migration with that string in front of you, the post-migration repair tutorial is the one to follow. If you are reading it before, the point of steps 2 to 5 is that nobody ever pastes it.
How you know it worked: a search of your Cloud site's pages for ADF extensions with key legacy-content returns none for the pairs you restructured. That query is in step 8.
Repair patterns: flatten, split, or page-include
The repair is done on the source, in storage format, before the migration, and it is one transformation applied until nothing is left to transform. The engagement pattern calls it split-around-child:
text
1Before: A[ prefix B suffix ]
2After: A[ prefix ] B A'[ suffix ]
A' is a clone of A's wrapper, same macro name, same parameters, with a fresh ac:macro-id, which the pattern refreshes on every clone. Empty halves are dropped, so a nest at the very start or end of A produces two nodes, not three. The transformation runs iteratively until the tree is stable, which is what handles depth: a three-deep nest is three passes. The one visible side effect the pattern records: "Splitting a numbered-list parent resets list numbering in each split half."
The core of the split, on the parsed tree from step 3, is a function that finds the first nested bodied macro under an outer one, cuts the outer's body around it, and returns the three siblings:
That is the shape, not the tool; the tool around it is the tree walk from step 3, the iteration until stable, and the serialisation back to storage format, which fast-xml-parser's builder does with the same options the parser used.
Some containers must not be split, because splitting them changes what the page means. The pattern excludes column, section, layout, details, tabs-group and tabs by default: a section split around a child is two sections, a details macro split is two records, a tab set split is two tab sets. When the nest is inside one of those, the pattern offers three strategies and a default:
strategy
what it does
when
skip
Leave the nested content where it is, mark unfixable in the plan
Default; safest
promote
Unwrap the container, lifting children to the parent's level
Acceptable layout simplification
fail
Treat as a hard error, refuse to plan this entity
High-stakes layouts
4 rows × 3 columnsHeader row enabled
Note what the probe in step 4 showed for the most common excluded pair: a section containing a column containing a note converted natively into a layout. Deprecated containers are not always the problem; the nest inside them is. That is an argument for skip as the default and a probe before promote.
The other repair is not a script at all. For the excerpt-with-expand pattern, which people build to reuse a collapsible block across pages, Atlassian's own workaround is structural: put the excerpt content on its own page and use the Include macro where it was nested. The split pattern cannot produce that; a person deciding page by page can.
Then the plan. Every run writes a plan file before it writes a page, and the plan is what makes the run resumable and auditable. Its shape, from the pattern: per page, a status of pending, completed, failed, skipped or unfixable, the page version the plan was built against, the list of nestings with outer, inner, depth, path and the strategy chosen, and a beforeHash and afterHash of the storage body; at the top, totals of nestedFound, fixable, excluded and unfixable. The version matters because a page edited between plan and execution is a page you re-plan, not one you overwrite. A tool in the pattern rewrites a plan in place to mark more pairs as skip without rescanning; another rolls back by restoring the pre-run version from Confluence's own version history, which is the rollback path you should test on the first page before the thousandth.
How you know it worked: the plan's totals add up to the tally from step 3, unfixable is a list you have read and accepted rather than a number, and a dry run against one space produces before-and-after storage bodies you can diff by eye for the top three pairs.
Verify by reading ADF back, and keep the way back
Verification is the probe from step 4 run at scale. For every page the plan touched, read the ADF back and count extension nodes whose key is legacy-content; the count for a restructured page should be zero, and the reason strings on any that remain are your next triage list:
A structural compare of ADF before and after a repair needs one normalisation first, from the engagement's audit notes: strip empty text nodes and collapse single-element content arrays, or the diff is noise. And open a sample of the repaired pages in the editor, not the viewer; the error in step 6 is an editor error, and a page that renders is not yet a page that edits.
Keep three things after the run. The plan file, because it is the only record of what was changed and why. The pre-run versions, because Confluence keeps them and the rollback tool reads them; the conversion process itself leaves a marker you can use the same way, a version whose comment reads "Version published after converting to the new editor", which Atlassian's own undo instruction points at: "Select the version before ... and select Restore." And the probe pages' reason strings, because the next release of the editor will move a pair from wrapped to accepted, and the strings are how you notice.
This tutorial is the nested-macro chapter of the data work a migration needs. The gap list is where that work starts, and when you run the repair a second time, skipping the writes that change nothing is what keeps the second run cheap.
What this tutorial did not tell you, on purpose
It did not give you the full script, because the full script is an engagement tool with a client's shapes in it, and because the parts that carry the logic fit on this page: the SQL probe, the walk, the split, the exclusion table and the plan. It did not quote either of Atlassian's two knowledge base articles on the subject, because both are behind a login today, and the SQL is attributed to the community thread that re-posted it. It did not resolve the disagreement between Atlassian's differences page and its own March 2024 note about code inside panels, because the probe resolves it for your site and the page does not. And it did not test the probe pages through the editor's user interface; the ADF read-back is what was measured, and the error string in step 6 is what Atlassian documents the editor shows.
Key takeaways
The rule is one sentence, the exceptions are dated, and the safety net is gone. A macro with a body cannot be nested in the cloud editor; panels and expands accept a specific list since March 2024; the legacy editor ended on 1 April 2026, so every migrated page converts on first view.
Conversion wraps, it does not drop. An unsupported nest becomes a legacy-content extension with a reason, the original cxhtml, and nestedContent; it cannot be copied out except as text or take inline comments, certain blocks are ignored by Rovo summarise, and whether it can still be edited in place is where Atlassian's own pages disagree.
Discover on the database, then walk the tree. The SQL from the community re-post lists every page with any nest; the tree walk into rich-text bodies only gives outer, inner, depth and path without CDATA false positives.
Probe before you decide. One page per pair on a sandbox, read back as ADF; storage format tells you nothing. Today: expand inside excerpt or panel wraps; code inside panel inside expand does not; section and column convert to a layout.
Triage on evidence, three verdicts. Accepted, wrapped, restructure; the readiness insight gives two numbers and no list, so the list is yours.
The error string is edit-time. confluenceADFMigrationUnsupportedContentInternalExtension means a wrapper met the editor; the editor-property flip Atlassian once suggested is no longer possible, and the fix is to un-nest.
Split around the child, iterate until stable, exclude the containers that carry meaning, keep a plan and the pre-run versions. Default to skip for the excluded set; probe before you promote.
Verify by counting wrappers in ADF, and open pages in the editor. Zero legacy-content extensions on a restructured page is the pass; a page that renders has not yet passed.
Confluence heap size for a Cloud migration: Data Center memory settings, Synchrony, CCMA