diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d7b5e774e..f9e78cad7 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -207,7 +207,9 @@ Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Ent Typing **#123** in a composer looks up that exact issue or pull request in the /dev/null repository, `dev-null-sh/dev-null.sh`. The matching title appears beneath the number; click it or use Enter or Tab to insert its GitHub URL, just like choosing an emoji suggestion. A space or punctuation leaves the number as typed and dismisses the suggestion. Missing numbers and failed lookups leave the text unchanged. Code, URL fragments, and references qualified with another repository are not expanded. -Sources: [body renderer](resources/js/components/commit-body.tsx), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php). +A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. A color written only with digits counts when it has 6 or 8 digits or starts with a zero, which issue numbers here don't: `#000`, `#000000` and `#333333` are colors, while `#123` and `#333` stay plain text. + +Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php). ### Draft previews diff --git a/resources/js/components/linkified-text.tsx b/resources/js/components/linkified-text.tsx index 2dd86d98e..eb2a9fc97 100644 --- a/resources/js/components/linkified-text.tsx +++ b/resources/js/components/linkified-text.tsx @@ -7,7 +7,9 @@ import AppWordmark from '@/components/app-wordmark'; import { CertificateText } from '@/components/certificate-text'; import { GitHubIcon } from '@/components/github-icon'; import { UserPreview } from '@/components/user-preview'; -import { typedProse } from '@/lib/code'; +import { typedProse, withoutCode } from '@/lib/code'; +import type { Color } from '@/lib/colors'; +import { colorsIn } from '@/lib/colors'; import { mentionPattern } from '@/lib/mentions'; import { hrefFor, @@ -257,19 +259,23 @@ function withPasteis(text: string, pasteisMode: boolean): ReactNode { ); } -function withWordmarks(text: string, pasteisMode: boolean): ReactNode[] { +function withWordmarks( + text: string, + offset: number, + pasteisMode: boolean, +): ReactNode[] { return text .split( /(? index % 2 === 0 ? ( - + {withPasteis(part, pasteisMode)} ) : ( @@ -279,10 +285,60 @@ function withWordmarks(text: string, pasteisMode: boolean): ReactNode[] { ); } +/** + * The hex colors found in the whole text that fall in this slice of it, as their value with a swatch of the color. + */ +function withColors( + text: string, + offset: number, + colors: Color[], + pasteisMode: boolean, +): ReactNode[] { + const parts: ReactNode[] = []; + let position = 0; + + for (const color of colors) { + const index = color.index - offset; + + if (index < position || index + color.length > text.length) { + continue; + } + + parts.push( + ...withWordmarks( + text.slice(position, index), + offset + position, + pasteisMode, + ), + + , + ); + + position = index + color.length; + } + + parts.push( + ...withWordmarks(text.slice(position), offset + position, pasteisMode), + ); + + return parts; +} + function withMentions( text: string, offset: number, mentions: Commit['mentions'], + colors: Color[], pasteisMode: boolean, isStatic: boolean, ): ReactNode[] { @@ -297,7 +353,12 @@ function withMentions( : null; parts.push( - ...withWordmarks(text.slice(position, match.index), pasteisMode), + ...withColors( + text.slice(position, match.index), + offset + position, + colors, + pasteisMode, + ), isStatic || !userId ? ( match[0] ) : ( @@ -320,7 +381,14 @@ function withMentions( position = match.index + match[0].length; } - parts.push(...withWordmarks(text.slice(position), pasteisMode)); + parts.push( + ...withColors( + text.slice(position), + offset + position, + colors, + pasteisMode, + ), + ); return parts; } @@ -330,13 +398,18 @@ export function LinkifiedText({ mentions, pasteisMode = false, isStatic = false, + startsText = false, }: { text: string; mentions: Commit['mentions']; pasteisMode?: boolean; isStatic?: boolean; + // Whether the text is a whole body, so its start is the start of a line. + startsText?: boolean; }) { const parts: ReactNode[] = []; + // Colors are found in the prose only, for callers that pass a whole body, code included. + const colors = colorsIn(withoutCode(text), startsText); let position = 0; for (const match of text.matchAll(urlPattern)) { @@ -355,6 +428,7 @@ export function LinkifiedText({ text.slice(position, match.index), position, mentions, + colors, pasteisMode, isStatic, ), @@ -400,6 +474,7 @@ export function LinkifiedText({ text.slice(position), position, mentions, + colors, pasteisMode, isStatic, ), diff --git a/resources/js/lib/code.ts b/resources/js/lib/code.ts index 04b69970d..fece440b2 100644 --- a/resources/js/lib/code.ts +++ b/resources/js/lib/code.ts @@ -45,6 +45,19 @@ export function mapProse( .join(''); } +/** + * The text with its committed code blanked out to spaces of the same length, so what is found in the prose keeps its + * place in the text. + */ +export function withoutCode(text: string): string { + return text + .split(codePattern) + .map((part, index) => + index % 2 === 0 ? part : ' '.repeat(part.length), + ) + .join(''); +} + /** * Applies the callback to the prose being typed only, leaving the code as written. */ diff --git a/resources/js/lib/colors.ts b/resources/js/lib/colors.ts new file mode 100644 index 000000000..1ca300729 --- /dev/null +++ b/resources/js/lib/colors.ts @@ -0,0 +1,30 @@ +// A hex color like "#ff8a4c" or "#fff", which the web shows next to a swatch of it. It has 3, 4, 6 or 8 hex digits. One +// of 3 or 4 digits also needs a letter or a leading zero, so "#123" stays an issue reference while "#000" is black: issue +// numbers never start with a zero, and none are 6 or 8 digits long. It is a whole word like a hashtag: it starts at the start, +// after whitespace or after the ">" that starts a quoted line, and only punctuation may follow it before the next +// space, so neither "#fff-x" nor "#ff8a4c.com" is a color. +const colorPattern = + // oxlint-disable-next-line no-misleading-character-class + /(?:(?))[#\uFF03]([0-9a-f]{8}|[0-9a-f]{6}|(?=[0-9]*[a-f]|0)[0-9a-f]{3,4})(?=\p{P}*(?:[\s\u00AD\u2060\u200A-\u200D\u20E2]|$))/giu; + +export type Color = { + value: string; + index: number; + length: number; +}; + +/** + * The hex colors in a run of prose, where they are and how long they are with the "#". When the prose starts a whole + * body, its start is the start of a line, so a ">" there starts a quoted line. + */ +export function colorsIn(text: string, startsText = false): Color[] { + const start = startsText ? 1 : 0; + + return [...(startsText ? `\n${text}` : text).matchAll(colorPattern)].map( + (match) => ({ + value: match[1], + index: match.index - start, + length: match[0].length, + }), + ); +} diff --git a/resources/js/lib/issue-reference.ts b/resources/js/lib/issue-reference.ts index a6106a6d8..2a3a604be 100644 --- a/resources/js/lib/issue-reference.ts +++ b/resources/js/lib/issue-reference.ts @@ -1,4 +1,5 @@ -import { typedProse } from '@/lib/code'; +import { typedProse, withoutCode } from '@/lib/code'; +import { colorsIn } from '@/lib/colors'; export function issueReferenceBeforeCaret(text: string, caret: number) { const prose = typedProse(text.slice(0, caret)).at(-1) ?? ''; @@ -12,8 +13,17 @@ export function issueReferenceBeforeCaret(text: string, caret: number) { return null; } + const start = caret - match[1].length - 1; + + // A color like "#333333" is all digits too, but it is never an issue. + if ( + colorsIn(withoutCode(text), true).some((color) => color.index === start) + ) { + return null; + } + return { - start: caret - match[1].length - 1, + start, query: match[1], }; } diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index fed5ade38..5ead07c82 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -588,6 +588,7 @@ function Suggestions({ suggestions }: { suggestions: Commit[] }) {

