diff --git a/composer.json b/composer.json index acbbb84f2..8b19c483d 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "symfony/polyfill-php82": "*", "t3docs/typo3-api": "self.version", "t3docs/typo3-docs-theme": "self.version", + "t3docs/typo3-docs-theme-md": "self.version", "t3docs/typo3-guides-cli": "self.version", "t3docs/typo3-guides-extension": "self.version", "t3docs/typo3-version-handling": "self.version" @@ -53,6 +54,7 @@ "psr-4": { "T3Docs\\GuidesCli\\": "packages/typo3-guides-cli/src/", "T3Docs\\GuidesExtension\\": "packages/typo3-guides-extension/src/", + "T3Docs\\Typo3DocsThemeMd\\": "packages/typo3-docs-theme-md/src/", "T3Docs\\Typo3DocsTheme\\": "packages/typo3-docs-theme/src/", "T3Docs\\VersionHandling\\": "packages/typo3-version-handling/src/" } diff --git a/composer.lock b/composer.lock index 54d835798..7c7ccaa9c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b00360a27ab5551f8286358ada2c82fe", + "content-hash": "5c18849c2c462858c6c711652ee3df6d", "packages": [ { "name": "brotkrueml/twig-codehighlight", diff --git a/config.subsplit-publish.json b/config.subsplit-publish.json index 00a287dd1..7a55cf0d8 100644 --- a/config.subsplit-publish.json +++ b/config.subsplit-publish.json @@ -24,6 +24,11 @@ "name": "t3docs-typo3-api", "directory": "packages/typo3-api", "target": "git@github.com:TYPO3-Documentation/t3docs-typo3-api.git" + }, + { + "name": "t3docs-typo3-docs-theme-md", + "directory": "packages/typo3-docs-theme-md", + "target": "git@github.com:TYPO3-Documentation/t3docs-typo3-docs-theme-md.git" } ] } diff --git a/packages/typo3-docs-theme-md/composer.json b/packages/typo3-docs-theme-md/composer.json new file mode 100644 index 000000000..e8745a89b --- /dev/null +++ b/packages/typo3-docs-theme-md/composer.json @@ -0,0 +1,16 @@ +{ + "name": "t3docs/typo3-docs-theme-md", + "description": "Markdown output format for TYPO3 Documentation", + "license": "MIT", + "type": "library", + "require": { + "php": "^8.2", + "phpdocumentor/guides": "^1.9", + "twig/twig": "^3.20" + }, + "autoload": { + "psr-4": { + "T3Docs\\Typo3DocsThemeMd\\": "src/" + } + } +} diff --git a/packages/typo3-docs-theme-md/resources/config/typo3-docs-theme-md.php b/packages/typo3-docs-theme-md/resources/config/typo3-docs-theme-md.php new file mode 100644 index 000000000..566147a70 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/config/typo3-docs-theme-md.php @@ -0,0 +1,37 @@ +services() + ->defaults() + ->autowire() + ->autoconfigure() + + ->load( + 'T3Docs\\Typo3DocsThemeMd\\NodeRenderers\\Md\\', + '../../src/NodeRenderers/Md', + ) + ->tag('phpdoc.guides.noderenderer.md') + + ->set(MdRenderer::class) + ->tag( + 'phpdoc.renderer.typerenderer', + [ + 'noderender_tag' => 'phpdoc.guides.noderenderer.md', + 'format' => 'md', + ], + ) + + + ->set(MdExtension::class) + ->arg('$nodeRenderer', service('phpdoc.guides.output_node_renderer')) + ->tag('twig.extension') + ->autowire(); +}; diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/admonition.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/admonition.md.twig new file mode 100644 index 000000000..06d847977 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/admonition.md.twig @@ -0,0 +1,48 @@ +{#- GFM alerts cover five kinds; the RST set is wider, so each admonition maps + to the closest alert and keeps its own wording as the first line when the + two do not coincide. A titled generic admonition has no alert at all and + renders as a blockquote with a bold title. -#} +{%- set alertMap = { + 'note': 'NOTE', + 'seealso': 'NOTE', + 'tip': 'TIP', + 'hint': 'TIP', + 'important': 'IMPORTANT', + 'warning': 'WARNING', + 'attention': 'WARNING', + 'caution': 'WARNING', + 'danger': 'CAUTION', + 'error': 'CAUTION' +} -%} +{%- set labelMap = { + 'note': 'Note', + 'seealso': 'See also', + 'tip': 'Tip', + 'hint': 'Hint', + 'important': 'Important', + 'warning': 'Warning', + 'attention': 'Attention', + 'caution': 'Caution', + 'danger': 'Danger', + 'error': 'Error' +} -%} +{%- set alert = alertMap[name]|default(null) -%} +{%- set label = labelMap[name]|default(null) -%} +{%- set body -%} +{{- renderNode(node) -}} +{%- endset -%} +{%- set inner -%} +{%- if alert %}[!{{ alert }}] +{% if label and label|lower != alert|lower %}**{{ label }}** + +{% endif %} +{%- elseif isTitled and title %}**{{ renderNode(title)|trim|raw }}** + +{% elseif label %}**{{ label }}** + +{% endif %} +{{- body|clean_content|raw -}} +{%- endset -%} +{{ renderMdPrefix(inner|clean_content, "> ")|raw }} +{{ "\n" }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/annotation-list.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/annotation-list.md.twig new file mode 100644 index 000000000..0a18afa9d --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/annotation-list.md.twig @@ -0,0 +1,4 @@ +{% for child in node.children -%} +{{- renderNode(child)|raw -}} +{% endfor -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/citation.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/citation.md.twig new file mode 100644 index 000000000..5f1da442c --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/citation.md.twig @@ -0,0 +1,3 @@ +{%- set body -%}{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%}{%- endset -%} +[{{ node.anchor|raw }}]: {{ body|inline_cell|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/code.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/code.md.twig new file mode 100644 index 000000000..f28b48f15 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/code.md.twig @@ -0,0 +1,12 @@ +{#- A caption says which file the snippet belongs in, or whether it is the + before or the after of a migration. Dropping it leaves consecutive blocks + in the same language indistinguishable, so it is carried as a bold line + above the fence, where HTML puts it too. -#} +{%- if node.caption -%} +**{{ renderNode(node.caption)|inline_text|raw }}** +{{ "\n" }} +{% endif -%} +```{{ node.language|default('')|raw }} +{{ node.value|raw }} +``` +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/collection.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/collection.md.twig new file mode 100644 index 000000000..358c94eff --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/collection.md.twig @@ -0,0 +1,6 @@ +{#- A collection is what "rst-class" leaves behind once it has applied its CSS + classes to the nodes inside it: the classes are presentation, the nodes are + the content. Without this the children are dropped, and because a + collection is structural the fallback marker was suppressed too, so the + loss was silent. -#} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/container.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/container.md.twig new file mode 100644 index 000000000..a6eb56e45 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/container.md.twig @@ -0,0 +1,22 @@ +{#- A container carries its meaning in its classes. A line block is a run of + lines whose breaks are part of the text - an address, a verse - and the + parser marks each one with the "line" class rather than a newline node, so + without this they run together. + + The break is written as a trailing backslash, not two trailing spaces: the + output is right-trimmed per line, which would silently remove the spaces + and the break with them. -#} +{%- set classes = node.classes -%} +{%- if 'line' in classes -%} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} +{%- elseif 'line-block' in classes -%} +{%- set lines = [] -%} +{%- for child in node.children -%} + {%- set lines = lines|merge([renderNode(child)|inline_text]) -%} +{%- endfor -%} +{{ lines|join("\\\n")|raw }} +{{ "\n" }} +{{ "\n" }} +{%- else -%} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} +{%- endif -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/definition-list-item.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/definition-list-item.md.twig new file mode 100644 index 000000000..dcf932b03 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/definition-list-item.md.twig @@ -0,0 +1,15 @@ +{#- Markdown has no definition list, so the term becomes a list item and its + definitions the item's body. Inside a list item indentation is + continuation, which is what ties the definition to its term; under a bare + paragraph it would be an indented code block. -#} +{%- set term -%} +{{- renderNode(node.term)|inline_text|raw -}} +{%- for classifier in node.classifiers %} : {{ renderNode(classifier)|inline_text|raw }}{% endfor -%} +{%- endset -%} +{%- set body -%} +{%- for definition in node.children -%}{{- renderNode(definition) -}}{%- endfor -%} +{%- endset -%} +- **{{ term|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/definition-list.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/definition-list.md.twig new file mode 100644 index 000000000..490f39019 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/definition-list.md.twig @@ -0,0 +1,2 @@ +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/definition.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/definition.md.twig new file mode 100644 index 000000000..60ccf2315 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/definition.md.twig @@ -0,0 +1,3 @@ +{#- The body of one definition; its term is written by the list item that + holds it. -#} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/accordion-item.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/accordion-item.md.twig new file mode 100644 index 000000000..05b4c1d0f --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/accordion-item.md.twig @@ -0,0 +1,5 @@ +{%- set title = node.title ? renderNode(node.title.value)|inline_text : '' -%} +{%- if title %}**{{ title|raw }}** +{{ "\n" }} +{% endif -%} +{{- renderNode(node.value) -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/accordion.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/accordion.md.twig new file mode 100644 index 000000000..56d9f944a --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/accordion.md.twig @@ -0,0 +1,5 @@ +{#- An accordion is a stack of collapsed panels. Collapsing is presentation; + the titles and the content are not, so they are written out in order. -#} +{%- for item in node.value -%} +{{- renderNode(item) -}} +{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/card-grid.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/card-grid.md.twig new file mode 100644 index 000000000..b572fa138 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/card-grid.md.twig @@ -0,0 +1,2 @@ +{#- The grid is the layout; the cards are the content. -#} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/card-group.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/card-group.md.twig new file mode 100644 index 000000000..b572fa138 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/card-group.md.twig @@ -0,0 +1,2 @@ +{#- The grid is the layout; the cards are the content. -#} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/card.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/card.md.twig new file mode 100644 index 000000000..ef520aaaf --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/card.md.twig @@ -0,0 +1,29 @@ +{#- A card is a box with an optional image, header, title, body and footer. The + box is presentation; everything inside it is content, written out in the + order the card shows it. + + Header and footer carry their text in "content" when the directive was + given an argument and in "value" when it was given a block, which is the + same pair the HTML template checks. A footer given a block can hold a whole + list, so it is written out as blocks; collapsing it onto one line turned a + list of version links into a single run-on line. -#} +{%- set title = node.title ? renderNode(node.title.value)|inline_text : '' -%} +{%- set header -%} +{%- if node.cardHeader -%}{{- renderNode(node.cardHeader.content) -}}{{- renderNode(node.cardHeader.value) -}}{%- endif -%} +{%- endset -%} +{%- set footer -%} +{%- if node.cardFooter -%}{{- renderNode(node.cardFooter.content) -}}{{- renderNode(node.cardFooter.value) -}}{%- endif -%} +{%- endset -%} +{%- if node.cardImage %}![{{ node.cardImage.alt|md_escape|raw }}]({{ node.cardImage.plainContent|raw }}) +{{ "\n" }} +{% endif -%} +{%- if header|trim %}**{{ header|inline_text|raw }}** +{{ "\n" }} +{% endif -%} +{%- if title %}**{{ title|raw }}** +{{ "\n" }} +{% endif -%} +{{- renderNode(node.value) -}} +{%- if footer|trim %} +{{ footer|clean_content|raw }} +{% endif -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/confval-menu.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/confval-menu.md.twig new file mode 100644 index 000000000..2cc5d7b14 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/confval-menu.md.twig @@ -0,0 +1,14 @@ +{#- The confval menu is an overview of the values that follow it, offered as a + list, a tree or a table. In HTML that is a navigational aid: it lets a + reader scan the available options and jump to one. Markdown has no jumping + and no collapsed tree, so the overview would only repeat, in shorter form, + what the full entries say directly below it. + + The caption is kept: it names the group of values, which the author wrote + as content rather than as decoration. -#} +{%- if node.caption %}**{{ node.caption|raw }}** +{{ "\n" }} +{% endif -%} +{%- for child in node.children -%} +{{- renderNode(child) -}} +{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/confval.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/confval.md.twig new file mode 100644 index 000000000..1e36147c6 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/confval.md.twig @@ -0,0 +1,46 @@ +{#- A configuration value: its name, the properties that constrain it and what + it does. + + Written as a list item so that the definition has an end. Markdown has no + definition lists, in GFM or otherwise, and indenting under a bare paragraph + does not express containment -- four spaces after a blank line is an + indented code block. Inside a list item the same indentation is a + continuation, which is what makes a nested confval nest and the next one + start somewhere visible. + + The name is bold and the property labels are italic, so the term being + explained outweighs the labels that describe it. + + The name is bold and nothing else. Bold around a code span is valid + CommonMark and GitHub renders it, but not every renderer parses emphasis + around a code span -- PhpStorm drops the bold silently -- and the term being + explained has to stand out, which is what bold does everywhere. + + Properties that are not set are left out: "Default: N/A" is noise a reader + has to skip. -#} +{%- set properties = [] -%} +{%- if node.type != null -%} + {%- set properties = properties|merge([{'label': 'Type', 'value': renderNode(node.type)}]) -%} +{%- endif -%} +{%- if node.required -%} + {%- set properties = properties|merge([{'label': 'Required', 'value': 'true'}]) -%} +{%- endif -%} +{%- if node.default != null -%} + {%- set properties = properties|merge([{'label': 'Default', 'value': renderNode(node.default)}]) -%} +{%- endif -%} +{%- for key, option in node.additionalOptions -%} + {%- if not (key in ['searchFacet', 'searchKeywords']) -%} + {%- set properties = properties|merge([{'label': key, 'value': renderNode(option)}]) -%} + {%- endif -%} +{%- endfor -%} +{%- set body -%} +{%- for property in properties -%} +- *{{ property.label|md_escape|raw }}:* {{ property.value|inline_text|raw }}{{ "\n" }} +{%- endfor -%} +{{ "\n" }} +{{- renderNode(node.value) -}} +{%- endset -%} +- **{{ node.plainContent|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/argument.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/argument.md.twig new file mode 100644 index 000000000..1e2b28171 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/argument.md.twig @@ -0,0 +1,21 @@ +{#- An argument of a console command: the name and what it does. + + No properties, the same as the HTML template. ArgumentNode carries + isRequired, isArray and default, but CommandNodeService fills two of the + three from the wrong keys: isRequired comes from "is_value_required", an + option key, so it is always false, and the default is given the + description. Nothing to render here until that is fixed upstream. + + The HTML template also offers a "Type" property that ArgumentNode does not + have, so that branch never renders anything either. -#} +{%- set body -%} +{{- node.description|md_escape|raw -}} +{%- if node.value -%} +{{ "\n\n" }} +{{- renderNode(node.value)|raw -}} +{%- endif -%} +{%- endset -%} +- **{{ node.plainContent|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/command-list.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/command-list.md.twig new file mode 100644 index 000000000..6fc6b238e --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/command-list.md.twig @@ -0,0 +1,32 @@ +{#- An overview table of the commands, followed by the commands themselves. + + A pipe table breaks on a blank line, so every row emits exactly one newline + and the whitespace control has to be explicit. + + A command is only linked when it can be linked: the anchor of a noindex + command is registered nowhere, so a permalink to it would lead to a 404 in + a downloaded file rather than to the command. -#} +{%- set namespaceCount = node.commandsByNamespace|length -%} +{{- '| Command | Description |' -}}{%- if node.showHidden %} Hidden |{% endif -%}{{- "\n" -}} +{{- '| --- | --- |' -}}{%- if node.showHidden %} --- |{% endif -%}{{- "\n" -}} +{%- for namespaceName, namespace in node.commandsByNamespace -%} + {%- if namespaceCount > 1 -%} + {{- ('| **' ~ (namespaceName is empty ? 'global' : namespaceName|md_escape) ~ '** | |')|raw -}} + {%- if node.showHidden %} |{% endif -%}{{- "\n" -}} + {%- endif -%} + {%- for command in namespace -%} + {%- set label = '`' ~ command.plainContent ~ '`' -%} + {%- if command.noindex -%} + {%- set cell = label -%} + {%- else -%} + {%- set cell = '[' ~ label ~ '](' ~ markdownLinkUrl('#' ~ command.anchor) ~ ')' -%} + {%- endif -%} + {{- ('| ' ~ cell ~ ' | ' ~ command.description|md_escape|inline_cell ~ ' |')|raw -}} + {%- if node.showHidden %} {{ command.hidden ? 'True' : '' }} |{% endif -%} + {{- "\n" -}} + {%- endfor -%} +{%- endfor -%} +{{- "\n" -}} +{%- for child in node.children -%} +{{- renderNode(child)|raw -}} +{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/command.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/command.md.twig new file mode 100644 index 000000000..69920a2db --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/command.md.twig @@ -0,0 +1,57 @@ +{#- A console command: what it is called, what it does, and the arguments and + options it takes. + + Written as a list item for the same reason a confval is: it gives the + definition an end, and the arguments and options nest visibly inside the + command they belong to rather than running on after it. + + The signature is bold and nothing else -- bold around a code span is valid + CommonMark, but not every renderer parses emphasis around code, and the name + is what has to stand out. The group labels below it are italic, so the + command outweighs the words that only introduce its parts. -#} +{%- set body -%} +{{- node.description|md_escape|raw -}} +{{ "\n\n" }} +{%- if node.usage -%} +*Usage* +{{ "\n\n" }} +{%- for item in node.usage -%} +{{- renderNode(item)|raw -}} +{{ "\n" }} +{%- endfor -%} +{{ "\n" }} +{%- endif -%} +{%- if node.argumentList -%} +*Arguments* +{{ "\n\n" }} +{%- for item in node.argumentList -%} +{{- renderNode(item)|raw -}} +{%- endfor -%} +{{ "\n" }} +{%- endif -%} +{%- if node.optionList -%} +*Options* +{{ "\n\n" }} +{%- for item in node.optionList -%} +{{- renderNode(item)|raw -}} +{%- endfor -%} +{{ "\n" }} +{%- endif -%} +{#- A command whose help text is empty still carries a help node, so the label + has to follow the rendered text rather than the node. -#} +{%- set help = node.help ? renderNode(node.help)|trim : '' -%} +{%- if help -%} +*Help* +{{ "\n\n" }} +{{- help|raw -}} +{{ "\n" }} +{%- endif -%} +{%- if node.value -%} +{{ "\n" }} +{{- renderNode(node.value)|raw -}} +{%- endif -%} +{%- endset -%} +- **{{ node.plainContent|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/option.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/option.md.twig new file mode 100644 index 000000000..39460fd02 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/console/option.md.twig @@ -0,0 +1,31 @@ +{#- An option of a console command. Same shape as a confval: the name, the + properties that constrain it, then what it does. -#} +{%- if node.isValueRequired -%} + {%- set accepts = 'Required' -%} +{%- elseif node.acceptValue -%} + {%- set accepts = 'Optional' -%} +{%- else -%} + {%- set accepts = 'None allowed' -%} +{%- endif -%} +{%- if node.isMultipe -%} + {%- set accepts = accepts ~ ' (multiple)' -%} +{%- endif -%} +{%- set properties = [{'label': 'Value', 'value': accepts}] -%} +{%- if node.default != null -%} + {%- set properties = properties|merge([{'label': 'Default value', 'value': node.default|md_escape}]) -%} +{%- endif -%} +{%- set body -%} +{%- for property in properties -%} +- *{{ property.label }}:* {{ property.value|raw }}{{ "\n" }} +{%- endfor -%} +{{ "\n" }} +{{- node.description|md_escape|raw -}} +{%- if node.value -%} +{{ "\n\n" }} +{{- renderNode(node.value)|raw -}} +{%- endif -%} +{%- endset -%} +- **{{ node.plainContent|md_escape|raw }}{% if node.shortcut %} / {{ node.shortcut|md_escape|raw }}{% endif %}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/directory-tree.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/directory-tree.md.twig new file mode 100644 index 000000000..057f27fd8 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/directory-tree.md.twig @@ -0,0 +1,35 @@ +{#- A directory tree is a nested list that HTML gives folder icons and collapse + toggles. Markdown keeps the nesting and drops the rest. + + The items are not ordinary list nodes -- an item holds its label in its + children and its sub-trees in "subLists" -- so the structure is walked the + same way the HTML template walks it. Passing the node through would run + every name together on one line. + + Each item emits exactly one newline: a blank line between them would make + the list loose, and a renderer then wraps every name in its own paragraph. -#} +{%- macro item(node) -%} + {%- set label -%} + {%- for part in node.children -%} + {%- if not part.isList -%}{{ renderNode(part)|raw }}{%- endif -%} + {%- endfor -%} + {%- endset -%} + {%- set children -%} + {%- for list in node.subLists -%} + {%- for child in list.children -%} + {{- _self.item(child) -}} + {%- endfor -%} + {%- endfor -%} + {%- endset -%} + {{- ('- ' ~ label|inline_text)|raw -}}{{- "\n" -}} + {%- if children|trim -%} + {{- renderMdIndent(children|clean_content, 2)|raw -}} + {%- endif -%} +{%- endmacro -%} +{%- import _self as tree -%} +{%- for list in node.children -%} + {%- for entry in list.children -%} + {{- tree.item(entry) -}} + {%- endfor -%} +{%- endfor -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/edit-on-github-include.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/edit-on-github-include.md.twig new file mode 100644 index 000000000..ffe88761d --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/edit-on-github-include.md.twig @@ -0,0 +1,2 @@ +{#- An "Edit on GitHub" button for an included section. It is a control, not + content, and a Markdown file has nowhere to put it. -#} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/general-child.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/general-child.md.twig new file mode 100644 index 000000000..6bc5d68eb --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/general-child.md.twig @@ -0,0 +1 @@ +{{- renderNode(node) -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/glossary.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/glossary.md.twig new file mode 100644 index 000000000..ce4b02a70 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/glossary.md.twig @@ -0,0 +1,5 @@ +{#- A glossary is its entries. The A-Z bar and the letter headings the HTML + template builds are navigation for a long page; in a file you search rather + than scroll, they are scaffolding around the terms, so only the terms + remain. -#} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/guides/codeblock-languages.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/guides/codeblock-languages.md.twig new file mode 100644 index 000000000..a12a38655 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/guides/codeblock-languages.md.twig @@ -0,0 +1,5 @@ +{#- The languages the syntax highlighter knows, as a plain list. -#} +{%- for language in codehighlight_languages() -%} +- {{ language|md_escape|raw }}{{ "\n" }} +{%- endfor -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/hlist.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/hlist.md.twig new file mode 100644 index 000000000..64a71d5ee --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/hlist.md.twig @@ -0,0 +1,3 @@ +{#- The columns are the layout; the list is the content. Markdown has no way to + lay a list out sideways, so it stays a plain list. -#} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/main-menu-json.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/main-menu-json.md.twig new file mode 100644 index 000000000..f741ff6d4 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/main-menu-json.md.twig @@ -0,0 +1 @@ +{#- Only the JSON output renders this; the HTML template is empty too. -#} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/option.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/option.md.twig new file mode 100644 index 000000000..094e5ae95 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/option.md.twig @@ -0,0 +1,9 @@ +{#- The reStructuredText "option" directive, which TYPO3 manuals use as an + alias for a configuration value. Shaped like a confval for that reason. -#} +{%- set body -%} +{{- renderNode(node.value)|raw -}} +{%- endset -%} +- **{{ node.plainContent|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/case.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/case.md.twig new file mode 100644 index 000000000..63371f393 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/case.md.twig @@ -0,0 +1,11 @@ +{%- set signature -%} + case {{ renderNode(node.memberName)|raw }} + {%- if node.backedValue %} : {{ node.backedValue|raw }}{% endif -%} +{%- endset -%} +{%- set body -%} +{{- renderNode(node.value)|raw -}} +{%- endset -%} +- **{{ signature|inline_text|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/component.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/component.md.twig new file mode 100644 index 000000000..768b00422 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/component.md.twig @@ -0,0 +1,25 @@ +{#- A class, interface, trait, enum or exception. + + Shaped like a confval, the way every named definition in this theme is: the + signature bold as a list item, so the definition has an end and its members + nest inside it rather than running on after it. + + Short properties come before the description and blocks come after it. A + fully qualified name is one line of text, so it goes first. -#} +{%- set fqn = node.name.namespaceNode ? '\\' ~ node.name.namespaceNode.name ~ '\\' ~ node.name.name : '' -%} +{%- set signature -%} + {%- for modifier in node.modifiers -%}{{ renderNode(modifier)|raw }} {% endfor -%} + {{- node.type|raw }} {{ node.name.name|raw -}} + {%- if node.type == 'enum' and node.phpType %} : {{ node.phpType|raw }}{% endif -%} +{%- endset -%} +{%- set body -%} + {%- if fqn -%} +- *Fully qualified name:* `{{ fqn|raw }}` +{{ "\n\n" }} + {%- endif -%} + {{- renderNode(node.value)|raw -}} +{%- endset -%} +- **{{ signature|inline_text|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/const.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/const.md.twig new file mode 100644 index 000000000..81364c068 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/const.md.twig @@ -0,0 +1,12 @@ +{%- set signature -%} + {%- for modifier in node.modifiers -%}{{ renderNode(modifier)|raw }} {% endfor -%} + {%- if node.phpType %}{{ node.phpType|raw }} {% endif -%} + const {{ renderNode(node.memberName)|raw -}} +{%- endset -%} +{%- set body -%} +{{- renderNode(node.value)|raw -}} +{%- endset -%} +- **{{ signature|inline_text|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/global.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/global.md.twig new file mode 100644 index 000000000..3a53488a5 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/global.md.twig @@ -0,0 +1,7 @@ +{%- set body -%} +{{- renderNode(node.value)|raw -}} +{%- endset -%} +- **{{ ('global ' ~ node.name)|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/memberName.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/memberName.md.twig new file mode 100644 index 000000000..b8f7f8e7e --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/memberName.md.twig @@ -0,0 +1,2 @@ +{#- A member name is part of a signature, so it renders bare. -#} +{{- node.name|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/method.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/method.md.twig new file mode 100644 index 000000000..444c3e01d --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/method.md.twig @@ -0,0 +1,22 @@ +{#- A method: its signature, what it does, and what it gives back. + + "Returns" follows the description rather than preceding it: unlike a type or + a default it is a block of prose, and a block above the description would + push the description out of sight. -#} +{%- set signature -%} + {%- for modifier in node.modifiers -%}{{ renderNode(modifier)|raw }} {% endfor -%} + {%- for modifier in node.methodName.modifiers -%}{{ renderNode(modifier)|raw }} {% endfor -%} + {{- node.methodName.name|raw -}}({{ node.methodName.params|join(', ')|raw }}) + {%- if node.methodName.return %} : {{ node.methodName.return|raw }}{% endif -%} +{%- endset -%} +{%- set body -%} + {{- renderNode(node.value)|raw -}} + {%- if node.returnsDescription -%} +{{ "\n\n" }} +*Returns:* {{ renderNode(node.returnsDescription)|inline_text|raw }} + {%- endif -%} +{%- endset -%} +- **{{ signature|inline_text|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/modifier.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/modifier.md.twig new file mode 100644 index 000000000..1ce390479 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/modifier.md.twig @@ -0,0 +1,2 @@ +{#- A modifier is part of a signature, so it renders bare. -#} +{{- node.type|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/namespace.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/namespace.md.twig new file mode 100644 index 000000000..e0f8b1b94 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/namespace.md.twig @@ -0,0 +1,3 @@ +{#- The HTML template is an anchor and nothing else. Markdown has no anchor to + place, and the namespace is already spelled out in the fully qualified name + of every component inside it. -#} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/property.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/property.md.twig new file mode 100644 index 000000000..3babaa70e --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/php/property.md.twig @@ -0,0 +1,12 @@ +{%- set signature -%} + {%- for modifier in node.modifiers -%}{{ renderNode(modifier)|raw }} {% endfor -%} + {%- if node.phpType %}{{ node.phpType|raw }} {% endif -%} + {{- renderNode(node.memberName)|raw -}} +{%- endset -%} +{%- set body -%} +{{- renderNode(node.value)|raw -}} +{%- endset -%} +- **{{ signature|inline_text|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/rubric.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/rubric.md.twig new file mode 100644 index 000000000..70b38e060 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/rubric.md.twig @@ -0,0 +1,6 @@ +{#- A rubric is an informal heading: it titles what follows without entering + the document outline. Bold is the Markdown form that does the same - a real + heading would put it in the outline, which is what the author avoided by + reaching for a rubric. -#} +**{{ renderNode(node.content)|trim|raw }}** +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/tabs.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/tabs.md.twig new file mode 100644 index 000000000..4f76dcfe4 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/tabs.md.twig @@ -0,0 +1,11 @@ +{#- Tabs show one panel at a time and hide the rest behind a click. Markdown + has no panels, so each tab becomes a bold lead-in with its content below + it, one after another - which is what a reader of the page would get by + clicking through them all. -#} +{%- for tab in node.tabs -%} +{%- set title = renderNode(tab.content)|inline_text -%} +{%- if title %}**{{ title|raw }}** +{{ "\n" }} +{% endif -%} +{{- renderNode(tab.value) -}} +{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/typo3-file.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/typo3-file.md.twig new file mode 100644 index 000000000..6c0c69b74 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/typo3-file.md.twig @@ -0,0 +1,31 @@ +{#- A TYPO3 file definition: the file name and where it lives. Same shape as a + confval, which is what the HTML template builds it as too. -#} +{%- set properties = [] -%} +{%- if node.scope -%} + {%- set properties = properties|merge([{'label': 'Scope', 'value': node.scope|md_escape}]) -%} +{%- endif -%} +{%- if node.composerPathPrefix or node.composerPath -%} + {%- set properties = properties|merge([{'label': 'Path (Composer)', 'value': (node.composerPathPrefix ~ node.composerPath ~ node.plainContent)|md_escape}]) -%} +{%- endif -%} +{%- if node.classicPathPrefix or node.classicPath -%} + {%- set properties = properties|merge([{'label': 'Path (Classic)', 'value': (node.classicPathPrefix ~ node.classicPath ~ node.plainContent)|md_escape}]) -%} +{%- endif -%} +{%- if node.configuration -%} + {%- set properties = properties|merge([{'label': 'Configuration', 'value': renderNode(node.configuration)|inline_text}]) -%} +{%- endif -%} +{%- if node.command -%} + {%- set properties = properties|merge([{'label': 'Command', 'value': renderNode(node.command)|inline_text}]) -%} +{%- endif -%} +{%- set body -%} +{%- for property in properties -%} +- *{{ property.label }}:* {{ property.value|raw }}{{ "\n" }} +{%- endfor -%} +{{ "\n" }} +{%- if node.description -%} +{{- renderNode(node.description)|raw -}} +{%- endif -%} +{%- endset -%} +- **{{ node.plainContent|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/viewhelper-argument.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/viewhelper-argument.md.twig new file mode 100644 index 000000000..76bb89ddd --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/viewhelper-argument.md.twig @@ -0,0 +1,24 @@ +{#- An argument of a ViewHelper. Same shape as a confval, which is what the + HTML template builds it as too: the name, the properties that constrain it, + then what it does. + + "Required" is a boolean and prints as "1" in HTML; here it reads as "true", + the way a confval states it. -#} +{%- set properties = [{'label': 'Type', 'value': node.type|md_escape}] -%} +{%- if node.defaultValue != null -%} + {%- set properties = properties|merge([{'label': 'Default', 'value': node.defaultValue|md_escape}]) -%} +{%- endif -%} +{%- if node.required -%} + {%- set properties = properties|merge([{'label': 'Required', 'value': 'true'}]) -%} +{%- endif -%} +{%- set body -%} +{%- for property in properties -%} +- *{{ property.label }}:* {{ property.value|raw }}{{ "\n" }} +{%- endfor -%} +{{ "\n" }} +{{- node.description|md_escape|raw -}} +{%- endset -%} +- **{{ node.plainContent|md_escape|raw }}** +{{ "\n" }} +{{ renderMdIndent(body|clean_content, 2)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/viewhelper.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/viewhelper.md.twig new file mode 100644 index 000000000..47b3bb3d8 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/viewhelper.md.twig @@ -0,0 +1,82 @@ +{#- A ViewHelper: its doc tags, prose, source link and arguments. + + The parts and their order come from the directive's "display" option, the + same as in HTML, so a page that asks for arguments only gets arguments only. + + "Arguments" is bold rather than a heading. The HTML template makes it an +

