Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce experimental accessible colors in markdown rendering #186

Open
wants to merge 31 commits into
base: trunk
Choose a base branch
from

Conversation

andyfeller
Copy link
Member

@andyfeller andyfeller commented Mar 10, 2025

Relates github/cli#830

These changes relate to a new experimental feature for rendering markdown in GitHub CLI and CLI extensions using accessible colors.

Out of the box, markdown package continues to render GFM using existing charmbracelet/glamour light and dark styles in 8-bit color.

By setting accessible_colors configuration setting or GH_ACCESSIBLE_COLORS environment variable, markdown package will begin using ANSI 4-bit colors that most modern terminals allow users to customize. This will allow users with low vision and color blindness to tailor their experiences more effectively.

Go version change

This pull request bumps the minimum Go version from 1.21 to 1.23 due to transitive dependencies from experimental Go module:

Demo

The following screenshots demonstrate 3 terminal experiences, from left to right:

  1. Current, default markdown behavior with 8-bit colors
  2. New, environment variable configured 4-bit accessible colors
  3. New, configuration setting configured 4-bit accessible colors

Screenshot of 3 experiences highlighting non-codeblock experiences

Screenshot of 3 experiences highlighting codeblock experiences

jtmcg and others added 28 commits February 6, 2025 16:44
Add accessibility package and isEnabled function
This commit leverages `cli` fork of `charmbracelet/glamour` with enhancement for configuring `chroma` formatter.

It includes simple tests that enabling `gh` accessible features causes codeblocks to be downsampled to base 16 ANSI colors.
This commit is v2 approach to testing for accessible colors and codeblock rendering using an in-house approach to identifying ANSI escape sequences and analyzing them for color depth.

After talking with @williammartin, this is likely going to be refactored a third time leveraging a module to help with parsing escape sequences from text.
This commit is focused on PR feedback around codeblock testing and simplifying related code:

1. Use of new `WithOptions(...TermRendererOption) TermRendererOption` to clean up `WithTheme()`

   The `glamour` TermRendererOption pattern has a limitation that users cannot compose multiple options without duplicating code or building one-off anonymous functions.

   However, this commit takes advantage of an enhancement in cli/glamour#3 that allows users to leverage a helper to avoid building one-offs.

1. Use of new `leaanthony/go-ansi-parser` dependency for parsing ANSI escape sequences and display attributes

   In v1 of `markdown_test.go`, the codeblock tests were very simple, testing the result of output of markdown rendering against a string of ANSI escape sequences. The concern raised is that this was testing the result rather than behavior wanted.

   In v2 of `markdown_test.go`, the codeblock tests were refactored to use regex to extract and analyze ANSI escape sequences and display attributes. The concern raised was that this was a lot of logic to build and maintain and might benefit from a dependency that could do it.

   In v3 of `markdown_test.go`, a combination of v1 and v2 approaches for 1) testing that theme appropriate colors are used and 2) testing that ensures accessible display options are used when accessible experience is enabled
After discussing the changing nature of the tests with @jtmcg, this commit renames the test functions to make it apparent their purposes: testing we get the colors we want separate from checking for accessibility concerns.

In addition, there were a number of other improvements to be more idiomatic.
1. Introduce constant for `gh config` setting for accessible colors
2. Align existing constant for env var to match
3. Rename exported functions to distinguish use specifically for color
4. Minor enhancements to comments and tests
Following Go `x` convention seen across various projects, the former `accessibility` package has been relocated to communicate its experimental status more clearly to extension authors.

It has also been renamed to `color` to align with adopting Primer Style color role functionality.
Realign accessible color logic around new env var
Due to errors in PR, this commit updates GitHub Actions workflows to use a newer version of actions/setup-go capable of reading Go version from go.mod.
@andyfeller
Copy link
Member Author

andyfeller commented Mar 10, 2025

In addressing the lint errors, I've made minor changes to GitHub Actions workflows to pull Go version from go.mod, which requires a newer version of actions/setup-go.

Separately while troubleshooting lint job, I notice that the action used for Go linting is 3 major version behind; v3.4.0 => v6.5.0.

