diff --git a/CLAUDE.md b/CLAUDE.md index 3bb7221dd0..bb0225298d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,34 @@ channel = rest.channels.get('channelName') ``` +### Line Highlighting + +Add `highlight="..."` to a code fence to highlight specific lines. Supports individual lines and ranges, with optional `+` (addition/green) or `-` (removal/orange) prefixes. Unprefixed lines get a neutral blue highlight. + +```mdx + +```javascript highlight="+1-2,3,-5-6,7-8" +const client = new Ably.Realtime('your-api-key'); +const channel = client.channels.get('my-channel'); +channel.unsubscribe(); +// This line has no highlight +console.log('done'); +console.log('highlighted'); +console.log('neutral range start'); +console.log('neutral range end'); +``` + +``` + +Syntax: +- `3`: highlight line 3 (blue) +- `1-6`: highlight lines 1 through 6 (blue) +- `+3` or `+1-6`: addition highlight (green) +- `-3` or `-1-6`: removal highlight (orange) +- Comma-separated for multiple specs + +Note: The `highlight` meta is stripped from code fences when compiling markdown for LLMs. + ### Variables in Codeblocks - `{{API_KEY}}`: Demo API key or user's key selector diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b287138ec..b1cd4d1fe0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,32 @@ To use nested codeblocks when describing features that are available to the real ``` ``` +### Line Highlighting + +Add `highlight="..."` after the language identifier to highlight specific lines. Supports individual lines and ranges, with optional `+` (addition/green) or `-` (removal/orange) prefixes. Unprefixed lines get a neutral blue highlight. + +```plaintext + ```javascript highlight="+1-2,3,-5-6,7-8" + const client = new Ably.Realtime('your-api-key'); + const channel = client.channels.get('my-channel'); + channel.unsubscribe(); + // This line has no highlight + console.log('done'); + console.log('highlighted'); + console.log('neutral range start'); + console.log('neutral range end'); + ``` +``` + +Syntax: +- `3`: highlight line 3 (blue) +- `1-6`: highlight lines 1 through 6 (blue) +- `+3` or `+1-6`: addition highlight (green) +- `-3` or `-1-6`: removal highlight (orange) +- Comma-separated for multiple specs + +Note: The `highlight` meta is stripped from code fences when compiling markdown for LLMs. + ### In-line code In-line code should be written between `@` symbols. For example, `the @get()@ method`. diff --git a/data/onPostBuild/transpileMdxToMarkdown.test.ts b/data/onPostBuild/transpileMdxToMarkdown.test.ts index 64fa3ac467..141e14c8f4 100644 --- a/data/onPostBuild/transpileMdxToMarkdown.test.ts +++ b/data/onPostBuild/transpileMdxToMarkdown.test.ts @@ -16,6 +16,7 @@ import { findPrecedingHeadingLevel, transformCodeBlocksWithSubheadings, addLanguageSubheadingsToCodeBlocks, + stripCodeFenceMeta, } from './transpileMdxToMarkdown'; import * as fs from 'fs'; import * as path from 'path'; @@ -749,6 +750,18 @@ const x = 1; expect(output).toBeNull(); }); + it('should strip highlight meta from language identifier', () => { + const input = ` +\`\`\`javascript highlight="2,8" +const x = 1; +\`\`\` +`; + const output = transformCodeBlocksWithSubheadings(input, '####'); + expect(output).toContain('#### Javascript'); + expect(output).not.toContain('highlight'); + expect(output).not.toContain('```javascript'); + }); + it('should handle multiple code blocks', () => { const input = ` \`\`\`javascript @@ -946,4 +959,43 @@ b = 2 expect(output).toContain('#### Python'); }); }); + + describe('stripCodeFenceMeta', () => { + it('should strip highlight meta from code fences', () => { + const input = '```javascript highlight="2,8"\nconst x = 1;\n```'; + const output = stripCodeFenceMeta(input); + expect(output).toBe('```javascript\nconst x = 1;\n```'); + }); + + it('should leave code fences without meta unchanged', () => { + const input = '```javascript\nconst x = 1;\n```'; + const output = stripCodeFenceMeta(input); + expect(output).toBe(input); + }); + + it('should leave bare code fences unchanged', () => { + const input = '```\nconst x = 1;\n```'; + const output = stripCodeFenceMeta(input); + expect(output).toBe(input); + }); + + it('should handle multiple code fences with and without meta', () => { + const input = `\`\`\`javascript highlight="2,8" +const x = 1; +\`\`\` + +\`\`\`python +x = 1 +\`\`\` + +\`\`\`kotlin highlight="1" +val x = 1 +\`\`\``; + const output = stripCodeFenceMeta(input); + expect(output).not.toContain('highlight'); + expect(output).toContain('```javascript\n'); + expect(output).toContain('```python\n'); + expect(output).toContain('```kotlin\n'); + }); + }); }); diff --git a/data/onPostBuild/transpileMdxToMarkdown.ts b/data/onPostBuild/transpileMdxToMarkdown.ts index 84d607267c..6c2f96edba 100644 --- a/data/onPostBuild/transpileMdxToMarkdown.ts +++ b/data/onPostBuild/transpileMdxToMarkdown.ts @@ -62,7 +62,9 @@ function transformCodeBlocksWithSubheadings(innerContent: string, headingPrefix: } // Replace each code block with a subheading followed by the code block (without language in fence) - return innerContent.replace(codeBlockRegex, (_codeBlock, lang, codeContent) => { + return innerContent.replace(codeBlockRegex, (_codeBlock, langWithMeta, codeContent) => { + // Strip any meta string (e.g. highlight="2,8") from the language identifier + const lang = langWithMeta.split(/\s+/)[0]; const displayName = getLanguageDisplayName(lang); return `${headingPrefix} ${displayName}\n\n\`\`\`\n${codeContent}\`\`\``; }); @@ -95,6 +97,15 @@ function addLanguageSubheadingsToCodeBlocks(content: string): string { }); } +/** + * Strip code fence meta strings (e.g. highlight="2,8") from all fenced code blocks, + * keeping only the language identifier. This keeps the compiled markdown clean for LLMs. + */ +function stripCodeFenceMeta(content: string): string { + // Match opening code fences with a language followed by whitespace and meta + return content.replace(/```(\S+)[ \t]+[^\n]+/g, '```$1'); +} + interface MdxNode { parent: { relativeDirectory: string; @@ -578,10 +589,13 @@ function transformMdxToMarkdown( // Stage 12: Replace template variables content = replaceTemplateVariables(content); - // Stage 13: Add language subheadings to code blocks within tags + // Stage 13: Strip code fence meta strings (e.g. highlight="2,8") to keep markdown clean for LLMs + content = stripCodeFenceMeta(content); + + // Stage 14: Add language subheadings to code blocks within tags content = addLanguageSubheadingsToCodeBlocks(content); - // Stage 14: Prepend title as markdown heading + // Stage 15: Prepend title as markdown heading const finalContent = `# ${title}\n\n${intro ? `${intro}\n\n` : ''}${content}`; return { content: finalContent, title, intro }; @@ -713,4 +727,5 @@ export { findPrecedingHeadingLevel, transformCodeBlocksWithSubheadings, addLanguageSubheadingsToCodeBlocks, + stripCodeFenceMeta, }; diff --git a/gatsby-config.ts b/gatsby-config.ts index cbee718aee..cc56590dd8 100644 --- a/gatsby-config.ts +++ b/gatsby-config.ts @@ -1,5 +1,23 @@ import dotenv from 'dotenv'; import remarkGfm from 'remark-gfm'; +import { visit } from 'unist-util-visit'; + +/** + * Remark plugin that preserves the code fence meta string (everything after the + * language) as a `data-meta` attribute on the element. MDX v2 drops the + * meta by default; setting data.hProperties on the MDAST node causes + * mdast-util-to-hast's applyData() to merge it into the HAST element properties, + * which then flow through to JSX props. + */ +const remarkCodeMeta = () => (tree: any) => { + visit(tree, 'code', (node: any) => { + if (node.meta) { + node.data = node.data || {}; + node.data.hProperties = node.data.hProperties || {}; + node.data.hProperties['data-meta'] = node.meta; + } + }); +}; dotenv.config({ path: `.env.${process.env.NODE_ENV}`, @@ -94,6 +112,8 @@ export const plugins = [ remarkPlugins: [ // Add GitHub Flavored Markdown (GFM) support remarkGfm, + // Preserve code fence meta strings (e.g. highlight="...") as data-meta attributes + remarkCodeMeta, ], }, }, diff --git a/package.json b/package.json index 9f599e4b5e..8a9571bcaf 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "validate-llms-txt": "node bin/validate-llms.txt.ts" }, "dependencies": { - "@ably/ui": "17.13.2", + "@ably/ui": "17.14.0-dev.72422c80f2", "@codesandbox/sandpack-react": "^2.20.0", "@codesandbox/sandpack-themes": "^2.0.21", "@gfx/zopfli": "^1.0.15", diff --git a/setupTests.js b/setupTests.js index 3d598cd7aa..d81e46f5a7 100644 --- a/setupTests.js +++ b/setupTests.js @@ -11,7 +11,19 @@ window.ResizeObserver = ResizeObserver; jest.mock('@ably/ui/core/utils/syntax-highlighter', () => ({ highlightSnippet: jest.fn, + LINE_HIGHLIGHT_CLASSES: { + addition: 'code-line-addition', + removal: 'code-line-removal', + highlight: 'code-line-highlight', + }, + parseLineHighlights: (lang) => ({ lang, highlights: {} }), registerDefaultLanguages: jest.fn, + splitHtmlLines: (html) => html.split('\n'), +})); + +jest.mock('@ably/ui/core/Code', () => ({ + __esModule: true, + default: () => null, })); jest.mock('@ably/ui/core/utils/syntax-highlighter-registry', () => ({ diff --git a/src/components/Markdown/CodeBlock.test.tsx b/src/components/Markdown/CodeBlock.test.tsx new file mode 100644 index 0000000000..0920f23383 --- /dev/null +++ b/src/components/Markdown/CodeBlock.test.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { CodeBlock } from './CodeBlock'; + +jest.mock('@ably/ui/core/Icon', () => { + return function MockIcon() { + return ; + }; +}); + +jest.mock('src/components/ButtonWithTooltip', () => ({ + ButtonWithTooltip: ({ children }: any) => , +})); + +jest.mock('src/external-scripts/google-tag-manager/events', () => ({ + copyCodeBlockContentTracker: jest.fn(), +})); + +jest.mock('dompurify', () => ({ + sanitize: (html: string) => html, +})); + +const mockHighlightSnippet = jest.fn(); +const mockParseLineHighlights = jest.fn(); +const mockSplitHtmlLines = jest.fn(); + +jest.mock('@ably/ui/core/utils/syntax-highlighter', () => ({ + highlightSnippet: (...args: any[]) => mockHighlightSnippet(...args), + LINE_HIGHLIGHT_CLASSES: { + addition: 'code-line-addition', + removal: 'code-line-removal', + highlight: 'code-line-highlight', + }, + parseLineHighlights: (...args: any[]) => mockParseLineHighlights(...args), + splitHtmlLines: (...args: any[]) => mockSplitHtmlLines(...args), + registerDefaultLanguages: jest.fn(), +})); + +jest.mock('@ably/ui/core/utils/syntax-highlighter-registry', () => ({ + __esModule: true, + default: [], +})); + +const buildCodeChild = (code: string, language: string, meta?: string) => { + const codeProps: Record = { + className: `language-${language}`, + children: code, + }; + if (meta) { + codeProps['data-meta'] = meta; + } + return ; +}; + +describe('', () => { + beforeEach(() => { + mockHighlightSnippet.mockReset(); + mockParseLineHighlights.mockReset(); + mockSplitHtmlLines.mockReset(); + mockHighlightSnippet.mockReturnValue('highlighted'); + mockSplitHtmlLines.mockReturnValue(['highlighted']); + }); + + it('renders a single element and skips splitHtmlLines when no highlights', () => { + mockParseLineHighlights.mockReturnValue({ lang: 'javascript', highlights: {} }); + + const { container } = render( + {buildCodeChild('const x = 1;', 'javascript')}, + ); + + expect(mockParseLineHighlights).toHaveBeenCalledWith('javascript', undefined); + expect(mockSplitHtmlLines).not.toHaveBeenCalled(); + expect(container.querySelectorAll('code.ui-text-code')).toHaveLength(1); + expect(container.querySelector('.code-line-addition')).toBeNull(); + expect(container.querySelector('.code-line-removal')).toBeNull(); + expect(container.querySelector('.code-line-highlight')).toBeNull(); + }); + + it('passes data-meta to parseLineHighlights', () => { + const meta = 'highlight="+1,2,-3"'; + mockParseLineHighlights.mockReturnValue({ + lang: 'javascript', + highlights: { 1: 'addition', 2: 'highlight', 3: 'removal' }, + }); + mockSplitHtmlLines.mockReturnValue(['line1', 'line2', 'line3']); + + render({buildCodeChild('line1\nline2\nline3', 'javascript', meta)}); + + expect(mockParseLineHighlights).toHaveBeenCalledWith('javascript', meta); + }); + + it('renders per-line with highlight classes when highlights are present', () => { + mockParseLineHighlights.mockReturnValue({ + lang: 'javascript', + highlights: { 1: 'addition', 2: 'highlight', 3: 'removal' }, + }); + mockSplitHtmlLines.mockReturnValue(['line1', 'line2', 'line3']); + + const { container } = render( + + {buildCodeChild('line1\nline2\nline3', 'javascript', 'highlight="+1,2,-3"')} + , + ); + + expect(container.querySelector('.code-line-addition')).not.toBeNull(); + expect(container.querySelector('.code-line-highlight')).not.toBeNull(); + expect(container.querySelector('.code-line-removal')).not.toBeNull(); + + // Both paths wrap content in a single element + expect(container.querySelectorAll('code.ui-text-code')).toHaveLength(1); + }); + + it('does not apply highlight classes when no highlights exist', () => { + mockParseLineHighlights.mockReturnValue({ lang: 'javascript', highlights: {} }); + + const { container } = render( + {buildCodeChild('const x = 1;', 'javascript')}, + ); + + expect(container.querySelector('.code-line-addition')).toBeNull(); + expect(container.querySelector('.code-line-removal')).toBeNull(); + expect(container.querySelector('.code-line-highlight')).toBeNull(); + }); + + it('calls highlightSnippet with correct language and content', () => { + mockParseLineHighlights.mockReturnValue({ lang: 'python', highlights: {} }); + + render({buildCodeChild('print("hi")', 'python')}); + + expect(mockHighlightSnippet).toHaveBeenCalledWith('python', 'print("hi")'); + }); +}); diff --git a/src/components/Markdown/CodeBlock.tsx b/src/components/Markdown/CodeBlock.tsx index 31daf85e38..4261af1db2 100644 --- a/src/components/Markdown/CodeBlock.tsx +++ b/src/components/Markdown/CodeBlock.tsx @@ -1,7 +1,13 @@ import React, { FC, useMemo } from 'react'; import DOMPurify from 'dompurify'; import Icon from '@ably/ui/core/Icon'; -import { highlightSnippet, registerDefaultLanguages } from '@ably/ui/core/utils/syntax-highlighter'; +import { + highlightSnippet, + LINE_HIGHLIGHT_CLASSES, + registerDefaultLanguages, + parseLineHighlights, + splitHtmlLines, +} from '@ably/ui/core/utils/syntax-highlighter'; import languagesRegistry from '@ably/ui/core/utils/syntax-highlighter-registry'; registerDefaultLanguages(languagesRegistry); @@ -10,10 +16,23 @@ import { ButtonWithTooltip } from 'src/components/ButtonWithTooltip'; import { safeWindow } from 'src/utilities'; import { copyCodeBlockContentTracker } from 'src/external-scripts/google-tag-manager/events'; +const sanitize = (html: string) => + DOMPurify.sanitize + ? DOMPurify.sanitize(html, { + // The SVG and Math tags have been used in the past as attack vectors for mXSS, + // but if we really need them should be safe enough to enable. + // This is probably too cautious but we have no need for them at time of writing, so forbidding them is free. + FORBID_TAGS: ['svg', 'math'], + }) + : html; + export const CodeBlock: FC<{ children: React.ReactNode; language: string }> = ({ children, language = 'javascript', }) => { + const meta: string | undefined = (children as React.ReactElement)?.props?.['data-meta']; + const { highlights } = useMemo(() => parseLineHighlights(language, meta), [language, meta]); + const hasHighlights = Object.keys(highlights).length > 0; const content = children.props.children; // hack-ish, but we get the content const highlightedContent = useMemo(() => { return highlightSnippet(language, content); @@ -31,20 +50,26 @@ export const CodeBlock: FC<{ children: React.ReactNode; language: string }> = ({ return (
       