, but a section can hold several ViewHelpers -- the rendertest has two + in one section -- and a real heading would leave the outline claiming the + arguments of the first one end where the second one starts. -#} +{%- set out -%} +{%- for display in node.display -%} + {%- if display == 'tags' and node.docTags -%} + {%- for key, docTag in node.docTags -%} + {%- if key == '@deprecated' -%} + {%- set alert -%} +[!WARNING] +**Deprecated** + +{{ docTag|md_escape|raw }} + {%- endset -%} +{{ renderMdPrefix(alert|clean_content, "> ")|raw }} +{{ "\n\n" }} + {%- elseif key == '@internal' -%} + {%- set alert -%} +[!WARNING] +**Internal** + +This ViewHelper is marked as internal. It is subject to be changed without notice. Use at your own risk. + {%- endset -%} +{{ renderMdPrefix(alert|clean_content, "> ")|raw }} +{{ "\n\n" }} + {%- elseif key not in ['@see', '@api', '@todo'] -%} +{{ key|md_escape|raw }}: {{ docTag|md_escape|raw }} +{{ "\n\n" }} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + {%- if display == 'description' -%} +{{- renderNode(node.description)|raw -}} +{{ "\n\n" }} + {%- endif -%} + {%- if display == 'sections' -%} +{{- renderNode(node.sections)|raw -}} +{{ "\n\n" }} + {%- endif -%} + {%- if display == 'examples' -%} +{{- renderNode(node.examples)|raw -}} +{{ "\n\n" }} + {%- endif -%} + {%- if display == 'documentation' -%} +{{- renderNode(node.documentation)|raw -}} +{{ "\n\n" }} + {%- endif -%} + {%- if display == 'gitHubLink' and node.gitHubLink -%} +Go to the source code of this ViewHelper: [{{ node.shortClassName|md_escape|raw }}.php (GitHub)]({{ node.gitHubLink }}). +{{ "\n\n" }} + {%- endif -%} + {%- if display in ['arguments', 'arguments-only'] and (node.arguments or node.allowsArbitraryArguments) -%} + {%- if display == 'arguments' -%} +**Arguments** +{{ "\n\n" }} + {%- if node.allowsArbitraryArguments -%} + {%- set alert -%} +[!NOTE] +**Allows arbitrary arguments** + +This ViewHelper allows you to pass arbitrary arguments not defined below directly to the HTML tag created. This includes custom `data-` arguments. + {%- endset -%} +{{ renderMdPrefix(alert|clean_content, "> ")|raw }} +{{ "\n\n" }} + {%- endif -%} +The following arguments are available for the {{ node.tagName|md_escape|raw }} ViewHelper: +{{ "\n\n" }} + {%- endif -%} + {%- for argument in node.arguments -%} +{{- renderNode(argument)|raw -}} + {%- endfor -%} + {%- endif -%} +{%- endfor -%} +{%- endset -%} +{{ out|clean_content|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/directive/youtube.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/directive/youtube.md.twig new file mode 100644 index 000000000..39a4a5235 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/directive/youtube.md.twig @@ -0,0 +1,7 @@ +{#- Markdown cannot embed a player, and the node carries nothing but the video + id, so the video becomes a link to itself. The watch URL rather than the + nocookie embed one: the privacy gain of youtube-nocookie.com is about + loading the player on our page, which a link does not do, and the watch + page is what a reader following the link expects. -#} +[Watch this video on YouTube](https://www.youtube.com/watch?v={{ node.youtubeId }}) +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/field-list-item.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/field-list-item.md.twig new file mode 100644 index 000000000..5f95766bd --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/field-list-item.md.twig @@ -0,0 +1,15 @@ +{#- Document metadata: a short label and its value. The value goes on the same + line when it is one line, and below it when it is not - a field can hold a + whole block, and collapsing that would run it together. -#} +{%- set body -%} +{%- for definition in node.children -%}{{- renderNode(definition) -}}{%- endfor -%} +{%- endset -%} +{%- set cleaned = body|clean_content|trim -%} +{%- if cleaned matches '/\\n/' -%} +- *{{ node.term|md_escape|raw }}:* +{{ "\n" }} +{{ renderMdIndent(cleaned, 2)|raw }} +{{ "\n" }} +{%- else -%} +- *{{ node.term|md_escape|raw }}:* {{ cleaned|raw }}{{ "\n" }} +{%- endif -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/field-list.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/field-list.md.twig new file mode 100644 index 000000000..490f39019 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/field-list.md.twig @@ -0,0 +1,2 @@ +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/figure.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/figure.md.twig new file mode 100644 index 000000000..b71a1a6b1 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/figure.md.twig @@ -0,0 +1,5 @@ +{{ renderNode(node.image)|raw }} +{%- if node.caption %} +{{ renderNode(node.caption)|raw }} +{% endif -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/footnote.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/footnote.md.twig new file mode 100644 index 000000000..aa0021a2d --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/footnote.md.twig @@ -0,0 +1,3 @@ +{%- set body -%}{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%}{%- endset -%} +[^{{ node.number }}]: {{ body|inline_cell|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/image.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/image.md.twig new file mode 100644 index 000000000..de6549e8e --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/image.md.twig @@ -0,0 +1,13 @@ +{#- Alt text, title and target live in the directive's options, the same ones + the HTML template reads. Without them an image renders as "![]()", which a + reader sees as an empty link rather than a picture. -#} +{%- set src = node.value is external_target ? node.value : asset(node.value) -%} +{%- set alt = node.hasOption('alt') ? node.option('alt')|md_escape : '' -%} +{%- set title = node.hasOption('title') ? ' "' ~ node.option('title')|replace({'"': '\\"'}) ~ '"' : '' -%} +{%- set image = '![' ~ alt ~ '](' ~ src ~ title ~ ')' -%} +{%- if node.target -%} +[{{ image|raw }}]({{ node.target.url|raw }}) +{%- else -%} +{{ image|raw }} +{%- endif %} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/list/list-item.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/list/list-item.md.twig new file mode 100644 index 000000000..14164de26 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/list/list-item.md.twig @@ -0,0 +1 @@ +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/list/list.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/list/list.md.twig new file mode 100644 index 000000000..a08b14497 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/list/list.md.twig @@ -0,0 +1,10 @@ +{% if node.isOrdered -%} + {%- for item in node.value -%} +1. {{ renderMdIndent(renderNode(item.value)|clean_content, 2)|trim|raw }}{{ "\n" }} + {%- endfor -%} +{%- else -%} + {%- for item in node.value -%} +- {{ renderMdIndent(renderNode(item.value)|clean_content, 2)|trim|raw }}{{ "\n" }} + {%- endfor -%} +{%- endif %} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/literal-block.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/literal-block.md.twig new file mode 100644 index 000000000..8b1fc3a99 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/literal-block.md.twig @@ -0,0 +1,4 @@ +``` +{{ node.value|raw }} +``` +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/math.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/math.md.twig new file mode 100644 index 000000000..6e6b05612 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/math.md.twig @@ -0,0 +1,6 @@ +{#- A formula, tagged as math: it is the source either way, and a renderer that + knows the fence -- GitHub does -- sets it. -#} +```math +{{ node.value|trim|raw }} +``` +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/menu/content-menu.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/menu/content-menu.md.twig new file mode 100644 index 000000000..7a5f7a6f4 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/menu/content-menu.md.twig @@ -0,0 +1,7 @@ +{%- if node.caption %}**{{ renderNode(node.caption)|trim|raw }}** +{{ "\n" }} +{%- elseif node.options['caption'] %}**{{ node.options['caption']|raw }}** +{{ "\n" }} +{%- endif %} +{%- include "body/menu/menu-level.md.twig" -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu-item.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu-item.md.twig new file mode 100644 index 000000000..ec50824ca --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu-item.md.twig @@ -0,0 +1,5 @@ +{%- set children -%} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} +{%- endset -%} +- [{{ node.value.toString|raw }}]({{ markdownLinkUrl(url)|raw }}) +{% if children|trim %}{{ renderMdIndent(children|clean_content, 2)|raw }}{% endif %} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu-level.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu-level.md.twig new file mode 100644 index 000000000..e6b758c08 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu-level.md.twig @@ -0,0 +1 @@ +{%- for entry in node.menuEntries -%}{{- renderNode(entry) -}}{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu.md.twig new file mode 100644 index 000000000..7a5f7a6f4 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/menu/menu.md.twig @@ -0,0 +1,7 @@ +{%- if node.caption %}**{{ renderNode(node.caption)|trim|raw }}** +{{ "\n" }} +{%- elseif node.options['caption'] %}**{{ node.options['caption']|raw }}** +{{ "\n" }} +{%- endif %} +{%- include "body/menu/menu-level.md.twig" -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/menu/table-of-content.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/menu/table-of-content.md.twig new file mode 100644 index 000000000..7a5f7a6f4 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/menu/table-of-content.md.twig @@ -0,0 +1,7 @@ +{%- if node.caption %}**{{ renderNode(node.caption)|trim|raw }}** +{{ "\n" }} +{%- elseif node.options['caption'] %}**{{ node.options['caption']|raw }}** +{{ "\n" }} +{%- endif %} +{%- include "body/menu/menu-level.md.twig" -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/paragraph.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/paragraph.md.twig new file mode 100644 index 000000000..043801fdf --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/paragraph.md.twig @@ -0,0 +1,6 @@ +{% set text = renderNode(node.value)|raw -%} +{%- if text|trim %} + {{- text|raw -}} +{% endif -%} +{{ "\n" }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/quote.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/quote.md.twig new file mode 100644 index 000000000..5cc676262 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/quote.md.twig @@ -0,0 +1,6 @@ +{%- set inner -%} + {%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} +{%- endset -%} +{{ renderMdPrefix(inner|clean_content, "> ")|raw }} +{{ "\n" }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/separator.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/separator.md.twig new file mode 100644 index 000000000..10b516b89 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/separator.md.twig @@ -0,0 +1,2 @@ +--- +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/sidebar.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/sidebar.md.twig new file mode 100644 index 000000000..299e96237 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/sidebar.md.twig @@ -0,0 +1,8 @@ +{#- A sidebar is a box floated beside the text: the box is presentation, the + title and the content are not. Markdown has no box, so the content flows + into the document and the title is kept as a lead-in. -#} +{%- if node.title %}**{{ renderNode(node.title)|trim|raw }}** +{{ "\n" }} +{% endif -%} +{%- for child in node.children -%}{{- renderNode(child) -}}{%- endfor -%} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/table.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/table.md.twig new file mode 100644 index 000000000..b3b3a504a --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/table.md.twig @@ -0,0 +1,2 @@ +{{ renderMdTable(node)|raw }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/uml.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/uml.md.twig new file mode 100644 index 000000000..5f51d53c8 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/uml.md.twig @@ -0,0 +1,11 @@ +{#- A UML diagram is PlantUML source that HTML turns into a picture. Markdown + has no renderer, so the source is shown as it is, tagged as plantuml for the + renderers that do know it. -#} +```plantuml +{{ node.value|trim|raw }} +``` +{%- if node.caption %} +{{ "\n" }} +{{ node.caption|md_escape|raw }} +{%- endif %} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/body/version-change.md.twig b/packages/typo3-docs-theme-md/resources/template/md/body/version-change.md.twig new file mode 100644 index 000000000..74aa39a7d --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/body/version-change.md.twig @@ -0,0 +1,15 @@ +{#- A version change is an info box in HTML, icon and all. Markdown keeps the + box as a GFM alert and the version label as its heading; a deprecation is a + warning rather than a note, matching what the icon conveys. -#} +{%- set alert = node.type == 'deprecated' ? 'WARNING' : 'NOTE' -%} +{%- set body -%} +{{- renderNode(node.value) -}} +{%- endset -%} +{%- set inner -%}[!{{ alert }}] +**{{ node.versionLabel|raw }}** + +{{ body|clean_content|raw }} +{%- endset -%} +{{ renderMdPrefix(inner|clean_content, "> ")|raw }} +{{ "\n" }} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/anchor.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/anchor.md.twig new file mode 100644 index 000000000..e69de29bb diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/citation.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/citation.md.twig new file mode 100644 index 000000000..18ce2a6f6 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/citation.md.twig @@ -0,0 +1 @@ +[{{- node|plaintext|raw -}}] diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/emphasis.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/emphasis.md.twig new file mode 100644 index 000000000..477ba0c5b --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/emphasis.md.twig @@ -0,0 +1 @@ +{{- node|plaintext|md_escape|md_wrap('*')|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/footnote.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/footnote.md.twig new file mode 100644 index 000000000..3bdc93dad --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/footnote.md.twig @@ -0,0 +1 @@ +[^{{ node.number }}] diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/image.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/image.md.twig new file mode 100644 index 000000000..a4175acc3 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/image.md.twig @@ -0,0 +1 @@ +![{{ node.altText|default('')|raw }}]({{ node.url|default(node.value)|raw }}) diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/inline-node.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/inline-node.md.twig new file mode 100644 index 000000000..9150f32bc --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/inline-node.md.twig @@ -0,0 +1,3 @@ +{%- for child in node.children -%} + {{- renderNode(child)|raw -}} +{%- endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/link.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/link.md.twig new file mode 100644 index 000000000..26673c0cb --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/link.md.twig @@ -0,0 +1,6 @@ +{#- The link text is prose and gets escaped; the URL must stay verbatim. -#} +{%- if node.url -%} + [{{ node|plaintext|md_escape|raw }}]({{ markdownLinkUrl(node.url)|raw }}) +{%- else -%} + {{- node|plaintext|md_escape|raw -}} +{%- endif -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/literal.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/literal.md.twig new file mode 100644 index 000000000..162fa587a --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/literal.md.twig @@ -0,0 +1 @@ +`{{- node|plaintext|raw -}}` diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/nbsp.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/nbsp.md.twig new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/nbsp.md.twig @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/newline.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/newline.md.twig new file mode 100644 index 000000000..04e6842bd --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/newline.md.twig @@ -0,0 +1 @@ +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/plain-text.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/plain-text.md.twig new file mode 100644 index 000000000..c674b6c1a --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/plain-text.md.twig @@ -0,0 +1,2 @@ +{#- Source prose, not Markdown: escape what would otherwise start a construct. -#} +{{- node.value|md_escape|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/strong.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/strong.md.twig new file mode 100644 index 000000000..affdc9eaa --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/strong.md.twig @@ -0,0 +1 @@ +{{- node|plaintext|md_escape|md_wrap('**')|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/abbreviation.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/abbreviation.md.twig new file mode 100644 index 000000000..b46934e46 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/abbreviation.md.twig @@ -0,0 +1 @@ +{{- node|plaintext|md_escape|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/aspect.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/aspect.md.twig new file mode 100644 index 000000000..9205057f9 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/aspect.md.twig @@ -0,0 +1 @@ +{{- node.value|md_escape|md_wrap('*')|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/br.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/br.md.twig new file mode 100644 index 000000000..04e6842bd --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/br.md.twig @@ -0,0 +1 @@ +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/code.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/code.md.twig new file mode 100644 index 000000000..211f49af2 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/code.md.twig @@ -0,0 +1,4 @@ +{#- The HTML rendering wraps this in a modal trigger carrying signature, flags + and a help text. None of that survives as Markdown, and the code itself is + what a reader needs. -#} +`{{- node.value|raw -}}` diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/composer.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/composer.md.twig new file mode 100644 index 000000000..c63e602f9 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/composer.md.twig @@ -0,0 +1,5 @@ +{%- if node.package.packagistLink -%} + [`{{ node.composerName|raw }}`]({{ node.package.packagistLink }}) +{%- else -%} + `{{- node.composerName|raw -}}` +{%- endif -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/emphasis.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/emphasis.md.twig new file mode 100644 index 000000000..9205057f9 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/emphasis.md.twig @@ -0,0 +1 @@ +{{- node.value|md_escape|md_wrap('*')|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/file.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/file.md.twig new file mode 100644 index 000000000..669be9cb8 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/file.md.twig @@ -0,0 +1,7 @@ +{%- if node.fileObject and node.url -%} + [`{{ node.fileLabel|raw }}`]({{ node.url }}) +{%- elseif node.fileObject -%} + `{{- node.fileLabel|raw -}}` +{%- else -%} + `{{- node.value|raw -}}` +{%- endif -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/generic.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/generic.md.twig new file mode 100644 index 000000000..e257ccd0c --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/generic.md.twig @@ -0,0 +1,6 @@ +{#- Dispatch by role name, the way the HTML theme does, so a role with a + Markdown form of its own gets it and everything else falls back. -#} +{%- include [ + ('inline/textroles/' ~ node.type ~ '.md.twig'), + 'inline/textroles/unknown.md.twig' +] -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/guilabel.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/guilabel.md.twig new file mode 100644 index 000000000..b0b0095dc --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/guilabel.md.twig @@ -0,0 +1 @@ +{{- node.value|md_escape|md_wrap('**')|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/strong.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/strong.md.twig new file mode 100644 index 000000000..b0b0095dc --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/strong.md.twig @@ -0,0 +1 @@ +{{- node.value|md_escape|md_wrap('**')|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/title-reference.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/title-reference.md.twig new file mode 100644 index 000000000..9205057f9 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/title-reference.md.twig @@ -0,0 +1 @@ +{{- node.value|md_escape|md_wrap('*')|raw -}} diff --git a/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/unknown.md.twig b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/unknown.md.twig new file mode 100644 index 000000000..55d084bef --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/inline/textroles/unknown.md.twig @@ -0,0 +1,4 @@ +{#- The HTML theme renders an unmapped text role as , and most roles that + reach here mark up something meant to be read literally - a command, a key, + a file. Backticks say the same in Markdown. -#} +`{{- node.value|raw -}}` diff --git a/packages/typo3-docs-theme-md/resources/template/md/structure/document.md.twig b/packages/typo3-docs-theme-md/resources/template/md/structure/document.md.twig new file mode 100644 index 000000000..3d4d459ac --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/structure/document.md.twig @@ -0,0 +1,39 @@ +{#- YAML front matter: what a tool needs to know about the file that the file + itself cannot say. Not CommonMark, but what every tool that reads Markdown + expects, and this output exists to be read by tools. + + A key whose value is unknown is left out rather than written empty: a + permalink with nothing behind it is worse than no permalink. -#} +{%- set permalink = markdownPermalink() -%} +{%- set version = markdownVersion() -%} +{%- set source = sourceFilename() -%} +{%- set frontMatter -%} +{%- if node.title %}title: {{ node.title.toString()|md_yaml|raw }} +{% endif -%} +{%- if env.projectNode.title %}manual: {{ env.projectNode.title|md_yaml|raw }} +{% endif -%} +{%- if version %}version: {{ version|md_yaml|raw }} +{% endif -%} +{%- if permalink %}permalink: {{ permalink|md_yaml|raw }} +{% endif -%} +{%- if source %}source: {{ source|md_yaml|raw }} +{% endif -%} +{%- if markdownIsStartPage() %}start: true +{% endif -%} +{%- if env.projectNode.lastRendered %}modified: {{ env.projectNode.lastRendered|date("Y-m-d\\TH:i:sP")|md_yaml|raw }} +{% endif -%} +{%- if isNoSearch() %}noindex: true +{% endif -%} +{%- endset -%} +{%- set renderedContent -%} + {%- for child in node.children -%} + {{- renderNode(child) -}} + {%- endfor -%} +{%- endset -%} +{%- if frontMatter|trim -%} +--- +{{ frontMatter|trim|raw }} +--- +{{ "\n" }}{% endif -%} +{{- renderedContent | clean_content | trim | raw -}} +{{ "\n" }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/structure/header-title.md.twig b/packages/typo3-docs-theme-md/resources/template/md/structure/header-title.md.twig new file mode 100644 index 000000000..8c5145599 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/structure/header-title.md.twig @@ -0,0 +1,3 @@ +{#- Twig autoescapes for HTML here, so every helper result has to be marked + raw explicitly: an escaped entity in a Markdown file is simply wrong. -#} +{{ renderMdTitle(node, renderNode(node.value))|raw }} diff --git a/packages/typo3-docs-theme-md/resources/template/md/structure/header/blank.md.twig b/packages/typo3-docs-theme-md/resources/template/md/structure/header/blank.md.twig new file mode 100644 index 000000000..e69de29bb diff --git a/packages/typo3-docs-theme-md/resources/template/md/structure/section.md.twig b/packages/typo3-docs-theme-md/resources/template/md/structure/section.md.twig new file mode 100644 index 000000000..e9538aa5d --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/structure/section.md.twig @@ -0,0 +1,4 @@ +{% for childNode in node.children -%} + {{- renderNode(childNode) }} + +{% endfor -%} diff --git a/packages/typo3-docs-theme-md/resources/template/md/template.php b/packages/typo3-docs-theme-md/resources/template/md/template.php new file mode 100644 index 000000000..aec011118 --- /dev/null +++ b/packages/typo3-docs-theme-md/resources/template/md/template.php @@ -0,0 +1,132 @@ + 'inline/anchor.md.twig', + FigureNode::class => 'body/figure.md.twig', + MetaNode::class => 'structure/header/blank.md.twig', + ParagraphNode::class => 'body/paragraph.md.twig', + QuoteNode::class => 'body/quote.md.twig', + ConfvalNode::class => 'body/directive/confval.md.twig', + OptionNode::class => 'body/directive/option.md.twig', + ConfvalMenuNode::class => 'body/directive/confval-menu.md.twig', + SidebarNode::class => 'body/sidebar.md.twig', + VersionChangeNode::class => 'body/version-change.md.twig', + SeparatorNode::class => 'body/separator.md.twig', + TitleNode::class => 'structure/header-title.md.twig', + SectionNode::class => 'structure/section.md.twig', + DocumentNode::class => 'structure/document.md.twig', + ImageNode::class => 'body/image.md.twig', + MathNode::class => 'body/math.md.twig', + CodeNode::class => 'body/code.md.twig', + CollectionNode::class => 'body/collection.md.twig', + ContainerNode::class => 'body/container.md.twig', + DefinitionListNode::class => 'body/definition-list.md.twig', + DefinitionNode::class => 'body/definition.md.twig', + FieldListNode::class => 'body/field-list.md.twig', + FieldListItemNode::class => 'body/field-list-item.md.twig', + DefinitionListItemNode::class => 'body/definition-list-item.md.twig', + ListNode::class => 'body/list/list.md.twig', + ListItemNode::class => 'body/list/list-item.md.twig', + LiteralBlockNode::class => 'body/literal-block.md.twig', + CitationNode::class => 'body/citation.md.twig', + FootnoteNode::class => 'body/footnote.md.twig', + AnnotationListNode::class => 'body/annotation-list.md.twig', + TableNode::class => 'body/table.md.twig', + // Inline + ImageInlineNode::class => 'inline/image.md.twig', + AbbreviationInlineNode::class => 'inline/textroles/abbreviation.md.twig', + CitationInlineNode::class => 'inline/citation.md.twig', + DocReferenceNode::class => 'inline/link.md.twig', + EmphasisInlineNode::class => 'inline/emphasis.md.twig', + FootnoteInlineNode::class => 'inline/footnote.md.twig', + HyperLinkNode::class => 'inline/link.md.twig', + LiteralInlineNode::class => 'inline/literal.md.twig', + NewlineInlineNode::class => 'inline/newline.md.twig', + WhitespaceInlineNode::class => 'inline/nbsp.md.twig', + PlainTextInlineNode::class => 'inline/plain-text.md.twig', + ReferenceNode::class => 'inline/link.md.twig', + StrongInlineNode::class => 'inline/strong.md.twig', + VariableInlineNode::class => 'inline/plain-text.md.twig', + GenericTextRoleInlineNode::class => 'inline/textroles/generic.md.twig', + InlineCompoundNode::class => 'inline/inline-node.md.twig', + // Document metadata has no Markdown representation; it is dropped rather + // than rendered, the way the RST theme drops what has no RST form. + AddressNode::class => 'structure/header/blank.md.twig', + AuthorNode::class => 'structure/header/blank.md.twig', + AuthorsNode::class => 'structure/header/blank.md.twig', + ContactNode::class => 'structure/header/blank.md.twig', + CopyrightNode::class => 'structure/header/blank.md.twig', + DateNode::class => 'structure/header/blank.md.twig', + NoCommentsNode::class => 'structure/header/blank.md.twig', + NoSearchNode::class => 'structure/header/blank.md.twig', + OrganizationNode::class => 'structure/header/blank.md.twig', + OrphanNode::class => 'structure/header/blank.md.twig', + RevisionNode::class => 'structure/header/blank.md.twig', + TocDepthNode::class => 'structure/header/blank.md.twig', + TopicNode::class => 'structure/header/blank.md.twig', + VersionNode::class => 'structure/header/blank.md.twig', +]; diff --git a/packages/typo3-docs-theme-md/src/DependencyInjection/Typo3DocsThemeMdExtension.php b/packages/typo3-docs-theme-md/src/DependencyInjection/Typo3DocsThemeMdExtension.php new file mode 100644 index 000000000..ddef1fe1b --- /dev/null +++ b/packages/typo3-docs-theme-md/src/DependencyInjection/Typo3DocsThemeMdExtension.php @@ -0,0 +1,97 @@ +load('typo3-docs-theme-md.php'); + } + + /** + * Replace the Markdown format's "nothing else matched" renderer with one + * that leaves a marker behind. + * + * That renderer is reachable twice -- through the tagged iterator and as + * the factory's fallback argument -- so decorating it would either be + * bypassed or, if tagged, take precedence over the templates. Swapping the + * definition in place keeps both paths pointing at the same object, and the + * original is kept as the inner renderer so its output is preserved. + * + * Must run after the guides RendererPass, which creates the service. + */ + public function markUnsupportedNodes(ContainerBuilder $container): void + { + $id = 'phpdoc.guides.noderenderer.default.md'; + if (!$container->hasDefinition($id)) { + return; + } + + $original = $container->getDefinition($id); + if ($original->getClass() === UnsupportedNodeRenderer::class) { + return; + } + + $innerId = $id . '.inner'; + $inner = clone $original; + $inner->clearTags(); + $container->setDefinition($innerId, $inner); + + $replacement = new Definition(UnsupportedNodeRenderer::class, ['$inner' => new Reference($innerId)]); + foreach ($original->getTags() as $tag => $attributes) { + foreach ($attributes as $attribute) { + $replacement->addTag($tag, $attribute); + } + } + + $container->setDefinition($id, $replacement); + } + + public function prepend(ContainerBuilder $container): void + { + // A base template path, not a theme: the active theme is whatever the + // HTML output uses (typo3docs), and ThemeManager searches the base + // paths under every theme. Registering "md" as a theme instead would + // force a project to choose between HTML and Markdown output. + $container->prependExtensionConfig('guides', [ + 'base_template_paths' => [dirname(__DIR__, 2) . '/resources/template/md'], + ]); + + $container->prependExtensionConfig( + 'guides', + [ + 'templates' => templateArray( + require dirname(__DIR__, 2) . '/resources/template/md/template.php', + 'md', + ), + ], + ); + } +} diff --git a/packages/typo3-docs-theme-md/src/NodeRenderers/Md/AdmonitionNodeRenderer.php b/packages/typo3-docs-theme-md/src/NodeRenderers/Md/AdmonitionNodeRenderer.php new file mode 100644 index 000000000..e8bfb8382 --- /dev/null +++ b/packages/typo3-docs-theme-md/src/NodeRenderers/Md/AdmonitionNodeRenderer.php @@ -0,0 +1,52 @@ + + */ +final class AdmonitionNodeRenderer implements NodeRenderer +{ + public function __construct( + private readonly TemplateRenderer $renderer, + ) {} + + public function supports(string $nodeFqcn): bool + { + return $nodeFqcn === AdmonitionNode::class || is_a($nodeFqcn, AdmonitionNode::class, true); + } + + public function render(Node $node, RenderContext $renderContext): string + { + if ($node instanceof AdmonitionNode === false) { + throw new InvalidArgumentException('Node must be an instance of ' . AdmonitionNode::class); + } + + return $this->renderer->renderTemplate( + $renderContext, + 'body/admonition.md.twig', + [ + 'name' => $node->getName(), + 'text' => $node->getText(), + 'title' => $node->getTitle(), + 'isTitled' => $node->isTitled(), + 'node' => $node->getValue(), + ], + ); + } +} diff --git a/packages/typo3-docs-theme-md/src/NodeRenderers/Md/GeneralDirectiveNodeRenderer.php b/packages/typo3-docs-theme-md/src/NodeRenderers/Md/GeneralDirectiveNodeRenderer.php new file mode 100644 index 000000000..e7f1855d1 --- /dev/null +++ b/packages/typo3-docs-theme-md/src/NodeRenderers/Md/GeneralDirectiveNodeRenderer.php @@ -0,0 +1,74 @@ + + */ +final class GeneralDirectiveNodeRenderer implements NodeRenderer +{ + public function __construct( + private readonly TemplateRenderer $renderer, + ) {} + + public function supports(string $nodeFqcn): bool + { + return $nodeFqcn === GeneralDirectiveNode::class || is_a($nodeFqcn, GeneralDirectiveNode::class, true); + } + + public function render(Node $node, RenderContext $renderContext): string + { + if ($node instanceof GeneralDirectiveNode === false) { + throw new InvalidArgumentException('Node must be an instance of ' . GeneralDirectiveNode::class); + } + + $template = 'body/directive/' . $this->templateName($node->getName()) . '.md.twig'; + if ($this->renderer->isTemplateFound($renderContext, $template)) { + return $this->renderer->renderTemplate($renderContext, $template, ['node' => $node]); + } + + $marker = ''; + $content = ''; + foreach ($node->getChildren() as $child) { + $content .= $this->renderer->renderTemplate( + $renderContext, + 'body/directive/general-child.md.twig', + ['node' => $child], + ); + } + + return $marker . "\n\n" . $content; + } + + /** Same mapping the HTML renderer uses, so the template names line up. */ + private function templateName(string $directiveName): string + { + $directiveName = str_replace(':', '/', $directiveName); + + return (string) preg_replace('/[^a-zA-Z0-9-_\/]/', '_', $directiveName); + } +} diff --git a/packages/typo3-docs-theme-md/src/NodeRenderers/Md/MenuEntryRenderer.php b/packages/typo3-docs-theme-md/src/NodeRenderers/Md/MenuEntryRenderer.php new file mode 100644 index 000000000..b0620888f --- /dev/null +++ b/packages/typo3-docs-theme-md/src/NodeRenderers/Md/MenuEntryRenderer.php @@ -0,0 +1,49 @@ + */ +final class MenuEntryRenderer implements NodeRenderer +{ + public function __construct( + private readonly TemplateRenderer $renderer, + private readonly UrlGeneratorInterface $urlGenerator, + ) {} + + public function supports(string $nodeFqcn): bool + { + return $nodeFqcn === MenuEntryNode::class || is_a($nodeFqcn, MenuEntryNode::class, true); + } + + public function render(Node $node, RenderContext $renderContext): string + { + Assert::isInstanceOf($node, MenuEntryNode::class); + + $url = $this->urlGenerator->generateCanonicalOutputUrl( + $renderContext, + $node->getUrl(), + $node->getValue()?->getId(), + ); + + return $this->renderer->renderTemplate( + $renderContext, + 'body/menu/menu-item.md.twig', + [ + 'url' => $url, + 'node' => $node, + ], + ); + } +} diff --git a/packages/typo3-docs-theme-md/src/NodeRenderers/Md/MenuNodeRenderer.php b/packages/typo3-docs-theme-md/src/NodeRenderers/Md/MenuNodeRenderer.php new file mode 100644 index 000000000..361ab7989 --- /dev/null +++ b/packages/typo3-docs-theme-md/src/NodeRenderers/Md/MenuNodeRenderer.php @@ -0,0 +1,67 @@ + + */ +final class MenuNodeRenderer implements NodeRenderer +{ + public function __construct( + private readonly TemplateRenderer $renderer, + ) {} + + public function supports(string $nodeFqcn): bool + { + return $nodeFqcn === MenuNode::class || is_a($nodeFqcn, MenuNode::class, true); + } + + public function render(Node $node, RenderContext $renderContext): string + { + Assert::isInstanceOf($node, MenuNode::class); + + if ($node->getOption('hidden', false)) { + return ''; + } + + return $this->renderer->renderTemplate( + $renderContext, + $this->getTemplate($node), + ['node' => $node], + ); + } + + private function getTemplate(Node $node): string + { + if ($node instanceof TocNode) { + return 'body/menu/table-of-content.md.twig'; + } + + if ($node instanceof ContentMenuNode) { + return 'body/menu/content-menu.md.twig'; + } + + return 'body/menu/menu.md.twig'; + } +} diff --git a/packages/typo3-docs-theme-md/src/Renderer/MdRenderer.php b/packages/typo3-docs-theme-md/src/Renderer/MdRenderer.php new file mode 100644 index 000000000..f227d57b2 --- /dev/null +++ b/packages/typo3-docs-theme-md/src/Renderer/MdRenderer.php @@ -0,0 +1,16 @@ + + */ +final class UnsupportedNodeRenderer implements NodeRenderer, NodeRendererFactoryAware +{ + /** @param NodeRenderer $inner */ + public function __construct( + private readonly NodeRenderer $inner, + ) {} + + public function setNodeRendererFactory(NodeRendererFactory $nodeRendererFactory): void + { + if (!$this->inner instanceof NodeRendererFactoryAware) { + return; + } + + $this->inner->setNodeRendererFactory($nodeRendererFactory); + } + + public function supports(string $nodeFqcn): bool + { + return $this->inner->supports($nodeFqcn); + } + + /** + * Nodes the default renderer already handles without losing anything: + * structural ones that only hold children, and "Null", which the Markdown + * parser emits where there is nothing to render at all. Marking these would + * bury the real gaps and claim content was dropped where none exists. + */ + private const TRANSPARENT = [ + 'Collection', + 'Container', + 'DefinitionListItem', + 'FieldListItem', + 'Null', + ]; + + public function render(Node $node, RenderContext $renderContext): string + { + $rendered = $this->inner->render($node, $renderContext); + $name = $this->describe($node); + + if (in_array($name, self::TRANSPARENT, true)) { + return $rendered; + } + + $marker = ''; + + // An inline node sits inside a paragraph, a table cell or a list item, + // where a blank line would break the construct around it. + if ($node instanceof InlineNodeInterface) { + return $marker . $rendered; + } + + return $marker . "\n\n" . $rendered; + } + + /** + * Prefer the directive name an author would recognise, and fall back to the + * node class when the node does not carry one. + */ + private function describe(Node $node): string + { + foreach (['getName', 'getDirective', 'getType'] as $method) { + $callable = [$node, $method]; + if (!is_callable($callable)) { + continue; + } + + $name = $callable(); + if (is_string($name) && $name !== '') { + return $this->sanitize($name); + } + } + + $class = $node::class; + $shortName = strrpos($class, '\\') === false ? $class : substr($class, strrpos($class, '\\') + 1); + + return $this->sanitize((string) preg_replace('/Node$/', '', $shortName)); + } + + /** Keep the comment a comment: "--" would end it early. */ + private function sanitize(string $value): string + { + return str_replace(['--', '>'], ['- -', ''], $value); + } +} diff --git a/packages/typo3-docs-theme-md/src/Twig/MdExtension.php b/packages/typo3-docs-theme-md/src/Twig/MdExtension.php new file mode 100644 index 000000000..57140c73b --- /dev/null +++ b/packages/typo3-docs-theme-md/src/Twig/MdExtension.php @@ -0,0 +1,257 @@ +renderMdTitle(...), ['is_safe' => ['md'], 'needs_context' => false]), + new TwigFunction('renderMdTable', $this->renderMdTable(...), ['is_safe' => ['md'], 'needs_context' => true]), + new TwigFunction('renderMdIndent', $this->renderMdIndent(...), ['is_safe' => ['md'], 'needs_context' => false]), + new TwigFunction('renderMdPrefix', $this->renderMdPrefix(...), ['is_safe' => ['md'], 'needs_context' => false]), + ]; + } + + /** @return TwigFilter[] */ + public function getFilters(): array + { + return [ + new TwigFilter('clean_content', $this->cleanContent(...)), + new TwigFilter('plaintext', $this->plaintext(...)), + new TwigFilter('inline_cell', $this->inlineCell(...)), + new TwigFilter('inline_text', $this->inlineText(...)), + new TwigFilter('md_escape', $this->escape(...)), + new TwigFilter('md_wrap', $this->wrap(...)), + new TwigFilter('md_yaml', $this->yamlString(...)), + ]; + } + + + public function plaintext(InlineNodeInterface $node): string + { + if ($node instanceof InlineCompoundNode) { + return implode('', array_map($this->plaintext(...), $node->getChildren())); + } + + return $node->toString(); + } + + /** + * Trailing whitespace is meaningful in Markdown (two spaces is a hard line + * break), so it is stripped rather than left to chance, and runs of blank + * lines are collapsed to the single blank line that separates blocks. + */ + public function cleanContent(string $content): string + { + $lines = explode("\n", $content); + $lines = array_map(rtrim(...), $lines); + $content = implode("\n", $lines); + + $content = preg_replace('/(\n){3,}/', "\n\n", $content) ?? $content; + + return rtrim($content) . "\n"; + } + + /** Indent every line, for nesting blocks inside list items. */ + public function renderMdIndent(string $text, int $indentNr = 1): string + { + $indent = str_repeat(' ', $indentNr); + + return preg_replace('/^(?!$)/m', $indent, $text) ?? $text; + } + + /** Prefix every line, e.g. "> " for blockquotes and GFM alerts. */ + public function renderMdPrefix(string $text, string $prefix): string + { + $lines = explode("\n", rtrim($text)); + $lines = array_map( + static fn(string $line): string => $line === '' ? rtrim($prefix) : $prefix . $line, + $lines, + ); + + return implode("\n", $lines); + } + + /** ATX headings only: they survive indentation and need no underline width. */ + public function renderMdTitle(TitleNode $node, string $content): string + { + return str_repeat('#', min($node->getLevel(), 6)) . ' ' . trim($content) . "\n"; + } + + /** + * A GFM pipe table. Cell content must stay on one line, so newlines become + * spaces and pipes are escaped. + * + * @param array{env: RenderContext} $context + */ + public function renderMdTable(array $context, TableNode $node): string + { + $headers = $node->getHeaders(); + $data = $node->getData(); + + $rows = []; + foreach ($headers as $row) { + $rows[] = $this->renderRow($row->getColumns(), $context['env']); + } + + // GFM has no table without a header row; an empty one keeps the table + // valid when the source table has only body rows. + $columnCount = 0; + foreach ([...$headers, ...$data] as $row) { + $columnCount = max($columnCount, count($row->getColumns())); + } + + if ($rows === [] && $columnCount > 0) { + $rows[] = array_fill(0, $columnCount, ''); + } + + $out = '| ' . implode(' | ', $rows[0]) . " |\n"; + $out .= '| ' . implode(' | ', array_fill(0, count($rows[0]), '---')) . " |\n"; + + foreach ($data as $row) { + $out .= '| ' . implode(' | ', $this->renderRow($row->getColumns(), $context['env'])) . " |\n"; + } + + return $out; + } + + /** + * @param TableColumn[] $columns + * @return string[] + */ + private function renderRow(array $columns, RenderContext $env): array + { + $cells = []; + foreach ($columns as $column) { + $cells[] = $this->inlineCell( + implode('', array_map(fn($node) => $this->nodeRenderer->render($node, $env), $column->getValue())), + ); + } + + return $cells; + } + + /** + * Wrap text in an emphasis marker, keeping surrounding spaces outside it. + * + * reStructuredText allows emphasis whose content begins or ends with a + * space; Markdown does not -- "* 3 *" is literal text, not emphasis. Moving + * the spaces out preserves both the wording and the emphasis. + */ + public function wrap(string $text, string $marker): string + { + $trimmed = trim($text); + if ($trimmed === '') { + return $text; + } + + $lead = substr($text, 0, strlen($text) - strlen(ltrim($text))); + $trail = substr($text, strlen(rtrim($text))); + + return $lead . $marker . $trimmed . $marker . $trail; + } + + /** + * Escape text that came from the source so Markdown reads it as text. + * + * Source prose is not Markdown, and characters that are ordinary there + * change the document here: "__dunder__" turns bold, "array[0](x)" turns + * into a link, a leading "#" turns into a heading, and a backslash before a + * space disappears entirely. + * + * Only the characters that can actually start a construct are escaped -- + * escaping all of CommonMark's punctuation would litter the output with + * backslashes for no gain. The line-start set is applied per line, because + * a "#" mid-sentence is harmless. + * + * Never use this inside a code span: backticks already take the content + * literally, and a backslash there would be part of the code. + */ + public function escape(string $text): string + { + // The backslash first, or the escapes added below would be escaped again. + $text = str_replace('\\', '\\\\', $text); + + // Always ambiguous: a backtick opens a code span, an asterisk opens + // emphasis even inside a word, and brackets open a link. + $text = (string) preg_replace('/([`*\[\]])/', '\\\\$1', $text); + + // An underscore inside a word is not emphasis in CommonMark, so + // "snake_case" needs nothing. Only the ones at a word boundary can open + // or close, which is what makes "__dunder__" bold. + $text = (string) preg_replace('/(?])(?=\s|$)/m', '$1\\\\$2', $text); + $text = (string) preg_replace('/^(\s*)([-+])(?=\s)/m', '$1\\\\$2', $text); + + // Only punctuation can be backslash-escaped, so an ordered list marker + // is defused at its dot rather than at its digits: "\1." would leave + // the backslash visible to the reader. + return (string) preg_replace('/^(\s*)(\d+)([.)])(?=\s)/m', '$1$2\\\\$3', $text); + } + + /** + * A scalar as a double-quoted YAML string, for the front matter. + * + * Always quoted rather than only when needed: a title that begins with a + * digit, holds a colon or reads as "yes" would otherwise come back out of + * a parser as a number, a mapping or a boolean. + */ + public function yamlString(string $value): string + { + return '"' . str_replace(['\\', '"', "\n"], ['\\\\', '\\"', ' '], $value) . '"'; + } + + /** Collapse a rendered block onto one line, for places that cannot hold one. */ + public function inlineText(string $content): string + { + return trim((string) preg_replace('/\s*\n\s*/', ' ', trim($content))); + } + + /** As inlineText, plus the pipe escaping a GFM table cell needs. */ + public function inlineCell(string $content): string + { + return $this->inlineText(str_replace('|', '\\|', $content)); + } +} diff --git a/packages/typo3-docs-theme-md/tests/unit/Twig/MdEscapeTest.php b/packages/typo3-docs-theme-md/tests/unit/Twig/MdEscapeTest.php new file mode 100644 index 000000000..b751a76be --- /dev/null +++ b/packages/typo3-docs-theme-md/tests/unit/Twig/MdEscapeTest.php @@ -0,0 +1,124 @@ +subject = (new ReflectionClass(MdExtension::class))->newInstanceWithoutConstructor(); + } + + /** @return array */ + public static function textIsEscaped(): array + { + return [ + // Emphasis: an underscore inside a word cannot open emphasis in + // CommonMark, one at a word boundary can. + 'underscore inside a word is left alone' => ['snake_case_name', 'snake_case_name'], + 'underscore at a word boundary is escaped' => ['__dunder__', '\_\_dunder\_\_'], + 'underscore between words is escaped' => ['a _ b', 'a \_ b'], + 'asterisk is escaped even inside a word' => ['2*3*4', '2\*3\*4'], + + // Links and code spans. + 'brackets are escaped' => ['see [1] and array[0](x)', 'see \[1\] and array\[0\](x)'], + 'backtick is escaped' => ['use ` to quote', 'use \` to quote'], + + // An angle bracket only starts something before a letter. + 'angle bracket before a letter is escaped' => ['bold', '\bold\'], + 'angle bracket as a comparison is left alone' => ['3 < 4', '3 < 4'], + + // Block markers count at the start of a line, and only with a space + // after them. + 'hash at line start is escaped' => ["# not a heading", '\# not a heading'], + 'hash without a space is left alone' => ['#tag', '#tag'], + 'dash at line start is escaped' => ['- not a list item', '\- not a list item'], + 'dash without a space is left alone' => ['-1 degree', '-1 degree'], + 'quote marker at line start is escaped' => ['> not a quote', '\> not a quote'], + 'ordered marker is escaped at the dot, not the digits' => ['1. not ordered', '1\. not ordered'], + 'number without a space is left alone' => ['1.5 metres', '1.5 metres'], + 'marker mid-sentence is left alone' => ['a - b # c > d', 'a - b # c > d'], + + // A literal backslash has to survive as one. + 'backslash is doubled' => ['C:\temp\file', 'C:\\\\temp\\\\file'], + + // Nothing to do. + 'plain text is untouched' => ['Just a sentence.', 'Just a sentence.'], + 'empty string stays empty' => ['', ''], + ]; + } + + #[Test] + #[DataProvider('textIsEscaped')] + public function textIsEscapedForMarkdown(string $input, string $expected): void + { + self::assertSame($expected, $this->subject->escape($input)); + } + + /** @return array */ + public static function emphasisKeepsSpacesOutside(): array + { + return [ + // reStructuredText allows emphasis whose content starts or ends + // with a space; Markdown reads "* 3 *" as literal text. + 'leading and trailing space move out' => [' 3 ', '*', ' *3* '], + 'leading space moves out' => [' text', '*', ' *text*'], + 'no spaces to move' => ['text', '**', '**text**'], + 'only whitespace is left as it is' => [' ', '*', ' '], + 'empty string is left as it is' => ['', '**', ''], + ]; + } + + /** @return array */ + public static function frontMatterValuesAreQuoted(): array + { + return [ + // Always quoted: unquoted, a parser would hand these back as a + // number, a mapping, a boolean or a list. + 'plain text is quoted' => ['Accordion', '"Accordion"'], + 'a colon would start a mapping' => ['Feature: 12345', '"Feature: 12345"'], + 'a number stays a string' => ['13.4', '"13.4"'], + 'yes stays a string' => ['yes', '"yes"'], + 'a leading dash would start a list' => ['- not a list', '"- not a list"'], + + // Inside the quotes only two characters can end them early. + 'a quote is escaped' => ['The "big" one', '"The \\"big\\" one"'], + 'a backslash is escaped' => ['C:\\temp', '"C:\\\\temp"'], + + // A value is one line; a newline would end it. + 'a newline becomes a space' => ["two\nlines", '"two lines"'], + + 'empty stays empty' => ['', '""'], + ]; + } + + #[Test] + #[DataProvider('frontMatterValuesAreQuoted')] + public function frontMatterValueIsQuotedForYaml(string $input, string $expected): void + { + self::assertSame($expected, $this->subject->yamlString($input)); + } + + #[Test] + #[DataProvider('emphasisKeepsSpacesOutside')] + public function emphasisKeepsSurroundingSpacesOutsideTheMarker( + string $input, + string $marker, + string $expected, + ): void { + self::assertSame($expected, $this->subject->wrap($input, $marker)); + } +} diff --git a/packages/typo3-docs-theme/composer.json b/packages/typo3-docs-theme/composer.json index a0188adc2..a7c6df42a 100644 --- a/packages/typo3-docs-theme/composer.json +++ b/packages/typo3-docs-theme/composer.json @@ -16,6 +16,8 @@ "brotkrueml/twig-codehighlight": "^1.0", "phpdocumentor/guides-graphs": "^1.0", "phpdocumentor/guides-theme-bootstrap": "^1.7", - "t3docs/typo3-version-handling": "self.version" + "t3docs/guides-php-domain": "^1.0", + "t3docs/typo3-version-handling": "self.version", + "t3docs/typo3-docs-theme-md": "self.version" } } diff --git a/packages/typo3-docs-theme/resources/template/structure/document.html.twig b/packages/typo3-docs-theme/resources/template/structure/document.html.twig index 6aa0fd5d2..ca41c6628 100644 --- a/packages/typo3-docs-theme/resources/template/structure/document.html.twig +++ b/packages/typo3-docs-theme/resources/template/structure/document.html.twig @@ -45,6 +45,15 @@ Give feedback {% endif -%} + {#- The page as Markdown, for handing to a tool rather than reading. + Absent when the project renders no Markdown. -#} + {%- set markdownLink = markdownAlternate() -%} + {%- if (markdownLink) %} + + + Markdown + + {% endif -%} {%- set gitHubLink = getEditOnGitHubLink() -%} {%- if (gitHubLink) %} diff --git a/packages/typo3-docs-theme/resources/template/structure/layoutParts/editOnGithubButtons.html.twig b/packages/typo3-docs-theme/resources/template/structure/layoutParts/editOnGithubButtons.html.twig index 86bf45905..b39ac6d21 100644 --- a/packages/typo3-docs-theme/resources/template/structure/layoutParts/editOnGithubButtons.html.twig +++ b/packages/typo3-docs-theme/resources/template/structure/layoutParts/editOnGithubButtons.html.twig @@ -6,12 +6,35 @@ {% endif -%} +{#- While "copy_sources" is on, the reStructuredText is published beside the + HTML and the entry links to it -- rendering this link is also what writes + the file. Once it is switched off the source is only in the repository, so + the entry points there instead, and is left out when no repository is + configured. -#} {%- set copySources = getSettings('copy_sources') -%} {%- if (copySources) %} - + View source +{% else -%} +{%- set viewSourceLink = getViewSourceLink() -%} +{%- if (viewSourceLink) %} + + + View source + +{% endif -%} +{% endif -%} + +{#- The same page as Markdown, for reading in the browser. Downloading it is + a button of its own next to the breadcrumb. -#} +{%- set markdownLink = markdownAlternate() -%} +{%- if (markdownLink) %} + + + View as Markdown + {% endif -%} {%- set howtoEditButton = getSettings('how_to_edit') -%} diff --git a/packages/typo3-docs-theme/resources/template/structure/layoutParts/generalHeaderLinks.html.twig b/packages/typo3-docs-theme/resources/template/structure/layoutParts/generalHeaderLinks.html.twig index 4126573a9..5bac12b9c 100644 --- a/packages/typo3-docs-theme/resources/template/structure/layoutParts/generalHeaderLinks.html.twig +++ b/packages/typo3-docs-theme/resources/template/structure/layoutParts/generalHeaderLinks.html.twig @@ -1,5 +1,7 @@ +{%- set markdownAlternate = markdownAlternate() -%} - +{% if markdownAlternate %} +{% endif %} diff --git a/packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php b/packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php index 99010a7b6..4e8f547f3 100644 --- a/packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php +++ b/packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php @@ -4,6 +4,7 @@ namespace T3Docs\Typo3DocsTheme\DependencyInjection; +use phpDocumentor\Guides\Graphs\Nodes\UmlNode; use phpDocumentor\Guides\NodeRenderers\TemplateNodeRenderer; use phpDocumentor\Guides\RestructuredText\Directives\FigureDirective as BaseFigureDirective; use phpDocumentor\Guides\TemplateRenderer; @@ -15,15 +16,30 @@ use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; +use phpDocumentor\Guides\Settings\ProjectSettings; +use phpDocumentor\Guides\Settings\SettingsManager; +use T3Docs\GuidesPhpDomain\Nodes\MemberNameNode; +use T3Docs\GuidesPhpDomain\Nodes\PhpCaseNode; +use T3Docs\GuidesPhpDomain\Nodes\PhpComponentNode; +use T3Docs\GuidesPhpDomain\Nodes\PhpConstNode; +use T3Docs\GuidesPhpDomain\Nodes\PhpGlobalNode; +use T3Docs\GuidesPhpDomain\Nodes\PhpMethodNode; +use T3Docs\GuidesPhpDomain\Nodes\PhpModifierNode; +use T3Docs\GuidesPhpDomain\Nodes\PhpNamespaceNode; +use T3Docs\GuidesPhpDomain\Nodes\PhpPropertyNode; use T3Docs\Typo3DocsTheme\Directives\FigureDirective; use T3Docs\Typo3DocsTheme\Nodes\Inline\CodeInlineNode; use T3Docs\Typo3DocsTheme\Nodes\Inline\ComposerInlineNode; use T3Docs\Typo3DocsTheme\Nodes\Inline\FileInlineNode; use T3Docs\Typo3DocsTheme\Nodes\YoutubeNode; use T3Docs\Typo3DocsTheme\Settings\Typo3DocsInputSettings; +use T3Docs\Typo3DocsThemeMd\DependencyInjection\Typo3DocsThemeMdExtension; use T3Docs\Typo3DocsTheme\Settings\Typo3DocsThemeSettings; use function dirname; +use function in_array; +use function strtolower; +use function trim; use function phpDocumentor\Guides\DependencyInjection\template; class Typo3DocsThemeExtension extends Extension implements PrependExtensionInterface, CompilerPassInterface @@ -40,6 +56,12 @@ public function load(array $configs, ContainerBuilder $container): void new FileLocator(dirname(__DIR__, 2) . '/resources/config'), ); $loader->load('typo3-docs-theme.php'); + + // The Markdown output format ships with the theme: a project enables it + // with "md" alone, without having to know + // that a second extension exists. + $this->markdownExtension()->load($configs, $container); + foreach (self::HTML as $node => $template) { $definition = new Definition( TemplateNodeRenderer::class, @@ -62,6 +84,14 @@ public function load(array $configs, ContainerBuilder $container): void 'edit_on_github_directory' => $this->getConfigValue($configs, 'edit_on_github_directory', 'Documentation'), 'how_to_edit' => $this->getConfigValue($configs, 'how_to_edit', 'https://docs.typo3.org/m/typo3/docs-how-to-document/main/en-us/WritingDocsOfficial/GithubMethod.html'), 'interlink_shortcode' => $this->getConfigValue($configs, 'interlink_shortcode', ''), + // Markdown is rendered beside the HTML unless a project + // opts out with render-markdown="false". + 'render_markdown' => $this->getConfigValue($configs, 'render_markdown', 'true'), + // Still on: tools read the published reStructuredText + // today, and the Markdown that replaces it has only just + // appeared. Switching this off ends the transition -- the + // "view source" entry then points at the repository + // instead, which is where the source lives anyway. 'copy_sources' => $this->getConfigValue($configs, 'copy_sources', 'true'), 'project_home' => $this->getConfigValue($configs, 'project_home', ''), 'project_contact' => $this->getConfigValue($configs, 'project_contact', ''), @@ -97,6 +127,14 @@ private function getConfigValue(array $configs, string $key, string $default): s public function prepend(ContainerBuilder $container): void { + // Before this extension's own templates, not after: prependExtensionConfig + // puts the last caller first, and a node renderer registered from a + // template wins by is_a(), so the first match decides. The Markdown + // package maps InlineCompoundNode, which every link node extends -- + // FileInlineNode among them. Registered the other way round, the generic + // map would swallow the file text role and it would lose its backticks. + $this->markdownExtension()->prepend($container); + $container->prependExtensionConfig('guides', [ 'themes' => [ 'typo3docs' => [ @@ -109,10 +147,67 @@ public function prepend(ContainerBuilder $container): void template(CodeInlineNode::class, 'inline/textroles/code.html.twig'), template(ComposerInlineNode::class, 'inline/textroles/composer.html.twig'), template(FileInlineNode::class, 'inline/textroles/file.html.twig'), + template(CodeInlineNode::class, 'inline/textroles/code.md.twig', 'md'), + template(ComposerInlineNode::class, 'inline/textroles/composer.md.twig', 'md'), + template(FileInlineNode::class, 'inline/textroles/file.md.twig', 'md'), + template(YoutubeNode::class, 'body/directive/youtube.md.twig', 'md'), + template(UmlNode::class, 'body/uml.md.twig', 'md'), + template(PhpComponentNode::class, 'body/directive/php/component.md.twig', 'md'), + template(PhpMethodNode::class, 'body/directive/php/method.md.twig', 'md'), + template(PhpPropertyNode::class, 'body/directive/php/property.md.twig', 'md'), + template(PhpConstNode::class, 'body/directive/php/const.md.twig', 'md'), + template(PhpCaseNode::class, 'body/directive/php/case.md.twig', 'md'), + template(PhpGlobalNode::class, 'body/directive/php/global.md.twig', 'md'), + template(PhpNamespaceNode::class, 'body/directive/php/namespace.md.twig', 'md'), + template(PhpModifierNode::class, 'body/directive/php/modifier.md.twig', 'md'), + template(MemberNameNode::class, 'body/directive/php/memberName.md.twig', 'md'), ], ]); } + /** + * The Markdown output format is part of the theme rather than a separate + * opt-in extension. It registers its own template paths and renderer, and + * stays inert until a project asks for the "md" output format. + */ + private function markdownExtension(): Typo3DocsThemeMdExtension + { + return new Typo3DocsThemeMdExtension(); + } + + /** + * Whether this project wants Markdown beside its HTML. On unless it says + * otherwise, so an author who cares can switch it off with + * render-markdown="false". + * + * The value is read back from the settings definition built in load(), + * which is the only place the guides.xml attributes are available. Note + * that "false" is a non-empty string and therefore truthy in PHP, so the + * off values are matched explicitly rather than by truthiness -- the same + * trap the Twig templates carry for their own flags. + */ + private function markdownRequested(ContainerBuilder $container): bool + { + if (!$container->hasDefinition(Typo3DocsThemeSettings::class)) { + return true; + } + + $arguments = $container->getDefinition(Typo3DocsThemeSettings::class)->getArguments(); + $settings = $arguments['$settings'] ?? []; + if (!is_array($settings)) { + return true; + } + + $value = $settings['render_markdown'] ?? 'true'; + if (!is_scalar($value)) { + return true; + } + + $value = strtolower(trim((string) $value)); + + return !in_array($value, ['', 'false', '0', 'off', 'no'], true); + } + /** * Build the template search path in priority order: * @@ -149,5 +244,59 @@ public function process(ContainerBuilder $container): void if ($container->hasDefinition(BaseFigureDirective::class)) { $container->removeDefinition(BaseFigureDirective::class); } + + $this->alwaysRenderMarkdown($container); + + if ($this->markdownRequested($container)) { + $this->markdownExtension()->markUnsupportedNodes($container); + } + } + + /** + * Add "md" to whatever output formats the project configured. + * + * This cannot be done by prepending "output_format" to the guides + * configuration: any explicitly provided value replaces the default + * ["html", "interlink"] rather than extending it, so a project that + * configures nothing would end up rendering Markdown and no HTML. + * + * By the time the container is compiled the settings object carries the + * final list, so appending to it leaves every other format untouched -- + * a project rendering only "singlepage" or only "rst" keeps doing that, + * and gains Markdown beside it. + */ + private function alwaysRenderMarkdown(ContainerBuilder $container): void + { + if (!$this->markdownRequested($container) || !$container->hasDefinition(SettingsManager::class)) { + return; + } + + $definition = $container->getDefinition(SettingsManager::class); + $methodCalls = $definition->getMethodCalls(); + + foreach ($methodCalls as $index => $call) { + if (!is_array($call) || ($call[0] ?? null) !== 'setProjectSettings') { + continue; + } + + $arguments = $call[1] ?? null; + $projectSettings = is_array($arguments) ? ($arguments[0] ?? null) : null; + if (!$projectSettings instanceof ProjectSettings) { + continue; + } + + $outputFormats = $projectSettings->getOutputFormats(); + if (in_array('md', $outputFormats, true)) { + return; + } + + $outputFormats[] = 'md'; + $projectSettings->setOutputFormats($outputFormats); + + $methodCalls[$index] = ['setProjectSettings', [$projectSettings]]; + $definition->setMethodCalls($methodCalls); + + return; + } } } diff --git a/packages/typo3-docs-theme/src/Twig/TwigExtension.php b/packages/typo3-docs-theme/src/Twig/TwigExtension.php index 3b81facc4..b8b6271ed 100644 --- a/packages/typo3-docs-theme/src/Twig/TwigExtension.php +++ b/packages/typo3-docs-theme/src/Twig/TwigExtension.php @@ -21,6 +21,7 @@ use phpDocumentor\Guides\RestructuredText\Nodes\ConfvalNode; use Psr\Log\LoggerInterface; use RuntimeException; +use Throwable; use T3Docs\GuidesPhpDomain\Nodes\PhpComponentNode; use T3Docs\GuidesPhpDomain\Nodes\PhpMemberNode; use T3Docs\Typo3DocsTheme\Directives\SiteSetSettingsDirective; @@ -56,6 +57,21 @@ final class TwigExtension extends AbstractExtension private string $typo3AzureEdgeURI = ''; + /** + * Documents already reported for a missing "interlink-shortcode", so the + * configuration problem is stated once and not once per link. + * + * @var array + */ + private array $reportedMissingShortcode = []; + + /** + * Documents already reported for an unusable project version. + * + * @var array + */ + private array $reportedMissingVersion = []; + public function __construct( private readonly LoggerInterface $logger, private readonly UrlGeneratorInterface $urlGenerator, @@ -87,6 +103,13 @@ public function getFunctions(): array new TwigFunction('getReportIssueLink', $this->getReportIssueLink(...), ['needs_context' => true]), new TwigFunction('getCurrentFilename', $this->getCurrentFilename(...), ['needs_context' => true]), new TwigFunction('sourceFilename', $this->getSourceFilename(...), ['needs_context' => true]), + new TwigFunction('markdownAlternate', $this->getMarkdownAlternate(...), ['needs_context' => true]), + new TwigFunction('markdownDownloadName', $this->getMarkdownDownloadName(...), ['needs_context' => true]), + new TwigFunction('markdownLinkUrl', $this->getMarkdownLinkUrl(...), ['needs_context' => true]), + new TwigFunction('markdownPermalink', $this->getMarkdownPermalink(...), ['needs_context' => true]), + new TwigFunction('markdownVersion', $this->getMarkdownVersion(...), ['needs_context' => true]), + new TwigFunction('markdownIsStartPage', $this->isMarkdownStartPage(...), ['needs_context' => true]), + new TwigFunction('getViewSourceLink', $this->getViewSourceLink(...), ['needs_context' => true]), new TwigFunction('getRelativePath', $this->getRelativePath(...), ['needs_context' => true]), new TwigFunction('getPagerLinks', $this->getPagerLinks(...), ['is_safe' => ['html'], 'needs_context' => true]), new TwigFunction('getPrevNextLinks', $this->getPrevNextLinks(...), ['is_safe' => ['html'], 'needs_context' => true]), @@ -342,6 +365,48 @@ public function getEditOnGitHubLink(array $context): string return $gitHubPerPageLink ?? sprintf("https://github.com/%s/edit/%s/%s/%s", $githubButton, $githubBranch, $githubDirectory, $sourceFile); } + /** + * The page's source file on the forge it is maintained in. + * + * The rendered output no longer ships the reStructuredText itself -- it + * lives in the project's repository, which is where a reader following + * "view source" wants to end up anyway, with history and blame attached. + * + * Returns an empty string when no repository is configured, in which case + * the menu entry is left out rather than pointing nowhere. + * + * @param array{env: RenderContext} $context + */ + public function getViewSourceLink(array $context): string + { + $sourceFile = $this->getSourceFilename($context); + if ($sourceFile === '') { + return ''; + } + + $branch = $this->themeSettings->getSettings('edit_on_github_branch', 'main'); + $directory = trim($this->themeSettings->getSettings('edit_on_github_directory', 'Documentation'), '/'); + + $github = $this->themeSettings->getSettings('edit_on_github'); + if ($github !== '') { + return sprintf('https://github.com/%s/blob/%s/%s/%s', $github, $branch, $directory, $sourceFile); + } + + // No GitHub setting exists for GitLab, but "project_repository" already + // carries the repository URL and is validated for the same hosts as the + // issue links. + $repository = rtrim($this->themeSettings->getSettings('project_repository'), '/'); + if (str_starts_with($repository, 'https://gitlab.com/')) { + return sprintf('%s/-/blob/%s/%s/%s', $repository, $branch, $directory, $sourceFile); + } + + if (str_starts_with($repository, 'https://github.com/')) { + return sprintf('%s/blob/%s/%s/%s', $repository, $branch, $directory, $sourceFile); + } + + return ''; + } + private function getEditOnGitHubLinkPerPage(RenderContext $renderContext): string|null { try { @@ -535,6 +600,380 @@ public function getCurrentFilename(array $context): string } } + /** + * Turn a link in the Markdown output into a permalink. + * + * A Markdown file is meant to be downloaded and read away from the site it + * came from, so a relative link would be dead on arrival. Every internal + * target carries an anchor, which is exactly what the permalink service + * resolves, so "interlink_shortcode" plus that anchor is enough to build a + * URL that keeps working -- and keeps working across versions, which a + * hard-coded manual URL would not. + * + * Left alone: anything with a scheme (external links, mailto:) and links + * without an anchor, which in practice are images. Without + * "interlink_shortcode" no permalink can be built, so those fall back to + * the relative HTML page. + * + * @param array{env: RenderContext} $context + */ + public function getMarkdownLinkUrl(array $context, string $url): string + { + if ($url === '' || preg_match('#^[a-z][a-z0-9+.-]*:#i', $url) === 1) { + return $url; + } + + $anchorPosition = strpos($url, '#'); + $anchor = $anchorPosition === false ? '' : substr($url, $anchorPosition + 1); + $interlink = $this->themeSettings->getSettings('interlink_shortcode'); + + if ($anchor === '') { + // "#" is a reference to the page being rendered. That is fine in a + // browser, but a downloaded file should still point somewhere, so + // it becomes the permalink of this very page. + $anchor = $url === '#' + ? ($this->getRenderContext($context)->getCurrentDocumentEntry()?->getTitle()->getId() ?? '') + : $this->anchorOfLinkedDocument($context, $url); + } + + // The fragment of a rendered URL keeps the casing of the element id it + // points at, which HTML resolves fine. The permalink service looks the + // target up in the inventory, where it is registered normalised, so the + // anchor has to be reduced the same way getPermalink() does it. + if ($anchor !== '') { + $anchor = $this->anchorNormalizer->reduceAnchor($anchor); + } + + if ($anchor === '' || $interlink === '') { + $this->logUnresolvedMarkdownLink($context, $url, $interlink === ''); + + // The URL generator appended the current output format, so an + // internal link reads "Feature.md" here; the HTML page is the one + // worth pointing at. + return preg_replace('/\.md(?=$|#)/', '.html', $url) ?? $url; + } + + return 'https://docs.typo3.org/permalink/' . $interlink . ':' . $anchor + . $this->permalinkVersionSuffix($this->getRenderContext($context)); + } + + /** + * The "@version" a permalink has to carry, or "" when it must not carry one. + * + * A permalink without a version resolves to the latest stable release. A + * Markdown file is a snapshot of one version, so without the suffix every + * link in it would send its reader into whatever manual is current later -- + * the opposite of what permalinks are here for. + * + * Manuals that exist only once carry no version at all; DefaultInventories + * knows which those are. What it does not know is a third-party manual, + * and those are versioned. + */ + private function permalinkVersionSuffix(RenderContext $renderContext): string + { + $interlink = $this->themeSettings->getSettings('interlink_shortcode'); + if ($interlink === '') { + return ''; + } + + $inventory = DefaultInventories::tryFrom($interlink); + if ($inventory !== null && !$inventory->isVersioned()) { + return ''; + } + + $version = $this->normalizedProjectVersion($renderContext); + if ($version === '') { + return ''; + } + + return '@' . $version; + } + + /** + * The project version in the form a URL and a metadata field can carry, or + * "" when there is none. + * + * A checkout names itself "main (development)"; both want the bare "main". + * A project that names no version at all is left alone -- the theme treats + * the version as optional everywhere else too. + */ + private function normalizedProjectVersion(RenderContext $renderContext): string + { + $version = explode(' ', trim((string) $renderContext->getProjectNode()->getVersion()))[0]; + if ($version === '') { + return ''; + } + + if (preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $version) !== 1) { + $this->reportUnusablePermalinkVersion($renderContext, $version); + + return ''; + } + + return $version; + } + + /** + * The permalink of the document being rendered, for the Markdown front + * matter: the one URL that names this page no matter where the file ends + * up, and the way back to the HTML it was rendered from. + * + * @param array{env: RenderContext} $context + */ + public function getMarkdownPermalink(array $context): string + { + $interlink = $this->themeSettings->getSettings('interlink_shortcode'); + $renderContext = $this->getRenderContext($context); + $entry = $renderContext->getCurrentDocumentEntry(); + $anchor = $entry === null ? '' : $this->documentAnchor($renderContext, $entry); + if ($interlink === '' || $anchor === '') { + return ''; + } + + return 'https://docs.typo3.org/permalink/' . $interlink . ':' . $anchor + . $this->permalinkVersionSuffix($renderContext); + } + + /** @param array{env: RenderContext} $context */ + public function getMarkdownVersion(array $context): string + { + return $this->normalizedProjectVersion($this->getRenderContext($context)); + } + + /** + * Whether the document being rendered is the manual's start page. + * + * Worth stating rather than leaving to be guessed: once the files lie + * flat in a directory, the entry point is no longer the one at the top, + * and "Index.rst" is a name several documents in a manual share. + * + * @param array{env: RenderContext} $context + */ + public function isMarkdownStartPage(array $context): bool + { + $renderContext = $this->getRenderContext($context); + $entry = $renderContext->getCurrentDocumentEntry(); + if ($entry === null) { + return false; + } + + try { + return $entry->getFile() === $renderContext->getProjectNode()->getRootDocumentEntry()->getFile(); + } catch (Throwable) { + return false; + } + } + + /** + * Reported once per document: the version is a property of the project, so + * every link on the page is missing it for the same reason. + */ + private function reportUnusablePermalinkVersion(RenderContext $renderContext, string $version): void + { + $document = $renderContext->hasCurrentFileName() ? $renderContext->getCurrentFileName() : ''; + if (isset($this->reportedMissingVersion[$document])) { + return; + } + + $this->reportedMissingVersion[$document] = true; + $this->logger->warning( + sprintf( + 'The version "%s" from the guides.xml cannot go into a URL, so the Markdown permalinks of this ' + . 'manual resolve to its latest stable release instead of to this version. ', + $version, + ), + $renderContext->getLoggerInformation(), + ); + } + + /** + * A link left relative in the Markdown output is dead once the file is + * downloaded, so it is worth reporting rather than shipping quietly. + * + * A missing "interlink_shortcode" affects every link on the page alike, so + * it is reported once per document instead of once per link; an + * unresolvable target is specific to that link and is reported each time. + * + * @param array{env: RenderContext} $context + */ + private function logUnresolvedMarkdownLink(array $context, string $url, bool $missingShortcode): void + { + $renderContext = $this->getRenderContext($context); + + if ($missingShortcode) { + $document = $renderContext->hasCurrentFileName() ? $renderContext->getCurrentFileName() : ''; + if (isset($this->reportedMissingShortcode[$document])) { + return; + } + + $this->reportedMissingShortcode[$document] = true; + + // Not a warning: a manual without "interlink-shortcode" cannot have + // permalinks at all, so this describes how the project is set up + // rather than something broken in the page. Warning per document + // would drown the links that genuinely failed to resolve. + $this->logger->info( + 'Links in the Markdown output stay relative because "interlink-shortcode" is not set in the guides.xml. ', + $renderContext->getLoggerInformation(), + ); + + return; + } + + $this->logger->warning( + sprintf('The Markdown link to "%s" could not be resolved to a permalink and stays relative. ', $url), + $renderContext->getLoggerInformation(), + ); + } + + /** + * The anchor of a page linked without one, so it too can become a permalink. + * + * A ":doc:" reference points at a page rather than a label, so the + * generated URL carries no fragment. The target is in this same manual, + * though, so its document entry is known and its title carries the anchor. + * + * Returns an empty string for anything that is not a document of this + * manual -- an image, most commonly. + * + * @param array{env: RenderContext} $context + */ + private function anchorOfLinkedDocument(array $context, string $url): string + { + $renderContext = $this->getRenderContext($context); + $path = preg_replace('/\.[A-Za-z0-9]+$/', '', $url); + if ($path === null || $path === '') { + return ''; + } + + try { + $canonical = $this->documentNameResolver->canonicalUrl($renderContext->getDirName(), $path); + $entry = $renderContext->getProjectNode()->findDocumentEntry($canonical); + } catch (Throwable) { + return ''; + } + + return $entry === null ? '' : $this->documentAnchor($renderContext, $entry); + } + + /** + * The anchor that identifies one document, taken from its own label. + * + * Not the title's id: titles repeat, and the pipeline exempts "std:title" + * from its duplicate-anchor check for exactly that reason, so only one of + * several pages sharing a title ends up registered under it. The TYPO3 + * changelog shows what that costs -- the same entry backported to three + * versions carries three distinct labels ("breaking-84843", + * "breaking-84843-1668719172", "breaking-84843-1668719171") but a single + * title anchor, which resolves to whichever of the three won. Building a + * permalink from the title would silently point at the wrong version. + * + * Falls back to the title id when a document declares no label of its own, + * which is the best available identifier in that case. + */ + private function documentAnchor(RenderContext $renderContext, DocumentEntryNode $entry): string + { + try { + $document = $renderContext->getDocumentNodeForEntry($entry); + } catch (Throwable) { + $document = null; + } + + foreach ($document?->getChildren() ?? [] as $child) { + if (!$child instanceof SectionNode) { + continue; + } + + foreach ($child->getChildren() as $sectionChild) { + if ($sectionChild instanceof AnchorNode) { + return $this->anchorNormalizer->reduceAnchor($sectionChild->toString()); + } + } + + break; + } + + $id = $entry->getTitle()->getId(); + + return $id === '' ? '' : $this->anchorNormalizer->reduceAnchor($id); + } + + /** + * The Markdown rendering of the current page, which is written next to the + * HTML file with the same base name. + * + * Machine consumers want the content without the surrounding HTML; the + * Markdown has includes, substitutions and interlinks resolved, which the + * reStructuredText source does not. + * + * @param array{env: RenderContext} $context + */ + /** + * The name the Markdown of this page is saved under. + * + * Built from the same two parts as its permalink, the manual's + * "interlink_shortcode" and the page's anchor, so a file picked out of a + * download folder still says which page of which manual it is -- and files + * collected from several manuals cannot collide, where every overview page + * would otherwise arrive as "index.md" and overwrite the last. + * + * Returns an empty string when either part is missing, which leaves the + * browser to name the file from the URL as before. + * + * @param array{env: RenderContext} $context + */ + public function getMarkdownDownloadName(array $context): string + { + if ($this->getMarkdownAlternate($context) === '') { + return ''; + } + + $interlink = $this->themeSettings->getSettings('interlink_shortcode'); + $renderContext = $this->getRenderContext($context); + $entry = $renderContext->getCurrentDocumentEntry(); + $anchor = $entry === null ? '' : $this->documentAnchor($renderContext, $entry); + if ($interlink === '' || $anchor === '') { + return ''; + } + + $prefix = $this->anchorNormalizer->reduceAnchor($interlink); + + // Changelog anchors already carry the manual's own name; repeating it + // would read as "changelog-changelog-feature-...". + if ($anchor === $prefix || str_starts_with($anchor, $prefix . '-')) { + return $anchor . '.md'; + } + + return $prefix . '-' . $anchor . '.md'; + } + + /** + * The Markdown rendering of the current page, which is written next to the + * HTML file with the same base name. + * + * Machine consumers want the content without the surrounding HTML; the + * Markdown has includes, substitutions and interlinks resolved, which the + * reStructuredText source does not. + * + * @param array{env: RenderContext} $context + */ + public function getMarkdownAlternate(array $context): string + { + // No Markdown is written when the project opted out, so neither the + // head link nor the menu entry may promise one. + $renderMarkdown = strtolower(trim($this->themeSettings->getSettings('render_markdown', 'true'))); + if (in_array($renderMarkdown, ['', 'false', '0', 'off', 'no'], true)) { + return ''; + } + + $renderContext = $this->getRenderContext($context); + if (!$renderContext->hasCurrentFileName()) { + return ''; + } + + return basename($renderContext->getCurrentFileName()) . '.md'; + } + /** * @param array{env: RenderContext} $context */ diff --git a/tests/Integration/tests-full/breadcrumb/expected/index.html b/tests/Integration/tests-full/breadcrumb/expected/index.html index c1f218d51..b762d74ea 100644 --- a/tests/Integration/tests-full/breadcrumb/expected/index.html +++ b/tests/Integration/tests-full/breadcrumb/expected/index.html @@ -18,6 +18,7 @@ + @@ -71,10 +72,14 @@

