diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Admonitions.stories.tsx b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Admonitions.stories.tsx new file mode 100644 index 000000000..b5b0e1140 --- /dev/null +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Admonitions.stories.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { EnrichedMarkdownTextStory } from '../EnrichedMarkdownTextStory'; +import { storyMeta } from '../shared/storyMeta'; +import { + admonitionStyledDefaults, + githubFlavorArgTypes, + numberControl, + type AdmonitionStyleControls, +} from '../shared/storybookMarkdownStyles'; +import { + splitStyleControls, + toAdmonitionStyle, +} from '../shared/storybookStyleBuilders'; +import type { StoryArgs, TextStory } from '../shared/storyTypes'; + +// The blockquote style controls plus a boolean toggle wired to +// md4cFlags.admonitions (on/off instead of an object). +type AdmonitionControls = AdmonitionStyleControls & { + admonitionsEnabled: boolean; +}; + +const ALL_TYPES_MARKDOWN = `> [!NOTE] +> Highlights information that users should take into account, even when skimming. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Key information users need to know to achieve their goal. + +> [!WARNING] +> Urgent info that needs immediate user attention to avoid problems. + +> [!CAUTION] +> Advises about risks or negative outcomes of certain actions. + +> Control text showing default blockquote`; + +const BLOCK_ELEMENTS_MARKDOWN = `> [!IMPORTANT] +> An admonition can hold many block elements: +> +> ## A heading inside the alert +> +> A paragraph with **bold**, _italic_ and a [link](https://swmansion.com). +> +> - first bullet +> - second bullet +> +> 1. ordered one +> 2. ordered two +> +> \`\`\`ts +> const answer = 42; +> \`\`\` +> +> | Column A | Column B | +> | -------- | -------- | +> | 1 | 2 | +> +> and a trailing paragraph.`; + +const NESTED_MARKDOWN = `> [!WARNING] +> An outer warning alert. +> +> > [!TIP] +> > A tip nested inside the warning. +> > +> > > [!NOTE] +> > > And a note nested one level deeper - each level is its own themed container.`; + +const argTypes = { + admonitionsEnabled: { + control: 'boolean', + description: + 'md4cFlags.admonitions - turn the extension off to render plain blockquotes (also forced off when flavor="commonmark")', + }, + fontSize: numberControl('markdownStyle.blockquote.fontSize', { + min: 12, + max: 24, + step: 1, + }), + borderWidth: numberControl('markdownStyle.blockquote.borderWidth', { + min: 1, + max: 8, + step: 1, + }), + gapWidth: numberControl('markdownStyle.blockquote.gapWidth', { + min: 0, + max: 32, + step: 2, + }), + borderRadius: numberControl('markdownStyle.blockquote.borderRadius', { + min: 0, + max: 16, + step: 1, + }), + padding: numberControl('markdownStyle.blockquote.padding', { + min: 0, + max: 32, + step: 2, + }), + noteColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.note.color', + }, + noteBackgroundColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.note.backgroundColor', + }, + tipColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.tip.color', + }, + tipBackgroundColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.tip.backgroundColor', + }, + importantColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.important.color', + }, + importantBackgroundColor: { + control: 'color', + description: + 'markdownStyle.blockquote.admonitions.important.backgroundColor', + }, + warningColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.warning.color', + }, + warningBackgroundColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.warning.backgroundColor', + }, + cautionColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.caution.color', + }, + cautionBackgroundColor: { + control: 'color', + description: 'markdownStyle.blockquote.admonitions.caution.backgroundColor', + }, +}; + +function renderAdmonition( + title: string, + description: string, + args: StoryArgs +) { + const { admonitionsEnabled = true, ...styleArgs } = args; + const { controls, rest } = splitStyleControls( + styleArgs, + admonitionStyledDefaults + ); + return ( + + ); +} + +const flavorArgTypes = githubFlavorArgTypes( + 'Admonitions require flavor="github". commonmark forces the extension off, so `> [!NOTE]` renders as a plain blockquote.' +); + +const admonitionStoryBase = { + argTypes: { ...argTypes, ...flavorArgTypes }, + args: { + ...admonitionStyledDefaults, + admonitionsEnabled: true, + flavor: 'github' as const, + }, +}; + +export default storyMeta('Block', 'Admonitions'); + +export const AllTypes: TextStory = { + ...admonitionStoryBase, + args: { + ...admonitionStoryBase.args, + markdown: ALL_TYPES_MARKDOWN, + }, + render: (args) => + renderAdmonition( + 'Admonitions', + 'Every GitHub alert type (note, tip, important, warning, caution), each a themed blockquote with an icon + title header. Toggle "admonitionsEnabled" off, or flip flavor to commonmark, to render them as plain blockquotes; tune the per-type colors via the controls.', + args + ), +}; + +export const WithBlockElements: TextStory = { + ...admonitionStoryBase, + args: { + ...admonitionStoryBase.args, + markdown: BLOCK_ELEMENTS_MARKDOWN, + }, + render: (args) => + renderAdmonition( + 'Admonition with Block Elements', + 'An admonition holding a heading, lists, a fenced code block and a table - each rendered as its own segment inside the alert container.', + args + ), +}; + +export const Nested: TextStory = { + ...admonitionStoryBase, + args: { + ...admonitionStoryBase.args, + markdown: NESTED_MARKDOWN, + }, + render: (args) => + renderAdmonition( + 'Nested Admonition', + 'Admonitions nested three levels deep - each level is its own recursive themed container.', + args + ), +}; diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookMarkdownStyles.ts b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookMarkdownStyles.ts index 7a877f6f3..bfed21ad1 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookMarkdownStyles.ts +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookMarkdownStyles.ts @@ -91,6 +91,35 @@ export const blockquoteStyledDefaults: BlockquoteStyleControls = { padding: 0, }; +// Admonitions inherit the blockquote geometry controls and add a per-type color +// pair. An empty background renders transparent (no fill). +export type AdmonitionStyleControls = BlockquoteStyleControls & { + noteColor: string; + noteBackgroundColor: string; + tipColor: string; + tipBackgroundColor: string; + importantColor: string; + importantBackgroundColor: string; + warningColor: string; + warningBackgroundColor: string; + cautionColor: string; + cautionBackgroundColor: string; +}; + +export const admonitionStyledDefaults: AdmonitionStyleControls = { + ...blockquoteStyledDefaults, + noteColor: '#0969da', + noteBackgroundColor: '', + tipColor: '#1a7f37', + tipBackgroundColor: '', + importantColor: '#8250df', + importantBackgroundColor: '', + warningColor: '#9a6700', + warningBackgroundColor: '', + cautionColor: '#cf222e', + cautionBackgroundColor: '', +}; + export type CodeBlockStyleControls = { fontSize: number; fontFamily: string; diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookStyleBuilders.ts b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookStyleBuilders.ts index 940d342dd..7ea70c7ee 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookStyleBuilders.ts +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookStyleBuilders.ts @@ -1,6 +1,7 @@ import type { MarkdownStyle } from 'react-native-enriched-markdown'; import type { StoryArgs } from './storyTypes'; import type { + AdmonitionStyleControls, BlockquoteStyleControls, CodeBlockStyleControls, EmphasisStyleControls, @@ -109,6 +110,39 @@ export function toBlockquoteStyle( }; } +function admonitionColors(color: string, backgroundColor: string) { + return { + ...(color ? { color } : {}), + ...(backgroundColor ? { backgroundColor } : {}), + }; +} + +// Blockquote style with the admonition palette nested under it. Admonitions +// reuse the blockquote geometry and only theme colors per type. +export function toAdmonitionStyle( + controls: AdmonitionStyleControls +): NonNullable { + return { + ...toBlockquoteStyle(controls), + admonitions: { + note: admonitionColors(controls.noteColor, controls.noteBackgroundColor), + tip: admonitionColors(controls.tipColor, controls.tipBackgroundColor), + important: admonitionColors( + controls.importantColor, + controls.importantBackgroundColor + ), + warning: admonitionColors( + controls.warningColor, + controls.warningBackgroundColor + ), + caution: admonitionColors( + controls.cautionColor, + controls.cautionBackgroundColor + ), + }, + }; +} + export function toCodeBlockStyle( controls: CodeBlockStyleControls ): NonNullable { diff --git a/packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp b/packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp index 301d2a374..c07645465 100644 --- a/packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp +++ b/packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp @@ -22,6 +22,8 @@ static_assert(static_cast(NodeType::Highlight) == 29, "NodeType enum must stay in sync with Kotlin MarkdownASTNode.NodeType"); static_assert(static_cast(NodeType::SoftBreak) == 30, "NodeType enum must stay in sync with Kotlin MarkdownASTNode.NodeType"); +static_assert(static_cast(NodeType::Admonition) == 31, + "NodeType enum must stay in sync with Kotlin MarkdownASTNode.NodeType"); local_ref createJavaNode(const std::shared_ptr &node) { if (!node) { @@ -69,6 +71,7 @@ Md4cFlags JMd4cFlags::toCppFlags() const { static const auto highlightField = javaClassStatic()->getField("highlight"); static const auto permissiveAutolinksField = javaClassStatic()->getField("permissiveAutolinks"); static const auto hardSoftBreaksField = javaClassStatic()->getField("hardSoftBreaks"); + static const auto admonitionsField = javaClassStatic()->getField("admonitions"); Md4cFlags flags; flags.underline = getFieldValue(underlineField) == JNI_TRUE; @@ -78,6 +81,7 @@ Md4cFlags JMd4cFlags::toCppFlags() const { flags.highlight = getFieldValue(highlightField) == JNI_TRUE; flags.permissiveAutolinks = getFieldValue(permissiveAutolinksField) == JNI_TRUE; flags.hardSoftBreaks = getFieldValue(hardSoftBreaksField) == JNI_TRUE; + flags.admonitions = getFieldValue(admonitionsField) == JNI_TRUE; return flags; } diff --git a/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt b/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt index 00aefb156..2b39760a3 100644 --- a/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +++ b/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt @@ -38,6 +38,7 @@ data class MarkdownASTNode( Subscript, Highlight, SoftBreak, + Admonition, } fun getAttribute(key: String): String? = attributes[key] diff --git a/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt b/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt index 4905681ad..abe786bea 100644 --- a/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +++ b/packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt @@ -10,6 +10,7 @@ data class Md4cFlags( val highlight: Boolean = false, val permissiveAutolinks: Boolean = true, val hardSoftBreaks: Boolean = false, + val admonitions: Boolean = false, ) { companion object { val DEFAULT = Md4cFlags() diff --git a/packages/core/cpp/parser/MD4CParser.cpp b/packages/core/cpp/parser/MD4CParser.cpp index 46532126d..6d434450c 100644 --- a/packages/core/cpp/parser/MD4CParser.cpp +++ b/packages/core/cpp/parser/MD4CParser.cpp @@ -20,6 +20,7 @@ class MD4CParser::Impl { static const std::string ATTR_IS_TASK; static const std::string ATTR_TASK_CHECKED; static const std::string ATTR_START; + static const std::string ATTR_ADMONITION_TYPE; void reset(size_t estimatedDepth) { root = std::make_shared(NodeType::Document); @@ -109,6 +110,19 @@ class MD4CParser::Impl { break; } + case MD_BLOCK_ADMONITION: { + auto node = std::make_shared(NodeType::Admonition); + if (detail) { + auto *adm = static_cast(detail); + std::string admonitionType = impl->getAttributeText(&adm->type); + if (!admonitionType.empty()) { + node->setAttribute(ATTR_ADMONITION_TYPE, admonitionType); + } + } + impl->pushNode(node); + break; + } + case MD_BLOCK_UL: { impl->pushNode(std::make_shared(NodeType::UnorderedList)); break; @@ -486,6 +500,7 @@ bool isBlockNode(const MarkdownASTNode &node) { case NodeType::Paragraph: case NodeType::Heading: case NodeType::Blockquote: + case NodeType::Admonition: case NodeType::UnorderedList: case NodeType::OrderedList: case NodeType::ListItem: @@ -611,6 +626,9 @@ std::shared_ptr MD4CParser::parse(const std::string &markdown, if (md4cFlags.hardSoftBreaks) { flags |= MD_FLAG_HARD_SOFT_BREAKS; } + if (md4cFlags.admonitions) { + flags |= MD_FLAG_ADMONITIONS; + } // Configure MD4C parser with callbacks MD_PARSER parser = { @@ -647,5 +665,6 @@ const std::string MD4CParser::Impl::ATTR_LANGUAGE = "language"; const std::string MD4CParser::Impl::ATTR_IS_TASK = "isTask"; const std::string MD4CParser::Impl::ATTR_TASK_CHECKED = "taskChecked"; const std::string MD4CParser::Impl::ATTR_START = "start"; +const std::string MD4CParser::Impl::ATTR_ADMONITION_TYPE = "admonitionType"; } // namespace Markdown diff --git a/packages/core/cpp/parser/MD4CParser.hpp b/packages/core/cpp/parser/MD4CParser.hpp index 56f318dd3..735f4af88 100644 --- a/packages/core/cpp/parser/MD4CParser.hpp +++ b/packages/core/cpp/parser/MD4CParser.hpp @@ -14,6 +14,7 @@ struct Md4cFlags { bool highlight = false; bool permissiveAutolinks = true; bool hardSoftBreaks = false; + bool admonitions = true; }; class MD4CParser { diff --git a/packages/core/cpp/parser/MarkdownASTNode.hpp b/packages/core/cpp/parser/MarkdownASTNode.hpp index 9570bf553..cd1f05633 100644 --- a/packages/core/cpp/parser/MarkdownASTNode.hpp +++ b/packages/core/cpp/parser/MarkdownASTNode.hpp @@ -38,7 +38,8 @@ enum class NodeType { Superscript, Subscript, Highlight, - SoftBreak + SoftBreak, + Admonition }; struct MarkdownASTNode { diff --git a/packages/core/cpp/wasm/ASTSerializer.cpp b/packages/core/cpp/wasm/ASTSerializer.cpp index 0c02258df..6a0a419ea 100644 --- a/packages/core/cpp/wasm/ASTSerializer.cpp +++ b/packages/core/cpp/wasm/ASTSerializer.cpp @@ -68,6 +68,8 @@ static const char *nodeTypeToString(NodeType type) { return "Subscript"; case NodeType::Highlight: return "Highlight"; + case NodeType::Admonition: + return "Admonition"; default: assert(false && "unhandled NodeType in nodeTypeToString"); return ""; diff --git a/packages/core/cpp/wasm/md4c_wasm.cpp b/packages/core/cpp/wasm/md4c_wasm.cpp index 180bb4e47..5e6d6fe44 100644 --- a/packages/core/cpp/wasm/md4c_wasm.cpp +++ b/packages/core/cpp/wasm/md4c_wasm.cpp @@ -19,10 +19,11 @@ extern "C" { * @param subscript 1 → enable ~subscript~ spans; 0 → disable. * @param highlight 1 → enable ==highlight== spans; 0 → disable. * @param hardSoftBreaks 1 → treat soft breaks as hard breaks; 0 → collapse to space. + * @param admonitions 1 → enable GitHub-style admonitions/alerts; 0 → disable. * @return Null-terminated UTF-8 JSON string, valid until the next call. */ const char *parseMarkdown(const char *markdown, int underline, int latexMath, int superscript, int subscript, - int highlight, int hardSoftBreaks) { + int highlight, int hardSoftBreaks, int admonitions) { if (!markdown) { g_resultBuffer = "{\"type\":\"Document\"}"; return g_resultBuffer.c_str(); @@ -35,6 +36,7 @@ const char *parseMarkdown(const char *markdown, int underline, int latexMath, in flags.subscript = (subscript != 0); flags.highlight = (highlight != 0); flags.hardSoftBreaks = (hardSoftBreaks != 0); + flags.admonitions = (admonitions != 0); Markdown::MD4CParser parser; auto root = parser.parse(std::string(markdown), flags); diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/MarkdownASTNode.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/MarkdownASTNode.swift index 810ba47ed..f4188c310 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/MarkdownASTNode.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/MarkdownASTNode.swift @@ -30,6 +30,7 @@ public enum NodeType: Int, CaseIterable, Sendable { case `subscript` case highlight case softBreak + case admonition } public struct MarkdownASTNode: Sendable, Equatable { diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/MarkdownParserBridge.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/MarkdownParserBridge.swift index 684fa82fa..49b290d4a 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/MarkdownParserBridge.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/MarkdownParserBridge.swift @@ -16,7 +16,8 @@ enum MarkdownParserBridge { flags.subscript ? 1 : 0, flags.highlight ? 1 : 0, flags.hardSoftBreaks ? 1 : 0, - flags.permissiveAutolinks ? 1 : 0 + flags.permissiveAutolinks ? 1 : 0, + flags.admonitions ? 1 : 0 ) else { return MarkdownASTNode(type: .document) } diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/Md4cFlags.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/Md4cFlags.swift index 3134b79af..52f703fd1 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/Md4cFlags.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Parser/Md4cFlags.swift @@ -6,6 +6,7 @@ public struct Md4cFlags: Sendable, Equatable { public var highlight: Bool public var hardSoftBreaks: Bool public var permissiveAutolinks: Bool + public var admonitions: Bool public init( underline: Bool = false, @@ -14,7 +15,8 @@ public struct Md4cFlags: Sendable, Equatable { subscript subscriptEnabled: Bool = false, highlight: Bool = false, hardSoftBreaks: Bool = false, - permissiveAutolinks: Bool = true + permissiveAutolinks: Bool = true, + admonitions: Bool = false ) { self.underline = underline self.latexMath = latexMath @@ -23,6 +25,7 @@ public struct Md4cFlags: Sendable, Equatable { self.highlight = highlight self.hardSoftBreaks = hardSoftBreaks self.permissiveAutolinks = permissiveAutolinks + self.admonitions = admonitions } public static let commonMark = Md4cFlags() diff --git a/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/ParserTests.swift b/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/ParserTests.swift index 2197f5908..ac28cdd90 100644 --- a/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/ParserTests.swift +++ b/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/ParserTests.swift @@ -105,7 +105,7 @@ final class ParserTests: XCTestCase { } func testNodeTypeEnumCountMatches() { - XCTAssertEqual(NodeType.allCases.count, 31) + XCTAssertEqual(NodeType.allCases.count, 32) } func testParsesSoftBreak() { diff --git a/packages/enriched-markdown-ios/cpp/SwiftParserCAPI.h b/packages/enriched-markdown-ios/cpp/SwiftParserCAPI.h index 4422d49e4..cbbb6ba8d 100644 --- a/packages/enriched-markdown-ios/cpp/SwiftParserCAPI.h +++ b/packages/enriched-markdown-ios/cpp/SwiftParserCAPI.h @@ -9,7 +9,7 @@ extern "C" { typedef struct EMCParseResult EMCParseResult; EMCParseResult *em_parse_markdown(const char *markdown, int underline, int latexMath, int superscript, int subscript, - int highlight, int hardSoftBreaks, int permissiveAutolinks); + int highlight, int hardSoftBreaks, int permissiveAutolinks, int admonitions); void em_parse_result_release(EMCParseResult *result); diff --git a/packages/enriched-markdown-ios/cpp/SwiftParserShim.cpp b/packages/enriched-markdown-ios/cpp/SwiftParserShim.cpp index e0a421f2f..59fb246ed 100644 --- a/packages/enriched-markdown-ios/cpp/SwiftParserShim.cpp +++ b/packages/enriched-markdown-ios/cpp/SwiftParserShim.cpp @@ -8,6 +8,8 @@ static_assert(static_cast(Markdown::NodeType::SoftBreak) == 30, "NodeType enum must stay in sync with Swift NodeType"); +static_assert(static_cast(Markdown::NodeType::Admonition) == 31, + "NodeType enum must stay in sync with Swift NodeType"); struct EMCParseResult { std::shared_ptr root; @@ -29,7 +31,7 @@ const Markdown::MarkdownASTNode *asNode(const void *node) { extern "C" { EMCParseResult *em_parse_markdown(const char *markdown, int underline, int latexMath, int superscript, int subscript, - int highlight, int hardSoftBreaks, int permissiveAutolinks) { + int highlight, int hardSoftBreaks, int permissiveAutolinks, int admonitions) { auto *result = new (std::nothrow) EMCParseResult(); if (!result) { return nullptr; @@ -43,6 +45,7 @@ EMCParseResult *em_parse_markdown(const char *markdown, int underline, int latex flags.highlight = highlight != 0; flags.hardSoftBreaks = hardSoftBreaks != 0; flags.permissiveAutolinks = permissiveAutolinks != 0; + flags.admonitions = admonitions != 0; Markdown::MD4CParser parser; result->root = parser.parse(markdown ? std::string(markdown) : "", flags); diff --git a/packages/react-native-enriched-markdown/__tests__/admonition-icons-parity.test.ts b/packages/react-native-enriched-markdown/__tests__/admonition-icons-parity.test.ts new file mode 100644 index 000000000..1c49e3c61 --- /dev/null +++ b/packages/react-native-enriched-markdown/__tests__/admonition-icons-parity.test.ts @@ -0,0 +1,138 @@ +/** + * Cross-platform parity guard for the GitHub admonition/alert header assets. + * + * The octicon `d` path strings, the header titles and the icon viewBox are + * duplicated verbatim in three renderers because each runtime parses them with + * its own API (iOS CGPath, Android PathParser, web SVG DOM) and there is no + * shared runtime between them. The web file is the single source of truth; this + * test reads the iOS and Android sources as text and asserts they stay + * byte-identical to it, so a one-sided edit or typo fails CI instead of shipping + * a mismatched glyph. + * + * This runs under `yarn test` (jest) in the `rn-lint` CI job, which fires + * whenever anything under packages/react-native-enriched-markdown/** changes - + * that glob currently contains all three copies, so any edit to any copy is + * covered. If the native sources ever move out of this package, this guard no + * longer sees them: re-home it (or add a native-side check) at that point. The + * hardcoded paths below are resolved eagerly so a moved/renamed file fails loud + * rather than silently passing. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { + ADMONITION_ICON_PATHS, + ADMONITION_TITLES, + ADMONITION_ICON_VIEWBOX, +} from '../src/web/renderers/admonitionIcons'; + +const PKG_ROOT = join(__dirname, '..'); +const IOS_FILE = join(PKG_ROOT, 'ios/segments/ENRMAdmonitionIcons.m'); +const ANDROID_FILE = join( + PKG_ROOT, + 'android/src/main/java/com/swmansion/enriched/markdown/segments/AdmonitionIcons.kt' +); + +const KEYS = ['note', 'tip', 'important', 'warning', 'caution'] as const; + +function read(path: string): string { + try { + return readFileSync(path, 'utf8'); + } catch { + throw new Error( + `Admonition icon source not found at ${path}. If it moved, update this ` + + `parity guard (see the file header) so it keeps checking all copies.` + ); + } +} + +function slice(src: string, from: string, to: string): string { + const start = src.indexOf(from); + const end = src.indexOf(to, start + from.length); + if (start === -1 || end === -1) { + throw new Error(`Could not locate block "${from}"..."${to}"`); + } + return src.slice(start, end); +} + +// Reconstructs a string map from a block of `value` literal +// entries, joining Objective-C's multi-literal continuations (@"a" @"b"). +function parseEntries( + block: string, + entryRe: RegExp, + literalRe: RegExp +): Record { + const out: Record = {}; + for (const m of block.matchAll(entryRe)) { + const key = m[1]; + const body = m[2]; + if (key === undefined || body === undefined) continue; + out[key] = [...body.matchAll(literalRe)].map((l) => l[1] ?? '').join(''); + } + return out; +} + +function num(src: string, re: RegExp): number { + const match = src.match(re)?.[1]; + if (match === undefined) throw new Error(`No numeric match for ${re}`); + return Number(match); +} + +function parseObjC(src: string) { + const entryRe = /@"([^"]+)"\s*:\s*((?:@"[^"]*"\s*)+)/g; + const literalRe = /@"([^"]*)"/g; + return { + paths: parseEntries(slice(src, 'data = @{', '};'), entryRe, literalRe), + titles: parseEntries(slice(src, 'titles = @{', '};'), entryRe, literalRe), + viewBox: num(src, /ENRMAdmonitionIconViewBox\s*=\s*([\d.]+)/), + }; +} + +function parseKotlin(src: string) { + const entryRe = /"([^"]+)"\s+to\s+((?:"[^"]*"\s*)+)/g; + const literalRe = /"([^"]*)"/g; + return { + paths: parseEntries( + slice(src, 'PATH_DATA =', 'TITLES'), + entryRe, + literalRe + ), + titles: parseEntries( + slice(src, 'TITLES =', 'fun path'), + entryRe, + literalRe + ), + viewBox: num(src, /VIEWBOX\s*=\s*([\d.]+)f?/), + }; +} + +describe('admonition icon assets stay in sync across platforms', () => { + const ios = parseObjC(read(IOS_FILE)); + const android = parseKotlin(read(ANDROID_FILE)); + + it.each(KEYS)('icon path "%s" is identical web/iOS/Android', (key) => { + const web = ADMONITION_ICON_PATHS[key]; + expect(web).toBeTruthy(); + expect(ios.paths[key]).toBe(web); + expect(android.paths[key]).toBe(web); + }); + + it.each(KEYS)('title "%s" is identical web/iOS/Android', (key) => { + const web = ADMONITION_TITLES[key]; + expect(ios.titles[key]).toBe(web); + expect(android.titles[key]).toBe(web); + }); + + it('viewBox is identical web/iOS/Android', () => { + expect(ios.viewBox).toBe(ADMONITION_ICON_VIEWBOX); + expect(android.viewBox).toBe(ADMONITION_ICON_VIEWBOX); + }); + + it('each platform declares exactly the web set of keys (no extras/missing)', () => { + const expected = [...KEYS].sort(); + expect(Object.keys(ADMONITION_ICON_PATHS).sort()).toEqual(expected); + expect(Object.keys(ios.paths).sort()).toEqual(expected); + expect(Object.keys(android.paths).sort()).toEqual(expected); + expect(Object.keys(ios.titles).sort()).toEqual(expected); + expect(Object.keys(android.titles).sort()).toEqual(expected); + }); +}); diff --git a/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp b/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp index 3d004a51d..293f8765d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp +++ b/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp @@ -77,6 +77,8 @@ static jint nodeTypeToJavaOrdinal(NodeType type) { return 29; case NodeType::SoftBreak: return 30; + case NodeType::Admonition: + return 31; default: return 0; } @@ -237,6 +239,10 @@ JNIEXPORT jobject JNICALL Java_com_swmansion_enriched_markdown_parser_Parser_nat if (hardSoftBreaksField) { md4cFlags.hardSoftBreaks = env->GetBooleanField(flags, hardSoftBreaksField) == JNI_TRUE; } + jfieldID admonitionsField = env->GetFieldID(flagsClass, "admonitions", "Z"); + if (admonitionsField) { + md4cFlags.admonitions = env->GetBooleanField(flags, admonitionsField) == JNI_TRUE; + } env->DeleteLocalRef(flagsClass); } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt index 5d4a65a39..4f8f628ba 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -325,6 +325,7 @@ object MeasurementStore { subscript = props.getMapOrNull("md4cFlags").getBooleanOrDefault("subscript", false), highlight = props.getMapOrNull("md4cFlags").getBooleanOrDefault("highlight", false), hardSoftBreaks = props.getMapOrNull("md4cFlags").getBooleanOrDefault("hardSoftBreaks", false), + admonitions = props.getMapOrNull("md4cFlags").getBooleanOrDefault("admonitions", true), ) val fontSize = getInitialFontSize(styleMap, context, allowFontScaling, fontScale, maxFontSizeMultiplier) @@ -417,6 +418,7 @@ object MeasurementStore { subscript = props.getMapOrNull("md4cFlags").getBooleanOrDefault("subscript", false), highlight = props.getMapOrNull("md4cFlags").getBooleanOrDefault("highlight", false), hardSoftBreaks = props.getMapOrNull("md4cFlags").getBooleanOrDefault("hardSoftBreaks", false), + admonitions = props.getMapOrNull("md4cFlags").getBooleanOrDefault("admonitions", true), ) val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false) val fontSize = getInitialFontSize(styleMap, context, allowFontScaling, fontScale, maxFontSizeMultiplier) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt index e283f7fab..e51813b8f 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt @@ -38,6 +38,7 @@ data class MarkdownASTNode( Subscript, Highlight, SoftBreak, + Admonition, } fun getAttribute(key: String): String? = attributes[key] diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt index 1cbe6e912..49a133ded 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt @@ -11,6 +11,7 @@ data class Md4cFlags( val highlight: Boolean = false, val permissiveAutolinks: Boolean = true, val hardSoftBreaks: Boolean = false, + val admonitions: Boolean = true, ) { companion object { val DEFAULT = Md4cFlags() diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt index e028f9fe4..3a9506111 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt @@ -87,6 +87,9 @@ class RendererFactory( put(MarkdownASTNode.NodeType.Paragraph, ParagraphRenderer(config)) put(MarkdownASTNode.NodeType.Heading, HeadingRenderer(config)) put(MarkdownASTNode.NodeType.Blockquote, BlockquoteRenderer(config)) + // A list-nested admonition falls back to the inline blockquote renderer + // (no themed header); top-level admonitions use the segment container path. + put(MarkdownASTNode.NodeType.Admonition, BlockquoteRenderer(config)) put(MarkdownASTNode.NodeType.CodeBlock, CodeBlockRenderer(config)) put(MarkdownASTNode.NodeType.UnorderedList, ListRenderer(config, isOrdered = false)) put(MarkdownASTNode.NodeType.OrderedList, ListRenderer(config, isOrdered = true)) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/AdmonitionIcons.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/AdmonitionIcons.kt new file mode 100644 index 000000000..eeabaa835 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/AdmonitionIcons.kt @@ -0,0 +1,58 @@ +package com.swmansion.enriched.markdown.segments + +import android.graphics.Path +import androidx.core.graphics.PathParser + +/** + * GitHub admonition/alert header assets shared with iOS and web. + * + * The `d` path strings are taken verbatim from @primer/octicons (16x16 viewBox) + * and MUST stay byte-identical across the three renderers so the glyph looks the + * same everywhere: + * - iOS: ios/segments/ENRMAdmonitionIcons.m + * - Android: this file + * - Web: src/web/renderers/admonitionIcons.ts (source of truth) + * + * note=info, tip=light-bulb, important=report, warning=alert, caution=stop. + * + * Parity is enforced by __tests__/admonition-icons-parity.test.ts (jest, run in + * CI), which reads all three files and fails on any drift. That guard only works + * while all three copies live under packages/react-native-enriched-markdown; + * moving a copy out of this package requires re-homing the guard (or adding a + * native-side check) so the copies can't silently diverge. + */ +object AdmonitionIcons { + const val VIEWBOX = 16f + + private val PATH_DATA = + mapOf( + "note" to + "M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z", + "tip" to + "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z", + "important" to + "M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z", + "warning" to + "M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z", + "caution" to + "M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z", + ) + + private val TITLES = + mapOf( + "note" to "Note", + "tip" to "Tip", + "important" to "Important", + "warning" to "Warning", + "caution" to "Caution", + ) + + /** The supported admonition types, in the order md4c reports them. */ + val TYPES: Set = PATH_DATA.keys + + /** Parses the octicon path for [type] into a Path in the 16x16 icon space. */ + fun path(type: String): Path? = PATH_DATA[type]?.let { PathParser.createPathFromPathData(it) } + + /** Capitalized header title for [type] (e.g. "note" -> "Note"). */ + fun title(type: String): String = TITLES[type] ?: type.replaceFirstChar { it.uppercase() } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt index 5cab50789..417171cbf 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt @@ -6,6 +6,7 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.RectF +import android.graphics.Typeface import android.view.View import androidx.core.graphics.withSave import com.swmansion.enriched.markdown.EnrichedMarkdownInternalText @@ -14,6 +15,8 @@ import com.swmansion.enriched.markdown.styles.BlockquoteStyle import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.roundToInt /** * A GFM blockquote rendered as a recursive container: it splits its own AST @@ -46,6 +49,22 @@ class BlockquoteContainerView( private val verticalInset: Int = ceil(paddingPx).toInt() private val rightInset: Int = ceil(paddingPx).toInt() + // Set from the applied node: null for a plain quote, the admonition type + // ("note"/"tip"/…) otherwise. Drives the header + per-type theming. + private var admonitionType: String? = null + + private val iconSizePx: Int = ceil(blockquoteStyle.fontSize).toInt() + + private val titlePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = Typeface.DEFAULT_BOLD + textSize = blockquoteStyle.fontSize + } + private val iconPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + + private fun reservedHeaderHeight(): Int = if (admonitionType != null) ceil(admonitionHeaderReservedHeight(blockquoteStyle)).toInt() else 0 + // Only the outermost quote carries vertical margins; a quote nested directly // inside another quote is separated by the parent's padding alone (matches the // commonmark BlockquoteRenderer, which applies margins only at depth 0). @@ -73,6 +92,15 @@ class BlockquoteContainerView( } fun applyBlockquoteNode(node: MarkdownASTNode) { + admonitionType = + if (node.type == MarkdownASTNode.NodeType.Admonition) { + node.getAttribute("admonitionType")?.takeIf { it.isNotEmpty() } ?: "note" + } else { + null + } + // Reserve the header band above the body by enlarging the top padding. + setPadding(leftInset, verticalInset + reservedHeaderHeight(), rightInset, verticalInset) + val segments = splitASTIntoSegments(node) val rendered = MarkdownSegmentRenderer.render( @@ -109,9 +137,25 @@ class BlockquoteContainerView( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val radius = blockquoteStyle.borderRadius - val bgColor = blockquoteStyle.backgroundColor?.takeIf { it != Color.TRANSPARENT } val hasRadius = radius > 0f + // Admonitions theme the box with their per-type color; a plain quote keeps + // the base blockquote colors. + val type = admonitionType + val tint = + if (type != null) { + blockquoteStyle.admonitions[type]?.color ?: blockquoteStyle.borderColor + } else { + blockquoteStyle.borderColor + } + val bgColor = + if (type != null) { + blockquoteStyle.admonitions[type]?.backgroundColor?.takeIf { it != Color.TRANSPARENT } + } else { + blockquoteStyle.backgroundColor?.takeIf { it != Color.TRANSPARENT } + } + borderPaint.color = tint + if (hasRadius) { rect.set(0f, 0f, width.toFloat(), height.toFloat()) radiiArray.fill(radius) @@ -138,6 +182,40 @@ class BlockquoteContainerView( } else { canvas.drawRect(0f, 0f, borderWidthPx, height.toFloat(), borderPaint) } + + if (type != null) { + drawAdmonitionHeader(canvas, type, tint) + } + } + + // Draws the admonition header (tinted octicon + capitalized title) in the band + // reserved at the top of the view by the enlarged top padding. + private fun drawAdmonitionHeader( + canvas: Canvas, + type: String, + tint: Int, + ) { + val headerTop = verticalInset.toFloat() + val headerHeight = admonitionHeaderContentHeight(blockquoteStyle) + var titleX = leftInset.toFloat() + + val iconPath = AdmonitionIcons.path(type) + if (iconPath != null) { + val scale = iconSizePx / AdmonitionIcons.VIEWBOX + val iconY = headerTop + (headerHeight - iconSizePx) / 2f + iconPaint.color = tint + canvas.withSave { + translate(leftInset.toFloat(), iconY) + scale(scale, scale) + drawPath(iconPath, iconPaint) + } + titleX = (leftInset + iconSizePx + (iconSizePx * 0.4f).roundToInt()).toFloat() + } + + titlePaint.color = tint + val fm = titlePaint.fontMetrics + val baseline = headerTop + headerHeight / 2f - (fm.ascent + fm.descent) / 2f + canvas.drawText(AdmonitionIcons.title(type), titleX, baseline, titlePaint) } /** @@ -202,6 +280,14 @@ class BlockquoteContainerView( } companion object { + // Height of the header band (icon + title row). Shared by the instance draw + // path and the view-free measurement so both reserve identical space. + fun admonitionHeaderContentHeight(style: BlockquoteStyle): Float = ceil(max(ceil(style.fontSize), style.fontSize * 1.35f)) + + // Vertical space the header adds above the body (band + gap). + fun admonitionHeaderReservedHeight(style: BlockquoteStyle): Float = + admonitionHeaderContentHeight(style) + (style.fontSize * 0.4f).roundToInt() + /** * View-free height of a blockquote node at the given outer content width. * The children are summed at the reduced inner width (outer minus horizontal @@ -219,6 +305,12 @@ class BlockquoteContainerView( val leftInset = ceil(style.borderWidth + style.gapWidth + style.padding).toInt() val rightInset = ceil(style.padding).toInt() val verticalInset = ceil(style.padding).toInt() + val headerReserved = + if (node.type == MarkdownASTNode.NodeType.Admonition) { + ceil(admonitionHeaderReservedHeight(style)) + } else { + 0f + } val innerWidth = (width - leftInset - rightInset).coerceAtLeast(1f) val segments = splitASTIntoSegments(node) @@ -239,7 +331,7 @@ class BlockquoteContainerView( mathHeightForIndex = { estimateMathHeight(config) }, ) - return childrenHeight + verticalInset * 2f + return childrenHeight + verticalInset * 2f + headerReserved } private fun estimateMathHeight(config: StyleConfig): Float { diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/MarkdownSegment.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/MarkdownSegment.kt index 4e8b4d743..04d0e1fca 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/MarkdownSegment.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/MarkdownSegment.kt @@ -59,7 +59,9 @@ fun splitASTIntoSegments(root: MarkdownASTNode): List { segments.add(MarkdownSegment.CodeBlock(child)) } - MarkdownASTNode.NodeType.Blockquote -> { + MarkdownASTNode.NodeType.Blockquote, + MarkdownASTNode.NodeType.Admonition, + -> { flushTextNodes() segments.add(MarkdownSegment.Blockquote(child)) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt index 521d7b01a..0c1efa6b8 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt @@ -1,6 +1,13 @@ package com.swmansion.enriched.markdown.styles import com.facebook.react.bridge.ReadableMap +import com.swmansion.enriched.markdown.segments.AdmonitionIcons + +/** Per-admonition-type colors. [color] tints border/title/icon; [backgroundColor] null = transparent. */ +data class AdmonitionColors( + val color: Int, + val backgroundColor: Int?, +) data class BlockquoteStyle( override val fontSize: Float, @@ -16,8 +23,26 @@ data class BlockquoteStyle( val backgroundColor: Int?, val borderRadius: Float, val padding: Float, + val admonitions: Map, ) : BaseBlockStyle { companion object { + private fun parseAdmonitions( + map: ReadableMap, + parser: StyleParser, + ): Map { + val admonitionsMap = map.getMap("admonitions") ?: return emptyMap() + val result = mutableMapOf() + for (type in AdmonitionIcons.TYPES) { + val typeMap = admonitionsMap.getMap(type) ?: continue + result[type] = + AdmonitionColors( + parser.parseColor(typeMap, "color"), + parser.parseOptionalColor(typeMap, "backgroundColor"), + ) + } + return result + } + fun fromReadableMap( map: ReadableMap, parser: StyleParser, @@ -36,6 +61,7 @@ data class BlockquoteStyle( val backgroundColor = parser.parseOptionalColor(map, "backgroundColor") val borderRadius = parser.toPixelFromDIP(map.getDouble("borderRadius").toFloat()) val padding = parser.toPixelFromDIP(map.getDouble("padding").toFloat()) + val admonitions = parseAdmonitions(map, parser) return BlockquoteStyle( fontSize, @@ -51,6 +77,7 @@ data class BlockquoteStyle( backgroundColor, borderRadius, padding, + admonitions, ) } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt index a7b185deb..cf53d437d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt @@ -104,6 +104,7 @@ fun parseMd4cFlags(flags: ReadableMap?): Md4cFlags = subscript = flags?.getBoolean("subscript") ?: false, highlight = flags?.getBoolean("highlight") ?: false, hardSoftBreaks = flags?.getBoolean("hardSoftBreaks") ?: false, + admonitions = flags?.getBoolean("admonitions") ?: true, ) fun parseContextMenuItems(value: ReadableArray?): List = diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index dae75b34f..2b4abae23 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -144,6 +144,7 @@ + (ENRMMd4cFlags *)flagsFromProps:(const EnrichedMarkdownMd4cFlagsStruct &)props flags.latexMath = props.latexMath; flags.highlight = props.highlight; flags.hardSoftBreaks = props.hardSoftBreaks; + flags.admonitions = props.admonitions; return flags; } @@ -1029,7 +1030,8 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & newViewProps.md4cFlags.subscript != oldViewProps.md4cFlags.subscript || newViewProps.md4cFlags.latexMath != oldViewProps.md4cFlags.latexMath || newViewProps.md4cFlags.highlight != oldViewProps.md4cFlags.highlight || - newViewProps.md4cFlags.hardSoftBreaks != oldViewProps.md4cFlags.hardSoftBreaks) { + newViewProps.md4cFlags.hardSoftBreaks != oldViewProps.md4cFlags.hardSoftBreaks || + newViewProps.md4cFlags.admonitions != oldViewProps.md4cFlags.admonitions) { _md4cFlags = [EnrichedMarkdown flagsFromProps:newViewProps.md4cFlags]; _dirtyFlags |= ENRMDirtyForceHeight | ENRMDirtyRender; } diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm index e3f1c3177..597d8085e 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm @@ -132,6 +132,7 @@ + (ENRMMd4cFlags *)flagsFromProps:(const EnrichedMarkdownTextMd4cFlagsStruct &)p flags.latexMath = props.latexMath; flags.highlight = props.highlight; flags.hardSoftBreaks = props.hardSoftBreaks; + flags.admonitions = props.admonitions; return flags; } @@ -556,7 +557,8 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & newViewProps.md4cFlags.subscript != oldViewProps.md4cFlags.subscript || newViewProps.md4cFlags.latexMath != oldViewProps.md4cFlags.latexMath || newViewProps.md4cFlags.highlight != oldViewProps.md4cFlags.highlight || - newViewProps.md4cFlags.hardSoftBreaks != oldViewProps.md4cFlags.hardSoftBreaks) { + newViewProps.md4cFlags.hardSoftBreaks != oldViewProps.md4cFlags.hardSoftBreaks || + newViewProps.md4cFlags.admonitions != oldViewProps.md4cFlags.admonitions) { _md4cFlags = [EnrichedMarkdownText flagsFromProps:newViewProps.md4cFlags]; _forceHeightUpdateOnNextRender = YES; _dirtyFlags |= ENRMDirtyRender; diff --git a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h index 0a58b4e43..4b6d9dd6c 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h +++ b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h @@ -88,6 +88,7 @@ template static inline ENRMMd4cFlags *ENRMMd4cFlagsFromPro flags.latexMath = props.latexMath; flags.highlight = props.highlight; flags.hardSoftBreaks = props.hardSoftBreaks; + flags.admonitions = props.admonitions; return flags; } diff --git a/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h b/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h index f5c671934..7bf350d70 100644 --- a/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h +++ b/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h @@ -41,6 +41,7 @@ struct MeasurementCacheKey { bool md4cFlagsHighlight; bool md4cFlagsLatexMath; bool md4cFlagsHardSoftBreaks; + bool md4cFlagsAdmonitions; size_t styleFingerprint; CGFloat fontScale; MarkdownFlavor flavor; @@ -51,13 +52,13 @@ struct MeasurementCacheKey { { return std::tie(markdown, maxWidth, allowTrailingMargin, allowFontScaling, maxFontSizeMultiplier, md4cFlagsUnderline, md4cFlagsSuperscript, md4cFlagsSubscript, md4cFlagsHighlight, - md4cFlagsLatexMath, md4cFlagsHardSoftBreaks, styleFingerprint, fontScale, flavor, - lineBreakStrategyIOS, writingDirection) == + md4cFlagsLatexMath, md4cFlagsHardSoftBreaks, md4cFlagsAdmonitions, styleFingerprint, fontScale, + flavor, lineBreakStrategyIOS, writingDirection) == std::tie(other.markdown, other.maxWidth, other.allowTrailingMargin, other.allowFontScaling, other.maxFontSizeMultiplier, other.md4cFlagsUnderline, other.md4cFlagsSuperscript, other.md4cFlagsSubscript, other.md4cFlagsHighlight, other.md4cFlagsLatexMath, - other.md4cFlagsHardSoftBreaks, other.styleFingerprint, other.fontScale, other.flavor, - other.lineBreakStrategyIOS, other.writingDirection); + other.md4cFlagsHardSoftBreaks, other.md4cFlagsAdmonitions, other.styleFingerprint, other.fontScale, + other.flavor, other.lineBreakStrategyIOS, other.writingDirection); } }; @@ -76,6 +77,7 @@ struct MeasurementCacheKeyHash { HashUtils::hash_one(h, key.md4cFlagsHighlight); HashUtils::hash_one(h, key.md4cFlagsLatexMath); HashUtils::hash_one(h, key.md4cFlagsHardSoftBreaks); + HashUtils::hash_one(h, key.md4cFlagsAdmonitions); HashUtils::hash_one(h, key.styleFingerprint); HashUtils::hash_one(h, key.fontScale); HashUtils::hash_one(h, static_cast(key.flavor)); @@ -161,6 +163,7 @@ inline MeasurementCacheKey buildMeasurementCacheKey(const PropsType &props, CGFl .md4cFlagsHighlight = props.md4cFlags.highlight, .md4cFlagsLatexMath = props.md4cFlags.latexMath, .md4cFlagsHardSoftBreaks = props.md4cFlags.hardSoftBreaks, + .md4cFlagsAdmonitions = props.md4cFlags.admonitions, .styleFingerprint = computeStyleFingerprint(props.markdownStyle), .fontScale = fontScale, .flavor = flavor, diff --git a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h index c3a1a6d48..91eed0494 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h +++ b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h @@ -69,6 +69,7 @@ static inline bool ENRMPropsNeedExactStreamingMeasurement(const PropsT &oldProps oldProps.md4cFlags.latexMath != newProps.md4cFlags.latexMath || oldProps.md4cFlags.highlight != newProps.md4cFlags.highlight || oldProps.md4cFlags.hardSoftBreaks != newProps.md4cFlags.hardSoftBreaks || + oldProps.md4cFlags.admonitions != newProps.md4cFlags.admonitions || computeStyleFingerprint(oldProps.markdownStyle) != computeStyleFingerprint(newProps.markdownStyle); } diff --git a/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.h b/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.h index 00b35595a..0d3e33414 100644 --- a/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.h +++ b/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.h @@ -9,6 +9,7 @@ @property (nonatomic, assign) BOOL subscript; @property (nonatomic, assign) BOOL highlight; @property (nonatomic, assign) BOOL hardSoftBreaks; +@property (nonatomic, assign) BOOL admonitions; + (instancetype)defaultFlags; diff --git a/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.mm b/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.mm index 52013504b..cce0bb722 100644 --- a/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.mm +++ b/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.mm @@ -14,6 +14,7 @@ - (instancetype)init _subscript = NO; _highlight = NO; _hardSoftBreaks = NO; + _admonitions = YES; } return self; } @@ -32,6 +33,7 @@ - (id)copyWithZone:(NSZone *)zone copy.subscript = self.subscript; copy.highlight = self.highlight; copy.hardSoftBreaks = self.hardSoftBreaks; + copy.admonitions = self.admonitions; return copy; } diff --git a/packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h b/packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h index 5ace29eef..f34e4deab 100644 --- a/packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h +++ b/packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h @@ -31,7 +31,8 @@ typedef NS_ENUM(NSInteger, MarkdownNodeType) { MarkdownNodeTypeSuperscript, MarkdownNodeTypeSubscript, MarkdownNodeTypeHighlight, - MarkdownNodeTypeSoftBreak + MarkdownNodeTypeSoftBreak, + MarkdownNodeTypeAdmonition }; @interface MarkdownASTNode : NSObject diff --git a/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm b/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm index 3131dc7dd..2849688d6 100644 --- a/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm +++ b/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm @@ -107,6 +107,9 @@ case Markdown::NodeType::SoftBreak: objcType = MarkdownNodeTypeSoftBreak; break; + case Markdown::NodeType::Admonition: + objcType = MarkdownNodeTypeAdmonition; + break; } MarkdownASTNode *objcNode = [[MarkdownASTNode alloc] initWithType:objcType]; @@ -156,6 +159,7 @@ cppFlags.subscript = flags.subscript; cppFlags.highlight = flags.highlight; cppFlags.hardSoftBreaks = flags.hardSoftBreaks; + cppFlags.admonitions = flags.admonitions; Markdown::MD4CParser parser; auto cppAST = parser.parse(cppMarkdown, cppFlags); diff --git a/packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m b/packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m index 1f4ff795e..e598233e7 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m +++ b/packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m @@ -98,6 +98,9 @@ - (instancetype)initWithConfig:(StyleConfig *)config case MarkdownNodeTypeImage: return [[ENRMImageRenderer alloc] initWithRendererFactory:self config:_config]; case MarkdownNodeTypeBlockquote: + // A list-nested admonition falls back to the inline blockquote renderer + // (no themed header); top-level admonitions use the segment container path. + case MarkdownNodeTypeAdmonition: return [[BlockquoteRenderer alloc] initWithRendererFactory:self config:_config]; case MarkdownNodeTypeListItem: return [[ListItemRenderer alloc] initWithRendererFactory:self config:_config]; diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMAdmonitionIcons.h b/packages/react-native-enriched-markdown/ios/segments/ENRMAdmonitionIcons.h new file mode 100644 index 000000000..4fefe232d --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMAdmonitionIcons.h @@ -0,0 +1,31 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +#ifdef __cplusplus +extern "C" { +#endif + +// GitHub admonition/alert header assets shared with Android and web. +// +// The `d` path strings live in ENRMAdmonitionIcons.m and are taken verbatim from +// @primer/octicons (16x16 viewBox); they MUST stay byte-identical across: +// - iOS: this file +// - Android: android/.../segments/AdmonitionIcons.kt +// - Web: src/web/renderers/admonitionIcons.ts +extern const CGFloat ENRMAdmonitionIconViewBox; + +// CGPath for the octicon of the given admonition type ("note", "tip", +// "important", "warning", "caution"), in the 16x16 icon space. NULL for unknown +// types. Caller owns the returned path (CGPathRelease). +CGPathRef _Nullable ENRMAdmonitionIconPath(NSString *type) CF_RETURNS_RETAINED; + +// Capitalized header title for the type (e.g. "note" -> "Note"). +NSString *ENRMAdmonitionTitle(NSString *type); + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMAdmonitionIcons.m b/packages/react-native-enriched-markdown/ios/segments/ENRMAdmonitionIcons.m new file mode 100644 index 000000000..41f72e1cb --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMAdmonitionIcons.m @@ -0,0 +1,79 @@ +#import "ENRMAdmonitionIcons.h" +#import "ENRMSVGPath.h" + +const CGFloat ENRMAdmonitionIconViewBox = 16.0; + +// note=info, tip=light-bulb, important=report, warning=alert, caution=stop. +// Keep byte-identical with AdmonitionIcons.kt / admonitionIcons.ts (the web file +// is the source of truth). Parity is enforced by +// __tests__/admonition-icons-parity.test.ts (jest, run in CI), which reads all +// three files and fails on any drift. That guard only works while all three +// copies live under packages/react-native-enriched-markdown/**; moving a copy +// out of this package requires re-homing the guard (or adding a native-side +// check) so the copies can't silently diverge. +static NSDictionary *ENRMAdmonitionIconPathData(void) +{ + static NSDictionary *data; + static dispatch_once_t once; + dispatch_once(&once, ^{ + data = @{ + @"note" : + @"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 " + @"7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 " + @"0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z", + @"tip" : + @"M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284." + @"411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 " + @"0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 " + @"5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-." + @"33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848." + @"075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 " + @"0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 " + @"0 0 1-.75-.75Z", + @"important" : + @"M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 " + @"13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25." + @"25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 " + @".53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 " + @"0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z", + @"warning" : + @"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 " + @"1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a." + @"25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 " + @"0 1 1 0 0 1 2 0Z", + @"caution" : + @"M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 " + @"0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 " + @"11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 " + @"4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 " + @"2Z", + }; + }); + return data; +} + +CGPathRef ENRMAdmonitionIconPath(NSString *type) +{ + NSString *pathData = ENRMAdmonitionIconPathData()[type]; + if (!pathData) { + return NULL; + } + return ENRMCreateCGPathFromSVGPath(pathData); +} + +NSString *ENRMAdmonitionTitle(NSString *type) +{ + static NSDictionary *titles; + static dispatch_once_t once; + dispatch_once(&once, ^{ + titles = @{ + @"note" : @"Note", + @"tip" : @"Tip", + @"important" : @"Important", + @"warning" : @"Warning", + @"caution" : @"Caution", + }; + }); + NSString *title = titles[type]; + return title ?: type.capitalizedString; +} diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm index d4b9f40f6..84a23800d 100644 --- a/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm @@ -1,4 +1,5 @@ #import "ENRMBlockquoteContainerView.h" +#import "ENRMAdmonitionIcons.h" #import "ENRMCodeBlockContainerView.h" #import "ENRMFeatureFlags.h" #import "ENRMSegmentHeightMeasurer.h" @@ -22,15 +23,76 @@ config.maxFontSizeMultiplier, NSLineBreakStrategyNone, /*blockquoteContent*/ YES); } -static UIEdgeInsets ENRMBlockquoteContentInsets(StyleConfig *config) +// The admonition type ("note"/"tip"/…) for a node, or nil for a plain quote. +static NSString *ENRMAdmonitionTypeForNode(MarkdownASTNode *node) +{ + if (node.type != MarkdownNodeTypeAdmonition) { + return nil; + } + NSString *type = node.attributes[@"admonitionType"]; + return type.length > 0 ? type : @"note"; +} + +static CGFloat ENRMAdmonitionIconSize(StyleConfig *config) +{ + return ceil(config.blockquoteFontSize); +} + +// Bold variant of the blockquote font for the header title, respecting the +// configured family where the platform supports trait derivation. +static UIFont *ENRMAdmonitionTitleFont(StyleConfig *config) +{ + UIFont *base = config.blockquoteFont; +#if !TARGET_OS_OSX + UIFontDescriptor *descriptor = [base.fontDescriptor + fontDescriptorWithSymbolicTraits:(base.fontDescriptor.symbolicTraits | UIFontDescriptorTraitBold)]; + UIFont *bold = descriptor ? [UIFont fontWithDescriptor:descriptor size:base.pointSize] : nil; + return bold ?: [UIFont boldSystemFontOfSize:base.pointSize]; +#else + return [NSFont boldSystemFontOfSize:base.pointSize]; +#endif +} + +// Height of the header band (icon + title row); body sits a gap below it. +static CGFloat ENRMAdmonitionHeaderContentHeight(StyleConfig *config) +{ + return ceil(MAX(ENRMAdmonitionIconSize(config), config.blockquoteFontSize * 1.35)); +} + +static CGFloat ENRMAdmonitionHeaderToBodyGap(StyleConfig *config) +{ + return round(config.blockquoteFontSize * 0.4); +} + +// Vertical space reserved above the body for the header (0 for a plain quote). +static CGFloat ENRMAdmonitionHeaderReservedHeight(StyleConfig *config, BOOL isAdmonition) +{ + if (!isAdmonition) { + return 0; + } + return ENRMAdmonitionHeaderContentHeight(config) + ENRMAdmonitionHeaderToBodyGap(config); +} + +static UIEdgeInsets ENRMBlockquoteContentInsetsForNode(StyleConfig *config, BOOL isAdmonition) { CGFloat padding = config.blockquotePadding; CGFloat left = ceil(config.blockquoteBorderWidth + config.blockquoteGapWidth + padding); + CGFloat top = ceil(padding + ENRMAdmonitionHeaderReservedHeight(config, isAdmonition)); CGFloat vertical = ceil(padding); CGFloat right = ceil(padding); - return UIEdgeInsetsMake(vertical, left, vertical, right); + return UIEdgeInsetsMake(top, left, vertical, right); } +static UIEdgeInsets ENRMBlockquoteContentInsets(StyleConfig *config) +{ + return ENRMBlockquoteContentInsetsForNode(config, NO); +} + +@interface ENRMBlockquoteContainerView () +// nil for a plain blockquote; the admonition type string otherwise. +@property (nonatomic, copy, nullable) NSString *admonitionType; +@end + @implementation ENRMBlockquoteContainerView - (instancetype)initWithConfig:(StyleConfig *)config @@ -50,8 +112,15 @@ - (instancetype)initWithConfig:(StyleConfig *)config - (void)applyBlockquoteNode:(MarkdownASTNode *)node { + self.admonitionType = ENRMAdmonitionTypeForNode(node); + self.contentInsets = ENRMBlockquoteContentInsetsForNode(self.config, self.admonitionType != nil); NSArray *rendered = ENRMRenderBlockquoteChildren(node, self.config); [self applySegments:rendered reset:NO]; +#if !TARGET_OS_OSX + [self setNeedsDisplay]; +#else + self.needsDisplay = YES; +#endif } - (void)pushCopyLabelsToChildren @@ -244,7 +313,7 @@ + (CGFloat)measureHeightForBlockquoteNode:(MarkdownASTNode *)node maxWidth:(CGFloat)maxWidth pointScaleFactor:(CGFloat)pointScaleFactor { - UIEdgeInsets insets = ENRMBlockquoteContentInsets(config); + UIEdgeInsets insets = ENRMBlockquoteContentInsetsForNode(config, ENRMAdmonitionTypeForNode(node) != nil); CGFloat innerWidth = MAX(maxWidth - insets.left - insets.right, 1); NSArray *rendered = ENRMRenderBlockquoteChildren(node, config); @@ -272,14 +341,19 @@ - (void)drawRect:(CGRect)rect : [NSBezierPath bezierPathWithRect:bounds]; #endif - RCTUIColor *backgroundColor = config.blockquoteBackgroundColor; + // Admonitions theme the box with their per-type color; a plain quote keeps the + // base blockquote colors. + NSString *admonitionType = self.admonitionType; + RCTUIColor *backgroundColor = + admonitionType ? [config admonitionBackgroundColorForType:admonitionType] : config.blockquoteBackgroundColor; if (backgroundColor && backgroundColor != [RCTUIColor clearColor]) { [backgroundColor setFill]; [boxPath fill]; } if (borderWidth > 0) { - RCTUIColor *borderColor = config.blockquoteBorderColor; + RCTUIColor *borderColor = + admonitionType ? [config admonitionColorForType:admonitionType] : config.blockquoteBorderColor; if (borderColor) { CGRect barRect = CGRectMake(0, 0, borderWidth, bounds.size.height); [borderColor setFill]; @@ -297,6 +371,55 @@ - (void)drawRect:(CGRect)rect #endif } } + + if (admonitionType) { + [self drawAdmonitionHeaderForType:admonitionType config:config]; + } +} + +// Draws the admonition header (tinted octicon + capitalized title) in the band +// reserved at the top by the enlarged content inset. The view is flipped on +// macOS, so the y-down icon path and text draw identically on both platforms. +- (void)drawAdmonitionHeaderForType:(NSString *)type config:(StyleConfig *)config +{ + RCTUIColor *tint = [config admonitionColorForType:type]; + CGFloat padding = config.blockquotePadding; + CGFloat leftInset = ceil(config.blockquoteBorderWidth + config.blockquoteGapWidth + padding); + CGFloat iconSize = ENRMAdmonitionIconSize(config); + CGFloat headerHeight = ENRMAdmonitionHeaderContentHeight(config); + CGFloat headerTop = ceil(padding); + CGFloat titleX = leftInset; + + CGPathRef iconPath = ENRMAdmonitionIconPath(type); + if (iconPath) { +#if !TARGET_OS_OSX + CGContextRef ctx = UIGraphicsGetCurrentContext(); +#else + CGContextRef ctx = [[NSGraphicsContext currentContext] CGContext]; +#endif + if (ctx) { + CGFloat scale = iconSize / ENRMAdmonitionIconViewBox; + CGFloat iconY = headerTop + (headerHeight - iconSize) / 2.0; + CGContextSaveGState(ctx); + CGContextTranslateCTM(ctx, leftInset, iconY); + CGContextScaleCTM(ctx, scale, scale); + CGContextAddPath(ctx, iconPath); + [tint setFill]; + CGContextFillPath(ctx); + CGContextRestoreGState(ctx); + } + CGPathRelease(iconPath); + titleX = leftInset + iconSize + round(iconSize * 0.4); + } + + NSString *title = ENRMAdmonitionTitle(type); + UIFont *titleFont = ENRMAdmonitionTitleFont(config); + NSDictionary *attributes = + @{NSFontAttributeName : titleFont, NSForegroundColorAttributeName : tint}; + NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:title attributes:attributes]; + CGSize titleSize = [attributed size]; + CGFloat titleY = headerTop + (headerHeight - titleSize.height) / 2.0; + [attributed drawAtPoint:CGPointMake(titleX, titleY)]; } @end diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMSVGPath.h b/packages/react-native-enriched-markdown/ios/segments/ENRMSVGPath.h new file mode 100644 index 000000000..79f768363 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMSVGPath.h @@ -0,0 +1,21 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +#ifdef __cplusplus +extern "C" { +#endif + +// Parse an SVG path `d` string into a CGPath. Supports the M/L/H/V/C/S/Q/T/A/Z +// command set (absolute and relative), which covers the @primer/octicons glyphs +// used for admonition headers. Elliptical arcs are approximated with cubic +// beziers. Returns NULL on empty/invalid input. Caller owns the returned path +// (CGPathRelease). CoreGraphics is used so the same code renders on iOS and macOS. +CGPathRef _Nullable ENRMCreateCGPathFromSVGPath(NSString *pathData) CF_RETURNS_RETAINED; + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMSVGPath.m b/packages/react-native-enriched-markdown/ios/segments/ENRMSVGPath.m new file mode 100644 index 000000000..10a4fb1b0 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMSVGPath.m @@ -0,0 +1,409 @@ +#import "ENRMSVGPath.h" +#import + +// Minimal SVG path `d` parser producing a CGPath. It is intentionally scoped to +// the command set the admonition octicons use (M m L l H h V v C c S s Q q T t +// A a Z z); it is not a general-purpose SVG engine. The parser walks the string +// with a scanner, tracking the current point, the current subpath start, and the +// previous control point (for the smooth S/T shorthands). Elliptical arcs are +// converted to cubic beziers via the endpoint -> center parameterization from +// the SVG implementation notes (appendix F.6). + +typedef struct { + const char *chars; + NSUInteger length; + NSUInteger index; +} ENRMScanner; + +static BOOL ENRMScanIsCommand(char c) +{ + switch (c) { + case 'M': + case 'm': + case 'L': + case 'l': + case 'H': + case 'h': + case 'V': + case 'v': + case 'C': + case 'c': + case 'S': + case 's': + case 'Q': + case 'q': + case 'T': + case 't': + case 'A': + case 'a': + case 'Z': + case 'z': + return YES; + default: + return NO; + } +} + +static void ENRMScanSkipSeparators(ENRMScanner *s) +{ + while (s->index < s->length) { + char c = s->chars[s->index]; + if (c == ' ' || c == ',' || c == '\t' || c == '\n' || c == '\r') { + s->index++; + } else { + break; + } + } +} + +// Reads the next number. SVG allows numbers to run together without separators +// (e.g. "1 1 0 1 1"), and a leading '-' or '.' starts a new number even with no +// separator (e.g. "16 0Zm8-6.5"). Returns NO when no number is available. +static BOOL ENRMScanNumber(ENRMScanner *s, double *out) +{ + ENRMScanSkipSeparators(s); + NSUInteger start = s->index; + BOOL seenDigit = NO; + BOOL seenDot = NO; + BOOL seenExp = NO; + + if (s->index < s->length && (s->chars[s->index] == '+' || s->chars[s->index] == '-')) { + s->index++; + } + while (s->index < s->length) { + char c = s->chars[s->index]; + if (c >= '0' && c <= '9') { + seenDigit = YES; + s->index++; + } else if (c == '.' && !seenDot && !seenExp) { + seenDot = YES; + s->index++; + } else if ((c == 'e' || c == 'E') && seenDigit && !seenExp) { + seenExp = YES; + s->index++; + if (s->index < s->length && (s->chars[s->index] == '+' || s->chars[s->index] == '-')) { + s->index++; + } + } else { + break; + } + } + + if (!seenDigit) { + s->index = start; + return NO; + } + + char buffer[64]; + NSUInteger count = s->index - start; + if (count >= sizeof(buffer)) { + count = sizeof(buffer) - 1; + } + memcpy(buffer, s->chars + start, count); + buffer[count] = '\0'; + *out = atof(buffer); + return YES; +} + +// A single arc segment (<= 90 degrees) approximated as one cubic bezier. +static void ENRMArcSegment(CGMutablePathRef path, double cx, double cy, double rx, double ry, double phi, double t1, + double dt) +{ + double cosPhi = cos(phi); + double sinPhi = sin(phi); + double alpha = (4.0 / 3.0) * tan(dt / 4.0); + + double x1 = cos(t1); + double y1 = sin(t1); + double x2 = cos(t1 + dt); + double y2 = sin(t1 + dt); + + double p1x = cx + rx * cosPhi * x1 - ry * sinPhi * y1; + double p1y = cy + rx * sinPhi * x1 + ry * cosPhi * y1; + double p2x = cx + rx * cosPhi * x2 - ry * sinPhi * y2; + double p2y = cy + rx * sinPhi * x2 + ry * cosPhi * y2; + + double d1x = -rx * cosPhi * y1 - ry * sinPhi * x1; + double d1y = -rx * sinPhi * y1 + ry * cosPhi * x1; + double d2x = -rx * cosPhi * y2 - ry * sinPhi * x2; + double d2y = -rx * sinPhi * y2 + ry * cosPhi * x2; + + CGPathAddCurveToPoint(path, NULL, (CGFloat)(p1x + alpha * d1x), (CGFloat)(p1y + alpha * d1y), + (CGFloat)(p2x - alpha * d2x), (CGFloat)(p2y - alpha * d2y), (CGFloat)p2x, (CGFloat)p2y); +} + +// Endpoint -> center parameterization, then split into <=90 degree bezier arcs. +static void ENRMAppendArc(CGMutablePathRef path, double x0, double y0, double rx, double ry, double xAxisRotationDeg, + BOOL largeArc, BOOL sweep, double x, double y) +{ + if (rx == 0.0 || ry == 0.0) { + CGPathAddLineToPoint(path, NULL, (CGFloat)x, (CGFloat)y); + return; + } + + rx = fabs(rx); + ry = fabs(ry); + double phi = xAxisRotationDeg * M_PI / 180.0; + double cosPhi = cos(phi); + double sinPhi = sin(phi); + + double dx = (x0 - x) / 2.0; + double dy = (y0 - y) / 2.0; + double x1p = cosPhi * dx + sinPhi * dy; + double y1p = -sinPhi * dx + cosPhi * dy; + + // Scale up the radii if they are too small to span the endpoints. + double lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); + if (lambda > 1.0) { + double scale = sqrt(lambda); + rx *= scale; + ry *= scale; + } + + double num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p; + double den = rx * rx * y1p * y1p + ry * ry * x1p * x1p; + double factor = den == 0.0 ? 0.0 : sqrt(fmax(0.0, num / den)); + if (largeArc == sweep) { + factor = -factor; + } + + double cxp = factor * (rx * y1p / ry); + double cyp = factor * (-ry * x1p / rx); + double cx = cosPhi * cxp - sinPhi * cyp + (x0 + x) / 2.0; + double cy = sinPhi * cxp + cosPhi * cyp + (y0 + y) / 2.0; + + double startAngle = atan2((y1p - cyp) / ry, (x1p - cxp) / rx); + double endAngle = atan2((-y1p - cyp) / ry, (-x1p - cxp) / rx); + double delta = endAngle - startAngle; + + if (!sweep && delta > 0.0) { + delta -= 2.0 * M_PI; + } else if (sweep && delta < 0.0) { + delta += 2.0 * M_PI; + } + + int segments = (int)ceil(fabs(delta) / (M_PI / 2.0)); + if (segments < 1) { + segments = 1; + } + double segDelta = delta / segments; + double angle = startAngle; + for (int i = 0; i < segments; i++) { + ENRMArcSegment(path, cx, cy, rx, ry, phi, angle, segDelta); + angle += segDelta; + } +} + +CGPathRef ENRMCreateCGPathFromSVGPath(NSString *pathData) +{ + if (pathData.length == 0) { + return NULL; + } + + NSData *utf8 = [pathData dataUsingEncoding:NSUTF8StringEncoding]; + ENRMScanner scanner = {.chars = (const char *)utf8.bytes, .length = utf8.length, .index = 0}; + ENRMScanner *s = &scanner; + + CGMutablePathRef path = CGPathCreateMutable(); + double cx = 0, cy = 0; // current point + double sx = 0, sy = 0; // subpath start + double lastCx = 0, lastCy = 0; // last cubic control point (for S/s) + double lastQx = 0, lastQy = 0; // last quadratic control point (for T/t) + char lastCommand = 0; + char command = 0; + + while (s->index < s->length) { + ENRMScanSkipSeparators(s); + if (s->index >= s->length) { + break; + } + + char c = s->chars[s->index]; + if (ENRMScanIsCommand(c)) { + command = c; + s->index++; + } else if (command == 0) { + break; // malformed: data before any command + } else if (command == 'M') { + command = 'L'; // implicit lineto for repeated M coordinate pairs + } else if (command == 'm') { + command = 'l'; + } + + switch (command) { + case 'M': + case 'm': { + double nx, ny; + if (!ENRMScanNumber(s, &nx) || !ENRMScanNumber(s, &ny)) { + goto done; + } + if (command == 'm') { + nx += cx; + ny += cy; + } + cx = nx; + cy = ny; + sx = cx; + sy = cy; + CGPathMoveToPoint(path, NULL, (CGFloat)cx, (CGFloat)cy); + break; + } + case 'L': + case 'l': { + double nx, ny; + if (!ENRMScanNumber(s, &nx) || !ENRMScanNumber(s, &ny)) { + goto done; + } + if (command == 'l') { + nx += cx; + ny += cy; + } + cx = nx; + cy = ny; + CGPathAddLineToPoint(path, NULL, (CGFloat)cx, (CGFloat)cy); + break; + } + case 'H': + case 'h': { + double nx; + if (!ENRMScanNumber(s, &nx)) { + goto done; + } + cx = command == 'h' ? cx + nx : nx; + CGPathAddLineToPoint(path, NULL, (CGFloat)cx, (CGFloat)cy); + break; + } + case 'V': + case 'v': { + double ny; + if (!ENRMScanNumber(s, &ny)) { + goto done; + } + cy = command == 'v' ? cy + ny : ny; + CGPathAddLineToPoint(path, NULL, (CGFloat)cx, (CGFloat)cy); + break; + } + case 'C': + case 'c': { + double c1x, c1y, c2x, c2y, nx, ny; + if (!ENRMScanNumber(s, &c1x) || !ENRMScanNumber(s, &c1y) || !ENRMScanNumber(s, &c2x) || + !ENRMScanNumber(s, &c2y) || !ENRMScanNumber(s, &nx) || !ENRMScanNumber(s, &ny)) { + goto done; + } + if (command == 'c') { + c1x += cx; + c1y += cy; + c2x += cx; + c2y += cy; + nx += cx; + ny += cy; + } + CGPathAddCurveToPoint(path, NULL, (CGFloat)c1x, (CGFloat)c1y, (CGFloat)c2x, (CGFloat)c2y, (CGFloat)nx, + (CGFloat)ny); + lastCx = c2x; + lastCy = c2y; + cx = nx; + cy = ny; + break; + } + case 'S': + case 's': { + double c2x, c2y, nx, ny; + if (!ENRMScanNumber(s, &c2x) || !ENRMScanNumber(s, &c2y) || !ENRMScanNumber(s, &nx) || + !ENRMScanNumber(s, &ny)) { + goto done; + } + if (command == 's') { + c2x += cx; + c2y += cy; + nx += cx; + ny += cy; + } + double c1x = cx, c1y = cy; + if (lastCommand == 'C' || lastCommand == 'c' || lastCommand == 'S' || lastCommand == 's') { + c1x = 2 * cx - lastCx; + c1y = 2 * cy - lastCy; + } + CGPathAddCurveToPoint(path, NULL, (CGFloat)c1x, (CGFloat)c1y, (CGFloat)c2x, (CGFloat)c2y, (CGFloat)nx, + (CGFloat)ny); + lastCx = c2x; + lastCy = c2y; + cx = nx; + cy = ny; + break; + } + case 'Q': + case 'q': { + double c1x, c1y, nx, ny; + if (!ENRMScanNumber(s, &c1x) || !ENRMScanNumber(s, &c1y) || !ENRMScanNumber(s, &nx) || + !ENRMScanNumber(s, &ny)) { + goto done; + } + if (command == 'q') { + c1x += cx; + c1y += cy; + nx += cx; + ny += cy; + } + CGPathAddQuadCurveToPoint(path, NULL, (CGFloat)c1x, (CGFloat)c1y, (CGFloat)nx, (CGFloat)ny); + lastQx = c1x; + lastQy = c1y; + cx = nx; + cy = ny; + break; + } + case 'T': + case 't': { + double nx, ny; + if (!ENRMScanNumber(s, &nx) || !ENRMScanNumber(s, &ny)) { + goto done; + } + if (command == 't') { + nx += cx; + ny += cy; + } + double c1x = cx, c1y = cy; + if (lastCommand == 'Q' || lastCommand == 'q' || lastCommand == 'T' || lastCommand == 't') { + c1x = 2 * cx - lastQx; + c1y = 2 * cy - lastQy; + } + CGPathAddQuadCurveToPoint(path, NULL, (CGFloat)c1x, (CGFloat)c1y, (CGFloat)nx, (CGFloat)ny); + lastQx = c1x; + lastQy = c1y; + cx = nx; + cy = ny; + break; + } + case 'A': + case 'a': { + double rx, ry, rot, laf, sf, nx, ny; + if (!ENRMScanNumber(s, &rx) || !ENRMScanNumber(s, &ry) || !ENRMScanNumber(s, &rot) || + !ENRMScanNumber(s, &laf) || !ENRMScanNumber(s, &sf) || !ENRMScanNumber(s, &nx) || !ENRMScanNumber(s, &ny)) { + goto done; + } + if (command == 'a') { + nx += cx; + ny += cy; + } + ENRMAppendArc(path, cx, cy, rx, ry, rot, laf != 0.0, sf != 0.0, nx, ny); + cx = nx; + cy = ny; + break; + } + case 'Z': + case 'z': { + CGPathCloseSubpath(path); + cx = sx; + cy = sy; + break; + } + default: + goto done; + } + + lastCommand = command; + } + +done: + return path; +} diff --git a/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.m b/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.m index 06f100e8d..822a27583 100644 --- a/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.m +++ b/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.m @@ -39,7 +39,7 @@ [currentTextNodes removeAllObjects]; } [segments addObject:[ENRMCodeBlockSegment segmentWithCodeBlockNode:child]]; - } else if (child.type == MarkdownNodeTypeBlockquote) { + } else if (child.type == MarkdownNodeTypeBlockquote || child.type == MarkdownNodeTypeAdmonition) { if (currentTextNodes.count > 0) { [segments addObject:[ENRMTextSegment segmentWithNodes:[currentTextNodes copy]]]; [currentTextNodes removeAllObjects]; diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h index 89ff9e9c8..b73121c36 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h @@ -253,6 +253,11 @@ NS_ASSUME_NONNULL_BEGIN - (void)setBlockquoteBorderRadius:(CGFloat)newValue; - (CGFloat)blockquotePadding; - (void)setBlockquotePadding:(CGFloat)newValue; +// Per-admonition-type colors (keyed by "note"/"tip"/"important"/"warning"/"caution"). +- (void)setAdmonitionColors:(NSDictionary *)colors + backgroundColors:(NSDictionary *)backgroundColors; +- (RCTUIColor *)admonitionColorForType:(NSString *)type; +- (RCTUIColor *)admonitionBackgroundColorForType:(NSString *)type; // List style properties (combined for both ordered and unordered lists) - (CGFloat)listStyleFontSize; - (void)setListStyleFontSize:(CGFloat)newValue; diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm index cabaa0873..750ae3c21 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm @@ -161,6 +161,9 @@ @implementation StyleConfig { RCTUIColor *_blockquoteBackgroundColor; CGFloat _blockquoteBorderRadius; CGFloat _blockquotePadding; + // Per-admonition-type colors keyed by type ("note"/"tip"/…). Empty until set. + NSDictionary *_admonitionColors; + NSDictionary *_admonitionBackgroundColors; ENRMFontSlot *_blockquoteFont; // List style properties (combined for both ordered and unordered lists) CGFloat _listStyleFontSize; @@ -286,6 +289,8 @@ - (instancetype)init _primaryFont, _paragraphFont, _h1Font, _h2Font, _h3Font, _h4Font, _h5Font, _h6Font, _listMarkerFont, _listStyleFont, _codeBlockFont, _blockquoteFont, _tableFont, _tableHeaderFont ]; + _admonitionColors = @{}; + _admonitionBackgroundColors = @{}; return self; } @@ -441,6 +446,8 @@ - (id)copyWithZone:(NSZone *)zone copy->_blockquoteBackgroundColor = [_blockquoteBackgroundColor copy]; copy->_blockquoteBorderRadius = _blockquoteBorderRadius; copy->_blockquotePadding = _blockquotePadding; + copy->_admonitionColors = [_admonitionColors copy]; + copy->_admonitionBackgroundColors = [_admonitionBackgroundColors copy]; copy->_listStyleFontSize = _listStyleFontSize; copy->_listStyleFontFamily = [_listStyleFontFamily copy]; copy->_listStyleFontWeight = [_listStyleFontWeight copy]; @@ -1761,6 +1768,28 @@ - (void)setBlockquotePadding:(CGFloat)newValue _blockquotePadding = newValue; } +- (void)setAdmonitionColors:(NSDictionary *)colors + backgroundColors:(NSDictionary *)backgroundColors +{ + _admonitionColors = [colors copy]; + _admonitionBackgroundColors = [backgroundColors copy]; +} + +// The tint (border + title + icon) for an admonition type; falls back to the +// base blockquote border color if the type was never configured. +- (RCTUIColor *)admonitionColorForType:(NSString *)type +{ + RCTUIColor *color = _admonitionColors[type]; + return color ?: _blockquoteBorderColor; +} + +// The callout fill for an admonition type; clear (no fill) when unset. +- (RCTUIColor *)admonitionBackgroundColorForType:(NSString *)type +{ + RCTUIColor *color = _admonitionBackgroundColors[type]; + return color ?: [RCTUIColor clearColor]; +} + // List style properties (combined for both ordered and unordered lists) - (CGFloat)listStyleFontSize { diff --git a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h index 03c8db575..57500b393 100644 --- a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +++ b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h @@ -491,6 +491,36 @@ BOOL applyMarkdownStyleToConfig(StyleConfig *config, const MarkdownStyle &newSty changed = YES; } + const auto &newAdm = newStyle.blockquote.admonitions; + const auto &oldAdm = oldStyle.blockquote.admonitions; + BOOL admonitionsChanged = + newAdm.note.color != oldAdm.note.color || newAdm.note.backgroundColor != oldAdm.note.backgroundColor || + newAdm.tip.color != oldAdm.tip.color || newAdm.tip.backgroundColor != oldAdm.tip.backgroundColor || + newAdm.important.color != oldAdm.important.color || + newAdm.important.backgroundColor != oldAdm.important.backgroundColor || + newAdm.warning.color != oldAdm.warning.color || + newAdm.warning.backgroundColor != oldAdm.warning.backgroundColor || + newAdm.caution.color != oldAdm.caution.color || newAdm.caution.backgroundColor != oldAdm.caution.backgroundColor; + if (admonitionsChanged) { + RCTUIColor *clear = [RCTUIColor clearColor]; + NSDictionary *admonitionColors = @{ + @"note" : RCTUIColorFromSharedColor(newAdm.note.color) ?: clear, + @"tip" : RCTUIColorFromSharedColor(newAdm.tip.color) ?: clear, + @"important" : RCTUIColorFromSharedColor(newAdm.important.color) ?: clear, + @"warning" : RCTUIColorFromSharedColor(newAdm.warning.color) ?: clear, + @"caution" : RCTUIColorFromSharedColor(newAdm.caution.color) ?: clear, + }; + NSDictionary *admonitionBackgroundColors = @{ + @"note" : RCTUIColorFromSharedColor(newAdm.note.backgroundColor) ?: clear, + @"tip" : RCTUIColorFromSharedColor(newAdm.tip.backgroundColor) ?: clear, + @"important" : RCTUIColorFromSharedColor(newAdm.important.backgroundColor) ?: clear, + @"warning" : RCTUIColorFromSharedColor(newAdm.warning.backgroundColor) ?: clear, + @"caution" : RCTUIColorFromSharedColor(newAdm.caution.backgroundColor) ?: clear, + }; + [config setAdmonitionColors:admonitionColors backgroundColors:admonitionBackgroundColors]; + changed = YES; + } + // ── Link ─────────────────────────────────────────────────────────────────── if (newStyle.link.fontFamily != oldStyle.link.fontFamily) { diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index e69c3644d..f0b6d6ab7 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -24,6 +24,19 @@ interface HeadingStyleInternal extends BaseBlockStyleInternal { textAlign: string; } +interface AdmonitionColorsInternal { + color: ColorValue; + backgroundColor: ColorValue; +} + +interface AdmonitionsStyleInternal { + note: AdmonitionColorsInternal; + tip: AdmonitionColorsInternal; + important: AdmonitionColorsInternal; + warning: AdmonitionColorsInternal; + caution: AdmonitionColorsInternal; +} + interface BlockquoteStyleInternal extends BaseBlockStyleInternal { borderColor: ColorValue; borderWidth: CodegenTypes.Float; @@ -31,6 +44,7 @@ interface BlockquoteStyleInternal extends BaseBlockStyleInternal { backgroundColor: ColorValue; borderRadius: CodegenTypes.Float; padding: CodegenTypes.Float; + admonitions: AdmonitionsStyleInternal; } interface ListStyleInternal extends BaseBlockStyleInternal { @@ -349,6 +363,12 @@ export interface Md4cFlagsInternal { * @default false */ hardSoftBreaks: boolean; + /** + * Enable GitHub-style admonitions/alerts extension. + * Forced off for `flavor="commonmark"`. + * @default true + */ + admonitions: boolean; } interface StreamingConfigInternal { diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts index e011cfc77..25f500143 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts @@ -24,6 +24,19 @@ interface HeadingStyleInternal extends BaseBlockStyleInternal { textAlign: string; } +interface AdmonitionColorsInternal { + color: ColorValue; + backgroundColor: ColorValue; +} + +interface AdmonitionsStyleInternal { + note: AdmonitionColorsInternal; + tip: AdmonitionColorsInternal; + important: AdmonitionColorsInternal; + warning: AdmonitionColorsInternal; + caution: AdmonitionColorsInternal; +} + interface BlockquoteStyleInternal extends BaseBlockStyleInternal { borderColor: ColorValue; borderWidth: CodegenTypes.Float; @@ -31,6 +44,7 @@ interface BlockquoteStyleInternal extends BaseBlockStyleInternal { backgroundColor: ColorValue; borderRadius: CodegenTypes.Float; padding: CodegenTypes.Float; + admonitions: AdmonitionsStyleInternal; } interface ListStyleInternal extends BaseBlockStyleInternal { @@ -350,6 +364,12 @@ export interface Md4cFlagsInternal { * @default false */ hardSoftBreaks: boolean; + /** + * Enable GitHub-style admonitions/alerts extension. + * Forced off for `flavor="commonmark"`. + * @default true + */ + admonitions: boolean; } interface StreamingConfigInternal { diff --git a/packages/react-native-enriched-markdown/src/admonitionDefaults.ts b/packages/react-native-enriched-markdown/src/admonitionDefaults.ts new file mode 100644 index 000000000..cf7e7fe1a --- /dev/null +++ b/packages/react-native-enriched-markdown/src/admonitionDefaults.ts @@ -0,0 +1,58 @@ +import { normalizeColor } from './styleUtils'; +import type { AdmonitionsStyle } from './types/MarkdownStyle'; + +// The five GitHub-flavored admonition/alert types, in the order md4c reports +// them (see MD_ADMONITION_TAGS). The `admonitionType` attribute emitted by the +// parser is always one of these lowercase strings. +export const ADMONITION_TYPES = [ + 'note', + 'tip', + 'important', + 'warning', + 'caution', +] as const; + +export type AdmonitionType = (typeof ADMONITION_TYPES)[number]; + +// GitHub alert palette. Each `color` tints the left accent bar, the title label +// and the icon for that type. Backgrounds default to transparent (no fill) per +// the issue; users opt into a tint via markdownStyle.blockquote.admonitions. +const ADMONITION_COLOR_DEFAULTS: Record = { + note: '#0969DA', + tip: '#1A7F37', + important: '#8250DF', + warning: '#9A6700', + caution: '#CF222E', +}; + +export interface ResolvedAdmonitionColors { + color: string; + backgroundColor: string; +} + +export type ResolvedAdmonitions = Record< + AdmonitionType, + ResolvedAdmonitionColors +>; + +// Merge user overrides over the GitHub defaults and normalize every color, so +// native/web receive a complete, concrete per-type palette. Mirrors the +// linkVariants resolution: `color` falls back to the type default, and an empty +// or omitted `backgroundColor` resolves to transparent (drawn as no fill). +export function resolveAdmonitionColors( + user: AdmonitionsStyle | undefined +): ResolvedAdmonitions { + const transparent = normalizeColor('transparent')! as string; + const result = {} as ResolvedAdmonitions; + for (const type of ADMONITION_TYPES) { + const override = user?.[type]; + result[type] = { + color: ((override?.color ? normalizeColor(override.color) : undefined) ?? + normalizeColor(ADMONITION_COLOR_DEFAULTS[type])!) as string, + backgroundColor: (override?.backgroundColor + ? (normalizeColor(override.backgroundColor) ?? transparent) + : transparent) as string, + }; + } + return result; +} diff --git a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx index 7d4b8e517..894a82f99 100644 --- a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx +++ b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx @@ -108,6 +108,7 @@ const defaultMd4cFlags: Md4cFlags = { latexMath: true, highlight: false, hardSoftBreaks: false, + admonitions: true, }; export const EnrichedMarkdownText = ({ @@ -157,8 +158,12 @@ export const EnrichedMarkdownText = ({ latexMath: md4cFlags.latexMath ?? true, highlight: md4cFlags.highlight ?? false, hardSoftBreaks: md4cFlags.hardSoftBreaks ?? false, + // Admonitions are a GitHub-flavor feature; force them off in commonmark so + // `> [!NOTE]` renders as a plain blockquote. + admonitions: + flavor === 'github' ? (md4cFlags.admonitions ?? true) : false, }), - [md4cFlags] + [md4cFlags, flavor] ); const contextMenuCallbacksRef = useRef< diff --git a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts index 8c57c55b5..475d0190d 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts @@ -8,6 +8,7 @@ import type { } from './types/MarkdownStyleInternal'; import { isStyleEqual, normalizeColor, mergeSubStyle } from './styleUtils'; import { normalizeLinkVariantEntries } from './linkVariantUtils'; +import { resolveAdmonitionColors } from './admonitionDefaults'; import { DEFAULT_HEADING_FONT_WEIGHT, HEADING_DEFAULTS, @@ -145,6 +146,7 @@ const DEFAULT_NORMALIZED_STYLE = Object.freeze({ backgroundColor: normalizeColor('#F9FAFB')!, borderRadius: 0, padding: 0, + admonitions: resolveAdmonitionColors(undefined), }, list: { fontSize: 16, @@ -361,6 +363,13 @@ export const normalizeMarkdownStyle = ( paragraphColor; } + // Admonition colors are nested two levels deep (blockquote.admonitions.), + // deeper than mergeSubStyle merges, so resolve the full per-type palette here. + (result.blockquote as MarkdownStyleInternal['blockquote']).admonitions = + resolveAdmonitionColors( + style.blockquote?.admonitions + ) as MarkdownStyleInternal['blockquote']['admonitions']; + const codeBlock = result.codeBlock as MarkdownStyleInternal['codeBlock']; const userSyntaxColors = style.codeBlock?.syntaxColors as | Record diff --git a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts index 73fe1eb54..d465ca5c3 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts @@ -7,6 +7,7 @@ import type { } from './types/MarkdownStyleInternal'; import { isStyleEqual, mergeSubStyle } from './styleUtils'; import { normalizeLinkVariantEntries } from './linkVariantUtils'; +import { resolveAdmonitionColors } from './admonitionDefaults'; import { DEFAULT_HEADING_FONT_WEIGHT, HEADING_DEFAULTS, @@ -94,6 +95,7 @@ const DEFAULT_NORMALIZED_STYLE: MarkdownStyleInternal = Object.freeze({ backgroundColor: '#F9FAFB', borderRadius: 0, padding: 0, + admonitions: resolveAdmonitionColors(undefined), }, list: { fontSize: 16, @@ -310,6 +312,13 @@ export const normalizeMarkdownStyle = ( paragraphColor; } + // Admonition colors are nested two levels deep (blockquote.admonitions.), + // deeper than mergeSubStyle merges, so resolve the full per-type palette here. + (result.blockquote as MarkdownStyleInternal['blockquote']).admonitions = + resolveAdmonitionColors( + style.blockquote?.admonitions + ) as MarkdownStyleInternal['blockquote']['admonitions']; + // The public API exposes `operator`, but the internal token is `operatorColor` // (`operator` is reserved in the generated C++ struct). Remap it after merge. const syntaxColors = ( diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts index 11158ab13..391557d85 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts @@ -20,6 +20,36 @@ interface HeadingStyle extends BaseBlockStyle { textAlign?: TextAlign; } +/** + * Per-type color overrides for a GitHub admonition/alert. + */ +export interface AdmonitionColors { + /** + * Tints the left accent bar, the title label and the icon for this type. + * Defaults to the GitHub palette (note=blue, tip=green, important=purple, + * warning=amber, caution=red). + */ + color?: string; + /** + * Fills the callout background. Omitted or empty means transparent (no fill). + */ + backgroundColor?: string; +} + +/** + * Color theming for GitHub admonitions/alerts, keyed by type. Nested under + * `blockquote` because admonitions inherit the blockquote geometry (borderWidth, + * gapWidth, padding, borderRadius, font, spacing) and only override colors. + * Requires `flavor="github"` and `md4cFlags.admonitions` enabled (both default on). + */ +export interface AdmonitionsStyle { + note?: AdmonitionColors; + tip?: AdmonitionColors; + important?: AdmonitionColors; + warning?: AdmonitionColors; + caution?: AdmonitionColors; +} + interface BlockquoteStyle extends BaseBlockStyle { borderColor?: string; borderWidth?: number; @@ -27,6 +57,7 @@ interface BlockquoteStyle extends BaseBlockStyle { backgroundColor?: string; borderRadius?: number; padding?: number; + admonitions?: AdmonitionsStyle; } interface ListStyle extends BaseBlockStyle { @@ -397,4 +428,14 @@ export interface Md4cFlags { * @default false */ hardSoftBreaks?: boolean; + /** + * Enable GitHub-style admonitions/alerts (`> [!NOTE]`, `> [!TIP]`, + * `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`). + * When enabled, such blockquotes render as themed callouts with an icon + + * title header (see `markdownStyle.blockquote.admonitions`). + * Only takes effect with `flavor="github"`; forced off for `flavor="commonmark"`, + * where the syntax renders as a plain blockquote. + * @default true + */ + admonitions?: boolean; } diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts index 6be91c078..025f5dac0 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts @@ -37,6 +37,19 @@ interface HeadingStyleInternal extends BaseBlockStyleInternal { textAlign: BlockTextAlign; } +export interface AdmonitionColorsInternal { + color: string; + backgroundColor: string; +} + +export interface AdmonitionsStyleInternal { + note: AdmonitionColorsInternal; + tip: AdmonitionColorsInternal; + important: AdmonitionColorsInternal; + warning: AdmonitionColorsInternal; + caution: AdmonitionColorsInternal; +} + interface BlockquoteStyleInternal extends BaseBlockStyleInternal { borderColor: string; borderWidth: number; @@ -44,6 +57,7 @@ interface BlockquoteStyleInternal extends BaseBlockStyleInternal { backgroundColor: string; borderRadius: number; padding: number; + admonitions: AdmonitionsStyleInternal; } interface ListStyleInternal extends BaseBlockStyleInternal { diff --git a/packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx b/packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx index 918911f77..786b56029 100644 --- a/packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx +++ b/packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx @@ -45,6 +45,7 @@ export const EnrichedMarkdownText = ({ subscript = false, highlight = false, hardSoftBreaks = false, + admonitions = true, } = md4cFlags; useEffect(() => { @@ -60,6 +61,7 @@ export const EnrichedMarkdownText = ({ subscript, highlight, hardSoftBreaks, + admonitions, }), katexPromise, ]) @@ -96,6 +98,7 @@ export const EnrichedMarkdownText = ({ subscript, highlight, hardSoftBreaks, + admonitions, ]); const callbacks = useMemo( diff --git a/packages/react-native-enriched-markdown/src/web/parseMarkdown.ts b/packages/react-native-enriched-markdown/src/web/parseMarkdown.ts index 8ec36e4a5..0973220c7 100644 --- a/packages/react-native-enriched-markdown/src/web/parseMarkdown.ts +++ b/packages/react-native-enriched-markdown/src/web/parseMarkdown.ts @@ -8,7 +8,8 @@ type ParseFn = ( superscript: number, subscript: number, highlight: number, - hardSoftBreaks: number + hardSoftBreaks: number, + admonitions: number ) => string; // Caching the Promise (not the resolved value) means concurrent callers share @@ -30,6 +31,7 @@ function initializeParser(): Promise { 'number', 'number', 'number', + 'number', ]) ) .catch((error) => { @@ -58,6 +60,7 @@ export async function parseMarkdown( subscript = false, highlight = false, hardSoftBreaks = false, + admonitions = true, }: Md4cFlags = {} ): Promise { const parse = await initializeParser(); @@ -70,7 +73,8 @@ export async function parseMarkdown( superscript ? 1 : 0, subscript ? 1 : 0, highlight ? 1 : 0, - hardSoftBreaks ? 1 : 0 + hardSoftBreaks ? 1 : 0, + admonitions ? 1 : 0 ) ); diff --git a/packages/react-native-enriched-markdown/src/web/renderers/BlockRenderers.tsx b/packages/react-native-enriched-markdown/src/web/renderers/BlockRenderers.tsx index 2d5247827..d31847e85 100644 --- a/packages/react-native-enriched-markdown/src/web/renderers/BlockRenderers.tsx +++ b/packages/react-native-enriched-markdown/src/web/renderers/BlockRenderers.tsx @@ -1,7 +1,14 @@ +import type { CSSProperties } from 'react'; import { extractNodeText, filenameFromUrl } from '../utils'; import type { RendererProps, RendererMap } from '../types'; import { toHeadingLevel } from '../styles'; import { KaTeXRenderer } from './KaTeXRenderer'; +import { + ADMONITION_ICON_PATHS, + ADMONITION_ICON_VIEWBOX, + ADMONITION_TITLES, +} from './admonitionIcons'; +import type { AdmonitionType } from '../../admonitionDefaults'; function ParagraphRenderer({ node, @@ -13,7 +20,7 @@ function ParagraphRenderer({ node.children?.length === 1 && node.children[0]?.type === 'Image'; if (isImageOnly) return <>{renderChildren(node)}; - if (parentType === 'Blockquote') { + if (parentType === 'Blockquote' || parentType === 'Admonition') { return

