diff --git a/README.md b/README.md index 55f687a..3f8ce00 100644 --- a/README.md +++ b/README.md @@ -114,19 +114,20 @@ Here we declare that links with `rel` "broader" are valid for all blocks that ma ### String constraints -| Name | Use | -|:-----------|:---------------------------------------------------------------------------| -| optional | Set to `true` if the value doesn't have to be present | -| allowEmpty | Set to `true` if an empty value is ok. | -| const | A specific `"value"` that must match | -| enum | A list `["of", "values"]` where one must match | -| pattern | A regular expression that the value must match | -| glob | A list of glob patterns `["http://**", "https://**"]` where one must match | -| format | A named format that the value must follow | -| time | A time format specification | -| geometry | The geometry and coordinate type that must be used for WKT strings. | -| labels | Labels used to describe the value | -| hints | Key value pairs used to describe the value | +| Name | Use | +|:--------------|:-----------------------------------------------------------------------------------------------------------| +| optional | Set to `true` if the value doesn't have to be present | +| allowEmpty | Set to `true` if an empty value is ok. | +| const | A specific `"value"` that must match | +| enum | A list `["of", "values"]` where one must match | +| pattern | A regular expression that the value must match | +| glob | A list of glob patterns `["http://**", "https://**"]` where one must match | +| format | A named format that the value must follow | +| time | A time format specification | +| colourFormats | Controls the "colour" format. Any combination of "hex", "rgb", and "rgba". Defaults to `["rgb", "rgba"]`. | +| geometry | The geometry and coordinate type that must be used for WKT strings. | +| labels | Labels used to describe the value | +| hints | Key value pairs used to describe the value | The distinction between optional and allowEmpty is only relevant for data attributes. The document and block attributes defined in the NewsDoc schema always exist, so `optional` and `allowEmpty` will be treated as equivalent. @@ -141,6 +142,7 @@ The following formats are available: * `html`: validate the contents as HTML * `uuid`: validate the string as a UUID * `wkt`: validate the string as a [WKT geometry](#wkt-geometry). +* `colour`: a colour in one of the formats specified in `colourFormats`. When using the format "html" it's also possible to use `htmlPolicy` to use a specific HTML policy. See the section on [HTML policies](#markdown-header-html-policies). diff --git a/colour.go b/colour.go new file mode 100644 index 0000000..faed050 --- /dev/null +++ b/colour.go @@ -0,0 +1,163 @@ +package revisor + +import ( + "encoding/hex" + "errors" + "fmt" + "slices" + "strconv" + "strings" +) + +type ColourFormat string + +const ( + ColourUnknown ColourFormat = "" + ColourHex ColourFormat = "hex" + ColourRGB ColourFormat = "rgb" + ColourRGBA ColourFormat = "rgba" +) + +type cFormatSpec struct { + Format ColourFormat + Prefix string + Validate func(spec cFormatSpec, code string) error +} + +var ( + defaultColourFormats = []ColourFormat{ColourRGB, ColourRGBA} + colourComponents = []string{"r", "g", "b", "alpha"} + colourFormats = []cFormatSpec{ + { + Format: ColourHex, + Prefix: "#", + Validate: parseHex, + }, + { + Format: ColourRGBA, + Prefix: "rgba", + Validate: parseRGBA, + }, + { + Format: ColourRGB, + Prefix: "rgb", + Validate: parseRGBA, + }, + } +) + +func validateColour(value string, formats []ColourFormat) error { + var ( + spec cFormatSpec + code string + ) + + for _, s := range colourFormats { + after, ok := strings.CutPrefix(value, s.Prefix) + if !ok { + continue + } + + spec = s + code = after + + break + } + + if len(formats) == 0 { + formats = defaultColourFormats + } + + if spec.Format == ColourUnknown || !slices.Contains(formats, spec.Format) { + if len(formats) == 1 { + return fmt.Errorf("expected a colour in the format %q", + formats[0]) + } + + return fmt.Errorf("expected a colour in one of the formats %s", + quotedSlice(formats)) + } + + return spec.Validate(spec, code) +} + +const hexColourLength = 6 + +func parseHex(_ cFormatSpec, code string) error { + if len(code) != hexColourLength { + return fmt.Errorf("code length: expected %d characters, got %d", + hexColourLength, len(code)) + } + + _, err := hex.DecodeString(code) + if err != nil { + return fmt.Errorf("invalid hex code: %w", err) + } + + return nil +} + +func parseRGBA(spec cFormatSpec, code string) error { + rest, ok := strings.CutPrefix(code, "(") + if !ok { + return errors.New("missing starting '('") + } + + rest, ok = strings.CutSuffix(rest, ")") + if !ok { + return errors.New("missing closing ')'") + } + + numberStrings := strings.Split(rest, ",") + components := len(numberStrings) + + //nolint: exhaustive + switch spec.Format { + case ColourRGB: + if components != 3 { + return fmt.Errorf("expected three components in a rgb() value, got %d", components) + } + case ColourRGBA: + if components != 4 { + return fmt.Errorf("expected four components in a rgba() value, got %d", components) + } + + n, err := strconv.ParseFloat(strings.TrimSpace(numberStrings[3]), 64) + if err != nil { + return fmt.Errorf("invalid alpha value: %w", err) + } + + if n < 0 || n > 1 { + return fmt.Errorf("%q out of range", colourComponents[3]) + } + default: + return fmt.Errorf( + "configuration error: cannot parse %q with parseRGBA()", + spec.Format, + ) + } + + for i, ns := range numberStrings[:3] { + n, err := strconv.Atoi(strings.TrimSpace(ns)) + if err != nil { + return fmt.Errorf("invalid %q value: %w", + colourComponents[i], err) + } + + if n < 0 || n > 255 { + return fmt.Errorf("%q out of range", colourComponents[i]) + } + } + + return nil +} + +func quotedSlice[T any](s []T) string { + ss := make([]string, len(s)) + + for i, v := range s { + ss[i] = strconv.Quote(fmt.Sprintf("%v", v)) + } + + return strings.Join(ss, ", ") +} diff --git a/string_constraint.go b/string_constraint.go index 1772672..943dc1e 100644 --- a/string_constraint.go +++ b/string_constraint.go @@ -23,6 +23,7 @@ const ( StringFormatHTML StringFormat = "html" StringFormatUUID StringFormat = "uuid" StringFormatWKT StringFormat = "wkt" + StringFormatColour StringFormat = "colour" ) func (f StringFormat) Describe() string { @@ -41,6 +42,8 @@ func (f StringFormat) Describe() string { return "a uuid" case StringFormatWKT: return "a WKT geometry" + case StringFormatColour: + return "a colour code" case StringFormatNone: return "" } @@ -117,20 +120,21 @@ func (cm ConstraintMap) MarshalJSON() ([]byte, error) { } type StringConstraint struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Optional bool `json:"optional,omitempty"` - AllowEmpty bool `json:"allowEmpty,omitempty"` - Const *string `json:"const,omitempty"` - Enum []string `json:"enum,omitempty"` - EnumRef string `json:"enumReference,omitempty"` - Pattern *Regexp `json:"pattern,omitempty"` - Glob GlobList `json:"glob,omitempty"` - Format StringFormat `json:"format,omitempty"` - Time string `json:"time,omitempty"` - Geometry string `json:"geometry,omitempty"` - HTMLPolicy string `json:"htmlPolicy,omitempty"` - Deprecated *Deprecation `json:"deprecated,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Optional bool `json:"optional,omitempty"` + AllowEmpty bool `json:"allowEmpty,omitempty"` + Const *string `json:"const,omitempty"` + Enum []string `json:"enum,omitempty"` + EnumRef string `json:"enumReference,omitempty"` + Pattern *Regexp `json:"pattern,omitempty"` + Glob GlobList `json:"glob,omitempty"` + Format StringFormat `json:"format,omitempty"` + Time string `json:"time,omitempty"` + Geometry string `json:"geometry,omitempty"` + ColourFormats []ColourFormat `json:"colourFormats,omitempty"` + HTMLPolicy string `json:"htmlPolicy,omitempty"` + Deprecated *Deprecation `json:"deprecated,omitempty"` // Labels (and hints) are not constraints per se, but should be seen as // labels on the value that can be used by systems that process data @@ -281,6 +285,11 @@ func (sc *StringConstraint) Validate( if err != nil { return nil, fmt.Errorf("WKT validation: %w", err) } + case StringFormatColour: + err := validateColour(value, sc.ColourFormats) + if err != nil { + return nil, fmt.Errorf("invalid colour value %q: %w", value, err) + } default: return nil, fmt.Errorf("unknown string format %q", sc.Format) } diff --git a/testdata/colour.json b/testdata/colour.json new file mode 100644 index 0000000..2d664c2 --- /dev/null +++ b/testdata/colour.json @@ -0,0 +1,40 @@ +{ + "uuid": "67912d12-bea0-4ec3-a8d4-16d669bd35bc", + "type": "test/colour-doc", + "content": [ + { + "type": "test/colour", + "data": { + "hexy": "#fefefe", + "solid": "rgb(10,10,10)", + "transparent": "rgba(10, 10, 10, 0.5)", + "anycol": "rgba(10, 10, 10, 0.5)" + } + }, + { + "type": "test/colour", + "data": { + "hexy": "#feqefe", + "solid": "rgb(-1,10,10)", + "transparent": "rgba(10, 10, 10, 2)", + "anycol": "rgb(10, 10, 10)" + } + }, + { + "type": "test/colour", + "data": { + "hexy": "#fefefefe", + "solid": "rgba(10,10,10,0.3)", + "transparent": "rgb(10, 10, 10)", + "anycol": "#ab332a" + } + }, + { + "type": "test/colour", + "data": { + "hexy": "fefefe", + "anycol": "nope" + } + } + ] +} diff --git a/testdata/constraints/colour.json b/testdata/constraints/colour.json new file mode 100644 index 0000000..dc5a5a7 --- /dev/null +++ b/testdata/constraints/colour.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "name": "colour", + "documents": [ + { + "declares": "test/colour-doc", + "content": [ + { + "declares": {"type": "test/colour"}, + "data": { + "hexy": { + "format": "colour", + "colourFormats": ["hex"], + "optional": true + }, + "solid": { + "format": "colour", + "colourFormats": ["rgb"], + "optional": true + }, + "transparent": { + "format": "colour", + "colourFormats": ["rgba"], + "optional": true + }, + "anycol": { + "format": "colour", + "colourFormats": ["rgba", "rgb", "hex"], + "optional": true + } + } + } + ] + } + ] +} diff --git a/testdata/results/test-colour.json b/testdata/results/test-colour.json new file mode 100644 index 0000000..14e7e88 --- /dev/null +++ b/testdata/results/test-colour.json @@ -0,0 +1,122 @@ +[ + { + "entity": [ + { + "refType": "data attribute", + "name": "hexy" + }, + { + "refType": "block", + "kind": "content", + "index": 1, + "type": "test/colour" + } + ], + "error": "invalid colour value \"#feqefe\": invalid hex code: encoding/hex: invalid byte: U+0071 'q'" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "solid" + }, + { + "refType": "block", + "kind": "content", + "index": 1, + "type": "test/colour" + } + ], + "error": "invalid colour value \"rgb(-1,10,10)\": \"r\" out of range" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "transparent" + }, + { + "refType": "block", + "kind": "content", + "index": 1, + "type": "test/colour" + } + ], + "error": "invalid colour value \"rgba(10, 10, 10, 2)\": \"alpha\" out of range" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "hexy" + }, + { + "refType": "block", + "kind": "content", + "index": 2, + "type": "test/colour" + } + ], + "error": "invalid colour value \"#fefefefe\": code length: expected 6 characters, got 8" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "solid" + }, + { + "refType": "block", + "kind": "content", + "index": 2, + "type": "test/colour" + } + ], + "error": "invalid colour value \"rgba(10,10,10,0.3)\": expected a colour in the format \"rgb\"" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "transparent" + }, + { + "refType": "block", + "kind": "content", + "index": 2, + "type": "test/colour" + } + ], + "error": "invalid colour value \"rgb(10, 10, 10)\": expected a colour in the format \"rgba\"" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "anycol" + }, + { + "refType": "block", + "kind": "content", + "index": 3, + "type": "test/colour" + } + ], + "error": "invalid colour value \"nope\": expected a colour in one of the formats \"rgba\", \"rgb\", \"hex\"" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "hexy" + }, + { + "refType": "block", + "kind": "content", + "index": 3, + "type": "test/colour" + } + ], + "error": "invalid colour value \"fefefe\": expected a colour in the format \"hex\"" + } +] diff --git a/validation_test.go b/validation_test.go index 200c9f0..a821b34 100644 --- a/validation_test.go +++ b/validation_test.go @@ -272,6 +272,7 @@ func TestValidateDocument(t *testing.T) { "testdata/constraints/geo.json", "testdata/constraints/labels-hints.json", "testdata/constraints/transcript.json", + "testdata/constraints/colour.json", ) testValidator, err := revisor.NewValidator(testConstraints...)