Options

-
+ View source + + + View as Markdown + How to edit @@ -131,7 +140,11 @@
diff --git a/tests/Integration/tests-full/breadcrumb/expected/page.html b/tests/Integration/tests-full/breadcrumb/expected/page.html index 731beb246..538a6c6fc 100644 --- a/tests/Integration/tests-full/breadcrumb/expected/page.html +++ b/tests/Integration/tests-full/breadcrumb/expected/page.html @@ -18,6 +18,7 @@ + @@ -72,10 +73,14 @@

Options

-
+ View source + + + View as Markdown + How to edit @@ -136,7 +145,11 @@ - +
diff --git a/tests/Integration/tests-full/breadcrumb/expected/yetAnotherPage.html b/tests/Integration/tests-full/breadcrumb/expected/yetAnotherPage.html index 633d46e78..50081e952 100644 --- a/tests/Integration/tests-full/breadcrumb/expected/yetAnotherPage.html +++ b/tests/Integration/tests-full/breadcrumb/expected/yetAnotherPage.html @@ -18,6 +18,7 @@ + @@ -71,10 +72,14 @@

Options

-
+ View source + + + View as Markdown + How to edit @@ -135,7 +144,11 @@ - +
diff --git a/tests/Integration/tests-full/changelog/expected/Changelog/12.0/Breaking-87616-RemovedHookForAlteringPageLinks.html b/tests/Integration/tests-full/changelog/expected/Changelog/12.0/Breaking-87616-RemovedHookForAlteringPageLinks.html index d51c03ddd..db19f7c55 100644 --- a/tests/Integration/tests-full/changelog/expected/Changelog/12.0/Breaking-87616-RemovedHookForAlteringPageLinks.html +++ b/tests/Integration/tests-full/changelog/expected/Changelog/12.0/Breaking-87616-RemovedHookForAlteringPageLinks.html @@ -18,6 +18,7 @@ + @@ -88,10 +89,14 @@ Give feedback - + View source + + + View as Markdown + How to edit @@ -140,10 +145,14 @@ Give feedback - + View source + + + View as Markdown + How to edit @@ -173,6 +182,10 @@