diff --git a/tests/Unit/Colors.test.ts b/tests/Unit/Colors.test.ts new file mode 100644 index 000000000..a0bde69dd --- /dev/null +++ b/tests/Unit/Colors.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { colorsIn } from '../../resources/js/lib/colors.ts'; + +const values = (text: string) => colorsIn(text).map((color) => color.value); + +await test('finds hex colors and where they are', () => { + assert.deepEqual( + colorsIn( + 'Brand orange is #ff8a4c, white is #fff, and #ff8a4c80 or #fffa', + ), + [ + { value: 'ff8a4c', index: 16, length: 7 }, + { value: 'fff', index: 34, length: 4 }, + { value: 'ff8a4c80', index: 44, length: 9 }, + { value: 'fffa', index: 57, length: 5 }, + ], + ); +}); + +await test('finds a hex color as a whole word, with punctuation after it left out', () => { + const cases: [string, string[]][] = [ + ['#FFF', ['FFF']], + ['Is it #fff?!', ['fff']], + ['(#ff8a4c)', []], + ['Line\n>#fff', ['fff']], + ['#ff8a4c', ['ff8a4c']], + ['Tab\t#abc', ['abc']], + ]; + + for (const [text, expected] of cases) { + assert.deepEqual(values(text), expected, text); + } +}); + +await test('ignores what only looks like a hex color', () => { + for (const text of [ + 'Issue #123', + 'Gray #333 or #9999', + '#fffff and #fffffff', + '#ff8a4g', + '#fff-x', + '#ff8a4c.com', + 'a#fff', + 'C#fff', + '##fff', + '>>#fff', + ]) { + assert.deepEqual(values(text), [], text); + } +}); + +await test('finds a hex color after the ">" that starts a whole body', () => { + assert.deepEqual(colorsIn('>#fff', true), [ + { value: 'fff', index: 1, length: 4 }, + ]); + assert.deepEqual(colorsIn('>#fff'), []); + assert.deepEqual(colorsIn('>>#fff', true), []); +}); + +await test('finds digit-only hex colors that cannot be issue numbers', () => { + assert.deepEqual( + values('#000, #0000, #000000, #00000000, #333333 and #99999999'), + ['000', '0000', '000000', '00000000', '333333', '99999999'], + ); + assert.deepEqual(values('#00000 and #0000000'), []); +}); + +await test('ends a hex color at a zero-width character, as a hashtag ends', () => { + assert.deepEqual(values('#fff\u200Bnext and #abc\u2060'), ['fff', 'abc']); + assert.deepEqual(values('#fff\u200Cx'), ['fff']); +}); diff --git a/tests/Unit/IssueReference.test.tsx b/tests/Unit/IssueReference.test.tsx index 91acdcd38..f55821822 100644 --- a/tests/Unit/IssueReference.test.tsx +++ b/tests/Unit/IssueReference.test.tsx @@ -61,3 +61,28 @@ test('preserves offsets after code and non-ASCII text, and chooses the latest re query: '456', }); }); + +test('does not look up digit-only colors as issues', () => { + for (const [text, caret] of [ + ['#333333', 7], + ['See #99999999', 13], + ['Gray is #333333.', 15], + ['#333333`code`', 7], + ['#99999999`a b` done', 9], + ] as const) { + assert.equal(issueReferenceBeforeCaret(text, caret), null, text); + } + + for (const [text, caret, query] of [ + ['See #123', 8, '123'], + ['See (#333333', 12, '333333'], + ['#333333.com', 7, '333333'], + ['#333333`code', 7, '333333'], + ] as const) { + assert.equal( + issueReferenceBeforeCaret(text, caret)?.query, + query, + text, + ); + } +}); diff --git a/tests/Unit/LinkifiedText.test.tsx b/tests/Unit/LinkifiedText.test.tsx index a74dde303..52f26903f 100644 --- a/tests/Unit/LinkifiedText.test.tsx +++ b/tests/Unit/LinkifiedText.test.tsx @@ -513,6 +513,53 @@ for (const link of [ }); } +test('shows a hex color with a swatch of it', () => { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('data-test="color"')); + assert.ok(html.includes('background-color:#ff8a4c')); + assert.equal(html.replace(/<[^>]*>/g, ''), 'Brand orange is #ff8a4c.'); +}); + +test('shows no swatch for a color in code when given a whole body', () => { + const html = renderToStaticMarkup( + , + ); + + assert.equal(html.match(/data-test="color"/g)?.length, 1); + assert.ok(html.includes('background-color:#ff8a4c')); +}); + +test('shows swatches next to links, mentions and wordmarks', () => { + const html = renderToStaticMarkup( + , + ); + + assert.equal(html.match(/data-test="color"/g)?.length, 2); + assert.ok(html.includes('data-test="mention"')); + assert.ok(html.includes('href="https://example.com/#abc"')); +}); + +test('shows a swatch for a color that starts a quoted line at the start of a whole body', () => { + const colors = (text: string) => + renderToStaticMarkup( + , + ).match(/data-test="color"/g)?.length ?? 0; + + assert.equal(colors('>#fff'), 1); + assert.equal(colors('line\n>#fff'), 1); + assert.equal(colors('>>#fff'), 0); + assert.equal(colors('`x`>#fff'), 0); +}); + test('styles every standalone brand mention in UI copy without changing its text', () => { const text = 'Welcome to /dev/null. /dev/null was updated.'; const html = renderToStaticMarkup({text}); @@ -608,3 +655,15 @@ for (const kind of ['direct', 'org'] as const) { }); } } + +test('gives the text on each side of a color its own key', () => { + const element = LinkifiedText({ + text: 'Brand #ff8a4c, white', + mentions: {}, + }) as ReactElement<{ children: unknown }>; + const parts = [element.props.children].flat(Infinity) as ReactElement[]; + const keys = parts.map((part) => part.key); + + assert.equal(parts.length, 3); + assert.equal(new Set(keys).size, keys.length, keys.join(', ')); +});