{renderChildren(node)}

; } @@ -35,6 +42,52 @@ function BlockquoteRenderer({ node, styles, renderChildren }: RendererProps) { ); } +function AdmonitionRenderer({ + node, + style, + styles, + renderChildren, +}: RendererProps) { + const type = (node.attributes?.admonitionType ?? 'note') as AdmonitionType; + const blockquote = style.blockquote; + const colors = blockquote.admonitions[type] ?? blockquote.admonitions.note; + const tint = colors.color; + const iconSize = Math.ceil(blockquote.fontSize); + // Reuse the blockquote box geometry; override the accent bar + fill per type. + const boxStyle: CSSProperties = { + ...styles.blockquote, + borderInlineStart: `${blockquote.borderWidth}px solid ${tint}`, + backgroundColor: colors.backgroundColor, + }; + const headerStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: Math.round(iconSize * 0.4), + marginBottom: Math.round(blockquote.fontSize * 0.4), + color: tint, + fontWeight: 'bold', + fontSize: blockquote.fontSize, + lineHeight: 1.2, + }; + return ( +
+
+ + {ADMONITION_TITLES[type]} +
+ {renderChildren(node)} +
+ ); +} + function CodeBlockRenderer({ node, styles, renderChildren }: RendererProps) { const language = node.attributes?.language; const label = language ? `Code block: ${language}` : 'Code block'; @@ -83,6 +136,7 @@ export const blockRenderers: RendererMap = { Paragraph: ParagraphRenderer, Heading: HeadingRenderer, Blockquote: BlockquoteRenderer, + Admonition: AdmonitionRenderer, CodeBlock: CodeBlockRenderer, ThematicBreak: ThematicBreakRenderer, Image: ImageRenderer, diff --git a/packages/react-native-enriched-markdown/src/web/renderers/admonitionIcons.ts b/packages/react-native-enriched-markdown/src/web/renderers/admonitionIcons.ts new file mode 100644 index 000000000..d73d0ec32 --- /dev/null +++ b/packages/react-native-enriched-markdown/src/web/renderers/admonitionIcons.ts @@ -0,0 +1,37 @@ +import type { AdmonitionType } from '../../admonitionDefaults'; + +// GitHub alert icons, taken verbatim from @primer/octicons (16x16 viewBox): +// note=info, tip=light-bulb, important=report, warning=alert, caution=stop. +// +// These `d` path strings are the single source of truth for the admonition +// header icon and MUST stay byte-identical across the three renderers so the +// glyph looks the same everywhere: +// - iOS: ios/segments/ENRMAdmonitionIcons.m +// - Android: android/.../segments/AdmonitionIcons.kt +// - Web: this file +// Parity is enforced by __tests__/admonition-icons-parity.test.ts (jest, run in +// CI), which reads all three files and fails on any drift. That guard only works +// while all three copies live under packages/react-native-enriched-markdown/**; +// moving a copy out of this package requires re-homing the guard (or adding a +// native-side check) so the copies can't silently diverge. +export const ADMONITION_ICON_VIEWBOX = 16; + +export const ADMONITION_ICON_PATHS: Record = { + note: 'M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z', + tip: 'M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z', + important: + 'M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z', + warning: + 'M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z', + caution: + 'M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z', +}; + +// GitHub renders the type name capitalized in the header (Note, Tip, ...). +export const ADMONITION_TITLES: Record = { + note: 'Note', + tip: 'Tip', + important: 'Important', + warning: 'Warning', + caution: 'Caution', +}; diff --git a/packages/react-native-enriched-markdown/src/web/types.ts b/packages/react-native-enriched-markdown/src/web/types.ts index 31a3a0663..909b45c46 100644 --- a/packages/react-native-enriched-markdown/src/web/types.ts +++ b/packages/react-native-enriched-markdown/src/web/types.ts @@ -38,7 +38,8 @@ export type NodeType = | 'TableHeaderCell' | 'TableCell' | 'LatexMathInline' - | 'LatexMathDisplay'; + | 'LatexMathDisplay' + | 'Admonition'; export interface NodeAttributes { level?: string; @@ -55,6 +56,8 @@ export interface NodeAttributes { colCount?: string; headRowCount?: string; bodyRowCount?: string; + /** "note"/"tip"/"important"/"warning"/"caution" for a MD_BLOCK_ADMONITION. */ + admonitionType?: string; align?: 'left' | 'center' | 'right' | 'default'; } diff --git a/packages/react-native-enriched-markdown/src/web/wasm/md4c.js b/packages/react-native-enriched-markdown/src/web/wasm/md4c.js index 18e4a9aac..7abdba4ce 100644 Binary files a/packages/react-native-enriched-markdown/src/web/wasm/md4c.js and b/packages/react-native-enriched-markdown/src/web/wasm/md4c.js differ