For changes, see diff

Iterate on resolving lint job errors due to Go 1.23 version upgrade.

This should resolve most of the problems with 1 potential fix that I'll wait to confirm in the next run.
@andyfeller
Copy link
Member Author

andyfeller commented Mar 10, 2025

Note

In upping the Go version, there is a discrepancy between automation in the default branch which takes 1.21 into account whereas this branch bumps it up. 🤷

Meaning there will be some follow up work to the repository ruleset / branch protection rules afterwards.

Copy link
Member

@williammartin williammartin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andyfeller I got a bit distracted with some other stuff so I didn't get into the good stuff yet in this PR review sorry. Mostly just cursory stuff!

// StubConfig replaces the config.Read function with a function that returns a config object
// created from the given config string. It also sets up a cleanup function that restores the
// original config.Read function.
func StubConfig(t *testing.T, cfgStr string) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Food For Thought

No concerns merging this as is because it's easy to change later, but maybe this should be config.Stub where this lives in package config_test would be better than a testutils package.

return strconv.Itoa(int(a))
}

func (a ANSIColorCode) StrPtr() *string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question

Did you take this pattern from somewhere?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I believe it was based on 2 ideas:

  • charmbracelet/glamour default styles logic:

        // DarkStyleConfig is the default dark style.
        DarkStyleConfig = ansi.StyleConfig{
      	  Document: ansi.StyleBlock{
      		  StylePrimitive: ansi.StylePrimitive{
      			  BlockPrefix: "\n",
      			  BlockSuffix: "\n",
      			  Color:       stringPtr("252"),
      		  },
      		  Margin: uintPtr(defaultMargin),
      	  },
      	  BlockQuote: ansi.StyleBlock{
      		  StylePrimitive: ansi.StylePrimitive{},
      		  Indent:         uintPtr(1),
      		  IndentToken:    stringPtr("│ "),
      	  },

    which uses a handful of helper functions:

    func boolPtr(b bool) *bool       { return &b }
    func stringPtr(s string) *string { return &s }
    func uintPtr(u uint) *uint       { return &u }
  • color.go in cli/cli and ansi.ColorFunc()

    This was more inspirational about having a type where information about color could then be leveraged elsewhere rather than the unexported private functions above:

    var (
        magenta            = ansi.ColorFunc("magenta")
        cyan               = ansi.ColorFunc("cyan")
        red                = ansi.ColorFunc("red")
        yellow             = ansi.ColorFunc("yellow")
        blue               = ansi.ColorFunc("blue")
        green              = ansi.ColorFunc("green")
        gray               = ansi.ColorFunc("black+h")
        lightGrayUnderline = ansi.ColorFunc("white+du")
        bold               = ansi.ColorFunc("default+b")
        cyanBold           = ansi.ColorFunc("cyan+b")
        greenBold          = ansi.ColorFunc("green+b")
        highlightStart     = ansi.ColorCode(highlightStyle)
        highlight          = ansi.ColorFunc(highlightStyle)
    
        gray256 = func(t string) string {
      	  return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t)
        }
    )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, makes sense to me.

Maybe it doesn't need to be exported?

@@ -0,0 +1,106 @@
package markdown
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question

Should this also live under x for the moment?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great question! I can get behind that especially as we don't want anyone thinking its stable, yeah.

}