- + {hasHighlights ? ( + + {splitHtmlLines(sanitize(highlightedContent ?? '')).map((lineHtml, i) => { + const lineNum = i + 1; + const highlightType = highlights[lineNum]; + const highlightClass = highlightType ? LINE_HIGHLIGHT_CLASSES[highlightType] : undefined; + return ( + + + + ); + })} + + ) : ( + + )}
diff --git a/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx b/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx index c3428d6737..b8e6ea6026 100644 --- a/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx +++ b/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx @@ -77,7 +77,7 @@ Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); To start streaming an AI response, publish the initial message. The message is identified by a server-assigned identifier called a [`serial`](/docs/messages#properties). Use the `serial` to append each subsequent token to the message as it arrives from the AI model: -```javascript +```javascript highlight="2,8" // Publish initial message and capture the serial for appending tokens const { serials: [msgSerial] } = await channel.publish({ name: 'response', data: '' }); diff --git a/yarn.lock b/yarn.lock index 2e1fa4d5d5..8645fa5167 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@ably/ui@17.13.2": - version "17.13.2" - resolved "https://registry.yarnpkg.com/@ably/ui/-/ui-17.13.2.tgz#d8b103f15b7bdec03dcbd8858c3812391ca638e6" - integrity sha512-lJ/fOxf9j4jSayonieb6g6/8lCvrLxRRmvi0r/CJUHSXCO8tgf+Kz/mY4ms9dQpCwO5/Fclkp9tiN/gZkiMjNg== +"@ably/ui@17.14.0-dev.72422c80f2": + version "17.14.0-dev.72422c80f2" + resolved "https://registry.yarnpkg.com/@ably/ui/-/ui-17.14.0-dev.72422c80f2.tgz#9064d7f247c4f1472c4ce9952dd79559a7031164" + integrity sha512-2Qo97kOr2MC7rbpVw+FC5S4aSVS3g/TSsVqS1Qqq4CuSQAz1z1JgwlVNzBk8cbdqlNU60/uLbmmQt3l8FD5UIQ== dependencies: "@heroicons/react" "^2.2.0" "@radix-ui/react-accordion" "^1.2.1" @@ -14824,16 +14824,7 @@ string-similarity@^1.2.2: lodash.map "^4.6.0" lodash.maxby "^4.6.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14943,7 +14934,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14964,13 +14955,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -16422,7 +16406,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16440,15 +16424,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"