Problem / Motivation
Markdownify converts an entity to Markdown by rendering it through its own pipeline rather than via the normal page request. In MarkdownifyEntityRenderer::renderEntity(), the entity is built with the entity type's view builder:
$view_builder = $this->entityTypeManager->getViewBuilder($entity->getEntityTypeId());
$build = $view_builder->view($entity, $view_mode, $langcode);
$html = $this->renderer->renderRoot($build);
This renders only the entity-level template (node.html.twig, taxonomy-term.html.twig, etc.). It never runs the page-level render pipeline (page.html.twig, the page title block, html.html.twig).
That distinction is the root of the bug. In standard Drupal theming, the entity's H1 page title is not emitted by the entity template — it is rendered by the page title block in the page template. The entity template only renders the title as a linked H2, and only when it is not the main entity of the page. The canonical guard in core/Stable/Olivero node.html.twig is:
{% if label and not page %}
<h2{{ title_attributes }}>
<a href="{{ url }}" rel="bookmark">{{ label }}</a>
</h2>
{% endif %}
Because Markdownify renders the entity in isolation, page is FALSE, so:
- The H1 page title is never produced (no page template runs), and
- The template instead emits the title as an H2 link — semantically wrong for a standalone document and, in Markdown, indistinguishable from a section heading.
The result is Markdown output with no top-level # heading, or a ## title link where an # was expected.
Relationship to #3577458
This is the same category of problem discussed in #3577458 "Markdown heading level incorrect" — an entity rendered through the view builder in isolation from the page template, so the H1 page title is lost or demoted. I did the root-cause investigation in that issue (entities are rendered via the view builder, which only produces entity-level HTML without the page template layer), and the mechanism described above is what I traced there.
However, I suspect the specific symptom in #3577458 may be a different trigger than the one this issue documents, for a concrete reason: the original report shows the title demoted to a plain ## Computer Science with no link. The standard node.html.twig guard above renders the demoted title as a linked <h2><a>...</a></h2>, which would convert to ## [Computer Science](/...). A plain, unlinked ## suggests the title there is coming from a different template path (e.g. a theme that renders the title as a plain heading, or without the not page guard) rather than the linked-H2 path this issue addresses.
I'm therefore filing this as a separate issue covering the linked-H2 path I was able to reproduce and fix, rather than overload #3577458. If maintainers determine these share one root cause, please consolidate — this can be closed as a duplicate and the resolution below treated as a proposed patch on #3577458.
Steps to reproduce
- Install Markdownify on a Drupal 10/11 site using a standard theme (Olivero or Claro) with the default
nodeconfiguration enabled inmarkdownify.settings.yml. - Create any content type node (e.g. an Article) with a title such as "My Test Page" and some body content.
- Visit the node's canonical page and confirm the title renders correctly as an H1 in the page title block.
- Request the Markdown version by appending
.mdto the canonical path (themarkdownifylink template), e.g./node/1.md. - Observe the output: the document has no top-level
# My Test Pageheading. Depending on the theme, the title is either missing entirely or rendered as an H2 link, e.g.:
## [My Test Page](/node/1)
Body content here...
Expected: the title appears as a single top-level H1, with body content following:
# My Test Page
Body content here...
The same behavior occurs for taxonomy_term entities at /taxonomy/term/N.md.
Proposed Resolution
Markdownify needs the entity's title rendered as a single, clean H1 at the top of the document — the role normally filled by the page template, which Markdownify deliberately does not invoke. Reconcile the entity-level/page-level split as follows:
- Signal page context into the render array. When rendering in the
fullview mode (the document-equivalent view), flag the build so downstream hooks can detect the Markdownify pipeline:
if ($view_mode === 'full') {
$build['#markdownify_page'] = TRUE;
}
- Suppress the entity template's H2 title. Implement
hook_preprocess_node()/hook_preprocess_taxonomy_term()to set$variables['page'] = TRUEwhen#markdownify_pageis present. This satisfies thenot pageguard above so the entity template stops emitting the demoted H2 link. This is a safety net that works even against themes that don't honor template suggestions:
function markdownify_preprocess_node(&$variables): void {
if (!empty($variables['elements']['#markdownify_page'])) {
$variables['page'] = TRUE;
}
}
- Provide the H1 via module-owned templates. Register
node--markdownifyandtaxonomy-term--markdownifytemplates (viahook_theme()+hook_theme_suggestions_HOOK_alter(), gated on#markdownify_page) that produce predictable, conversion-friendly HTML — the entity label as a real<h1>followed by the content, with no theme wrapper markup:
<h1>{{ label }}</h1>
{% if content._layout_builder is not empty %}
{{ content._layout_builder }}
{% else %}
{{ content }}
{% endif %}
Using module-owned templates makes the output deterministic regardless of how the active theme customizes entity title rendering, and the Layout Builder branch avoids duplicate field output (fields otherwise render both standalone and inside LB sections).
- Normalize post-conversion whitespace. Nested HTML wrappers leave ragged indentation and blank-line runs in the Markdown. After conversion, strip per-line leading/trailing whitespace and collapse 3+ newlines to a single blank-line separator:
$markdown = preg_replace("/^[ \t]+/m", '', $markdown);
$markdown = preg_replace("/[ \t]+$/m", '', $markdown);
$markdown = preg_replace("/\n{3,}/", "\n\n", $markdown);
return trim($markdown) . "\n";
Why a template, not just prepending an H1 string
Simply prepending # {{ label }} to the converted output would still leave the demoted H2 title in the body (producing a duplicate heading) and would not address themes that render the title differently. Driving the fix through the render array — suppress at the entity template, emit the H1 from a Markdownify-owned template — keeps a single source of truth for the heading and isolates Markdownify from arbitrary theme overrides.
Scope
Applies to entity types with a canonical page that Markdownify supports (node, taxonomy_term by default). Block content and other embedded entities are unaffected: they have no canonical page, are never converted as standalone documents, and their templates carry no not page title guard.
| Comment | File | Size | Author |
|---|---|---|---|
| markdownify-template-title-fix.patch | 5.51 KB | zooney |
Issue fork markdownify-3594936
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
zooney commentedComment #4
zooney commentedThis issue (and resolution) could potentially be moot if the changes in https://www.drupal.org/project/markdownify/issues/3592796 are accepted.
Comment #5
zooney commented