func isEnvVarEnabled(envVar string) bool {
return envVar != "" && envVar != "0" && envVar != "false"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Food For Thought

No specific request but this function looks a little sus in that the only place it is used is in this file and repeats the string check that wraps it on line 26.

I think I'd probably inline on 26 like if you wanted to keep the clarity:

if envVar != "" {
   isEnVarEnabled := envVar != "0" && envVar != "false"
   return isEnvVarEnabled
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally fair call out!

I was hoping we could tie into existing logic as this work evolved as this shouldn't be a new problem, but I suppose we don't really:

ghDebug := os.Getenv("GH_DEBUG")
switch ghDebug {
case "", "0", "false", "no":
// no logging
default:
opts.Log = os.Stderr
opts.LogColorize = !term.IsColorDisabled() && term.IsTerminal(os.Stderr)
opts.LogVerboseHTTP = strings.Contains(ghDebug, "api")
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably also want to add no, too

Copy link
Member

@williammartin williammartin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of good stuff in here, don't think there's too much work to resolve my comments.

I haven't yet pulled this into cli/cli to use it.

}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("GH_ACCESSIBLE_COLORS", tt.envVarValue)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Food For Thought

In practice I think this will work but I'm not sure it's totally correct in line with the descriptions e.g. the first test says:

When the accessibility configuration and env var are both unset, it should return false

But setting an env var to an empty string is not the same as it being unset. You can confirm this with:

			_, set := os.LookupEnv("GH_ACCESSIBLE_COLORS")
			fmt.Println(set)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually if you look at this test:

		{
			name:        "When the accessibility configuration is set to disabled and the env var is unset, it should return false",
			envVarValue: "",
			cfgStr:      accessibilityDisabledConfig(),
			wantOut:     false,
		},

You might expect that changing the implementation of IsAccessibleColorsEnabled with a refactor to a true "set" or "unset" check would be sage:

	envVar, set := os.LookupEnv(AccessibleColorsEnv)
	if set {
		return isEnvVarEnabled(envVar)
	}

But it's not because "" is considered set. This refactor actually results in the config never being checked in the tests.

For me, this is enough to Request Changes to make it clear what an empty string vs unset should do.

func accessibilityEnabledConfig() string {
return heredoc.Docf(`
%s: enabled
`, AccessibleColorsSetting)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit

I understand your desire to DRY out the use of this config but I have mixed feelings about it. The thing is, the tests are supposed to act as guardrails but changing the value of AccessibleColorsSetting would break users but wouldn't fail the tests.

I think it's generally better to duplicate in tests for this reason.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact you avoided this trap for the envvar by using the string directly in the test.

"github.com/stretchr/testify/assert"
)

func TestANSIColorCodeString(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question

Maybe this is pedantic but it feels like we should test all the colors here. I swapped the order of some of them and no tests failed.

Maybe it's unnecessary, who's really going to swap the order? Maybe a comment would be fine. I don't think it's obvious to uninformed readers that the order of the enumeration is important.

An alternative would be to use a map to int.

What do you think?

@@ -33,9 +34,16 @@ func WithWrap(w int) glamour.TermRendererOption {
// If the environment variable GLAMOUR_STYLE is set, it will take precedence over the provided theme.
func WithTheme(theme string) glamour.TermRendererOption {
style := os.Getenv("GLAMOUR_STYLE")
accessible := color.IsAccessibleColorsEnabled()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit

If this is only used inside the light, dark case then maybe just inline it in the if there?

It would probably be a totally negligible performance improvement but generally I value the scoping being tight.

// Test_RenderAccessible tests rendered markdown for accessibility concerns such as color mode / depth and other display attributes.
// It works by parsing the rendered markdown for ANSI escape sequences and checking their display attributes.
// Test scenarios allow multiple color mode / depths because `ansi.Parse()` considers `\x1b[0m` sequence as part of `ansi.Default`.
func Test_RenderAccessible(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise

This test is sick.

_, err := Render(tt.text, WithTheme(tt.theme))
assert.NoError(t, err)
t.Cleanup(func() {
// Chroma caches charm style used to render codeblocks, it must be unregistered to avoid previously used style being reused.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise

Great comment.

// Chroma caches charm style used to render codeblocks, it must be unregistered to avoid previously used style being reused.
delete(styles.Registry, "charm")
})
t.Setenv(color.AccessibleColorsEnv, tt.accessibleEnvVar)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit

Same issue as DRYing the config setting mentioned in another comment.

path := filepath.Join(t.TempDir(), fmt.Sprintf("%s.json", tt.styleEnvVar))
err := os.WriteFile(path, []byte(customGlamourStyle(t)), 0644)
if err != nil {
t.Fatal(err)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question

Is there a reason we're not using require.NoError(err) here? Suspect not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants