diff --git a/commitlint.config.js b/commitlint.config.js index 8ae4701ab0..ce88a47eec 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -32,6 +32,7 @@ export default { 'progress', 'radio', 'ripple', + 'sass-ext', 'select', 'slider', 'switch', diff --git a/docs/sass/sass-ext.md b/docs/sass/sass-ext.md new file mode 100644 index 0000000000..c5ecc7c616 --- /dev/null +++ b/docs/sass/sass-ext.md @@ -0,0 +1,746 @@ + + +# `sass/ext` + + + +go/sass-ext + +A collection of useful Sass modules that extend and complement the functionality +provided by built-in `sass:*` modules. + + + +## Usage + +These Sass utilities are intended to reduce boilerplate for common Sass +metaprogramming use cases, such as type checking. + +```scss +@use '@material/web/sass/ext/assert'; +@use '@material/web/sass/ext/map_ext'; + +@mixin theme($values) { + $values: assert.is-type($values, 'map'); + --primary: #{map_ext.get-strict($values, 'primary')}; + --secondary: #{map_ext.get-strict($values, 'secondary')}; + --tertiary: #{map_ext.get-strict($values, 'tertiary')}; +} +``` + +The same logic using built-in modules requires more boilerplate. + +```scss +@use 'sass:map'; +@use 'sass:meta'; + +@mixin theme($values) { + @if meta.type-of($values) != 'map' { + @error 'Argument is not a map: #{meta.inspect($values)}'; + } + @if not map.has-key($values, 'primary') { + @error 'Map is missing key "primary": #{meta.inspect($values)}'; + } + @if not map.has-key($values, 'secondary') { + @error 'Map is missing key "secondary": #{meta.inspect($values)}'; + } + @if not map.has-key($values, 'tertiary') { + @error 'Map is missing key "tertiary": #{meta.inspect($values)}'; + } + + --primary: #{map.get($values, 'primary')}; + --secondary: #{map.get($values, 'secondary')}; + --tertiary: #{map.get($values, 'tertiary')}; +} +``` + +## Who should use `sass/ext`? + +These utilities should be used by Sass libraries that need to perform +*repetitive* and *complex* Sass metaprogramming logic. + +Prefer using built-in Sass functionality for simple use cases where `sass/ext` +would not reduce complexity. + +```scss {.bad} +// Using sass/ext here does not reduce boilerplate, and adds another API to +// learn and maintain. +@use '@material/web/sass/ext/type'; + +@function value-or-default($value, $default) { + @if type.matches($value, 'null') { + @return $default; + } + @return $value; +} +``` + +```scss {.good} +// For simple use cases, built-in Sass functionality should be preferred. +@function value-or-default($value, $default) { + @if $value == null { + @return $default; + } + @return $value; +} +``` + + + + +## `assert` + +### `is-type` {#assert.is-type} + +```scss +assert.is-type($arg, $type, $message, $source) + +/// @param {*} $arg - The argument to check. +/// @param {string} $type - The string type to assert the argument matches. Multiple types may be separated by '|'. +/// @param {string} $message - Optional custom error message. +/// @param {string} $source - Optional source of the error message. +/// @return {*} The argument if it matches the type string. +/// @throw Error if the argument does not match the type string. +``` + +Asserts that the argument is a specific type. If it is, the argument is +returned, otherwise an error is thrown. + +```scss +@mixin multiply($a, $b) { + $a: assert.is-type($a, 'number'); + $b: assert.is-type($b, 'number'); + @return $a * $b; +} + +@function is-empty($value) { + $value: assert.is-type( + $value, + 'list|map|null', + $message: '$value must be a list, map, or null', + $source: 'is-empty' + ); + @return $value and list.length($value) == 0; +} +``` + +### `not-type` {#assert.not-type} + +```scss +assert.not-type($arg, $type, $message, $source) + +/// @param {*} $arg - The argument to check. +/// @param {string} $type - The string type to assert the argument does not match. Multiple types may be separated by '|'. +/// @param {string} $message - Optional custom error message. +/// @param {string} $source - Optional source of the error message. +/// @return {*} The argument if it does not match the type string. +/// @throw Error if the argument matches the type string. +``` + +Asserts that the argument is not a specific type. The argument is returned +if it does not match. An error is thrown if the argument matches the type. + +```scss +@function get-strict($map, $key) { + @return assert.not-type( + map.get($map, $key), + 'null', + $message: 'Key must be in the map' + ); +} +``` + +## `list_ext` + +### `difference` {#list_ext.difference} + +```scss +list_ext.difference($listA, $listB) //=> list + +/// @param {list} $listA - The first list to compare. +/// @param {list} $listB - The second list to compare. +/// @return {list} All items in $listA that are not in $listB. +``` + +Returns the difference between two lists. + +```scss +$listA: ('apple', 'banana', 'cherry', 'date'); +$listB: ('banana', 'cherry', 'apple'); +$listC: ('apple', 'banana', 'date'); + +@debug list_ext.difference($listA, $listB); // ('date') +@debug list_ext.difference($listA, $listC); // ('cherry') +``` + +### `are-equal` {#list_ext.are-equal} + +```scss +list_ext.are-equal($listA, $listB) //=> boolean + +/// @param {list} $listA - The first list to compare. +/// @param {list} $listB - The second list to compare. +/// @return {boolean} `true` if the lists contain the same elements, otherwise `false`. +``` + +Checks if two lists contain the same elements, regardless of their order. + +The function iterates through each list and verifies that every element in +one list is present in the other. The order of elements does not affect the +result. + +```scss +$listA: ('apple', 'banana', 'cherry'); +$listB: ('banana', 'cherry', 'apple'); +$listC: ('apple', 'banana', 'date'); + +@debug list_ext.are-equal($listA, $listB); // true +@debug list_ext.are-equal($listA, $listC); // false +``` + +### `intersection` {#list_ext.intersection} + +```scss +list_ext.intersection($listA, $listB) //=> list + +/// @param {list} $listA - The first list to compare. +/// @param {list} $listB - The second list to compare. +/// @return {list} All items in $listA that are also in $listB. +``` + +Returns the intersection of two lists. + +```scss +$listA: ('apple', 'banana', 'cherry', 'date'); +$listB: ('banana', 'cherry', 'apple'); +$listC: ('apple', 'banana', 'date'); + +@debug list_ext.intersection($listA, $listB); // ('apple', 'banana', 'cherry') +@debug list_ext.intersection($listA, $listC); // ('apple', 'banana') +``` + +## `map_ext` + +### `get-strict` {#map_ext.get-strict} + +```scss +map_ext.get-strict($map, $key, $keys) + +/// @param {map} $map - The map to retrieve the value from. +/// @param {string} $key - The key of the value to retrieve. +/// @param {list} $keys - Additional keys to retrieve deeply nested values. +/// @return {*} The value at the given key. +/// @throw Error if the key does not exist in the map. +``` + +The same as `map.get()` but throws an error if the key is not found. + +This is useful over `map.get()` when using Sass maps like records, where +the key is expected to exist. + +```scss +$map: ( + 'name': 'foo', + 'value': blue, +); + +@debug map_ext.get-strict($map, 'name'); // 'foo' +@debug map_ext.get-strict($map, 'bar'); // ERROR: Key "bar" expected but not found in $map: ('name': 'foo', 'value': blue) +``` + +### `split` {#map_ext.split} + +```scss +map_ext.split($map, $keys) //=> list + +/// @param {map} $map - The Map to split. +/// @param {list} $keys - List of keys to split the Map by. +/// @return {list} A List pair with two new Maps: the first with the keys provided, and the second with the remaining keys. +``` + +Splits a Map and returns a List pair with two new Maps: the first with the +provided keys and the second without. + +```scss +$map: ( + 'focus': blue, + 'focus-within': blue, + 'hover': teal, + 'active': green, +); + +$pair: map_ext.split($map, ('focus', 'focus-within')); + +$map-with-focus-keys: list.nth($pair, 1); +@debug $map-with-focus-keys; // ('focus': blue, 'focus-within': blue) + +$map-with-remaining-keys: list.nth($pair, 2); +@debug $map-with-remaining-keys; // ('hover': teal, 'active': green) +``` + +### `pick` {#map_ext.pick} + +```scss +map_ext.pick($map, $keys) //=> map + +/// @param {map} $map - The Map to split. +/// @param {list} $keys - List of keys to include in the new Map. +/// @return {map} Map with only the keys provided. +``` + +Splits a Map and returns a new Map that only includes the provided keys. + +```scss +$map: ( + 'focus': blue, + 'focus-within': blue, + 'hover': teal, + 'active': green, +); + +$map-with-focus-keys: map_ext.pick($map, ('focus', 'focus-within')); +@debug $map-with-focus-keys; // ('focus': blue, 'focus-within': blue) +``` + +### `omit` {#map_ext.omit} + +```scss +map_ext.omit($map, $keys) //=> map + +/// @param {map} $map - The Map to split. +/// @param {list} $keys - List of keys to exclude from the new Map. +/// @return {map} Map without the keys provided. +``` + +Splits a Map and returns a new Map that excludes the provided keys. + +```scss +$map: ( + 'focus': blue, + 'focus-within': blue, + 'hover': teal, + 'active': green, +); + +$map-without-focus-keys: map_ext.omit($map, ('focus', 'focus-within')); +@debug $map-without-focus-keys; // ('hover': teal, 'active': green) +``` + +### `rename-keys` {#map_ext.rename-keys} + +```scss +map_ext.rename-keys($map, $keys-to-rename) //=> map + +/// @param {map} $map - The map to rename keys within. +/// @param {map} $keys-to-rename - A map of keys and their new names. +/// @return {map} The map with any matching keys renamed. +``` + +Returns the given map with any matching keys renamed according to the +provided Map of keys to rename. + +```scss +$map: ('foo': red); + +$new-map: map_ext.rename-keys($map, ('foo': 'bar')); +@debug $new-map; // ('bar': red) +``` + +### `difference` {#map_ext.difference} + +```scss +map_ext.difference($mapA, $mapB) //=> list + +/// @param {map} $mapA - The reference map. +/// @param {map} $mapB - The map to compare against the reference. +/// @return {list} A list of keys where $mapB diverges from $mapA. +``` + +Returns a list of keys where $mapB diverges from $mapA. +Divergence occurs when: + 1. A key exists in $mapB but not in $mapA. + 2. A key exists in both maps but with different values. + +```scss +$mapA: ('foo': red, 'bar': yellow, 'baz': 10); +$mapB: ('foo': red, 'bar': green, 'baz': 10, 'fooBar': blue); + +$differences: map_ext.difference($mapA, $mapB); +@debug $differences; // ('bar', 'fooBar') +``` + +## `string_ext` + +### `starts-with` {#string_ext.starts-with} + +```scss +string_ext.starts-with($string, $prefix) //=> boolean + +/// @param {string} $string - The string to test. +/// @param {string} $prefix - The prefix to check. +/// @return {boolean} True if the string starts with the given prefix. +``` + +Checks if a string starts with a given prefix. + +```scss +@debug string_ext.starts-with('var(--foo)', 'var('); // true +``` + +### `ends-with` {#string_ext.ends-with} + +```scss +string_ext.ends-with($string, $suffix) //=> boolean + +/// @param {string} $string - The string to test. +/// @param {string} $suffix - The suffix to check. +/// @return {boolean} True if the string ends with the given suffix. +``` + +Checks if a string ends with a given suffix. + +```scss +@debug string_ext.ends-with('var(--foo)', ')'); // true +``` + +### `trim-start` {#string_ext.trim-start} + +```scss +string_ext.trim-start($string) //=> string + +/// @param {string} $string - The string to trim. +/// @return {string} The string with whitespace trimmed from the start. +``` + +Trims leading whitespace from the start of a string. + +```scss +@debug string_ext.trim-start(' foo bar '); // "foo bar " +``` + +### `trim-end` {#string_ext.trim-end} + +```scss +string_ext.trim-end($string) //=> string + +/// @param {string} $string - The string to trim. +/// @return {string} The string with trailing whitespace trimmed from the end. +``` + +Trims trailing whitespace from the end of a string. + +```scss +@debug string_ext.trim-end(' foo bar '); // " foo bar" +``` + +### `trim` {#string_ext.trim} + +```scss +string_ext.trim($string) //=> string + +/// @param {string} $string - The string to trim. +/// @return {string} The string with leading and trailing whitespace trimmed. +``` + +Trims leading and trailing whitespace from a string. + +```scss +@debug string_ext.trim(' foo bar '); // "foo bar" +``` + +### `replace` {#string_ext.replace} + +```scss +string_ext.replace($string, $pattern, $replacement) //=> string + +/// @param {string} $string - The string to be searched. +/// @param {string} $pattern - The pattern to search for. +/// @param {string} $replacement - The value to replace the pattern. +/// @return {string} The string with the first match of pattern replaced by the replacement or the initial string itself. +``` + +Returns a new string with the first match of a pattern replaced by a given +string. + +```scss +@debug string_ext.replace('foo bar baz', 'bar', 'quux'); // "foo quux baz" +``` + +### `replace-all` {#string_ext.replace-all} + +```scss +string_ext.replace-all($string, $pattern, $replacement) //=> string + +/// @param {string} $string - The string to be searched. +/// @param {string} $pattern - The pattern to search for. +/// @param {string} $replacement - The value to replace the pattern. +/// @return {string} The string with all matches of pattern replaced by the replacement or the initial string itself. +``` + +Returns a new string with all matches of a pattern replaced by a given +string. + +```scss +@debug string_ext.replace-all('foo bar baz', 'ba', 'qua'); // "foo quar quaz" +``` + +### `replace-start` {#string_ext.replace-start} + +```scss +string_ext.replace-start($string, $prefix, $replacement) //=> string + +/// @param {string} $string - The string to be searched. +/// @param {string} $prefix - The prefix string to replace. +/// @param {string} $replacement - The value to replace the prefix. +/// @return {string} The string with the prefix replaced. +``` + +Returns a new string that replaces a prefix at the start of the string. + +```scss +@debug string_ext.replace-start('var(--foo)', 'var(', ''); // "--foo)" +``` + +### `replace-end` {#string_ext.replace-end} + +```scss +string_ext.replace-end($string, $suffix, $replacement) //=> string + +/// @param {string} $string - The string to be searched. +/// @param {string} $suffix - The suffix string to replace. +/// @param {string} $replacement - The value to replace the suffix. +/// @return {string} The string with the suffix trimmed from the end. +``` + +Returns a new string that replaces a suffix at the end of the string. + +```scss +@debug string_ext.replace-end('var(--foo)', ')', ''); // "var(--foo" +``` + +## `throw` + +### `get-error` {#throw.get-error} + +```scss +throw.get-error($error, $errors) + +/// @param {*} $error - The value to check. +/// @param {list} $errors - Additional values to check. Useful for checking multiple errors at the same time. +/// @return {string|boolean} The error string if any value is an error, or false otherwise. +``` + +Returns false if none of the given values are error strings, or returns an +error string if any value has an error. + +This is used to support testing error behavior with `sass-true`, since +`@error` messages cannot be caught at build time. + +```scss +// A function that may return an "ERROR:" string in a test. +@function get-value($map, $key) { + @if meta.type-of($map) != 'map' { + // Identical to `@error 'ERROR: Arg is not a map'` outside of tests. + @return throw.error('Arg is not a map'); + } + @return map.get($map, $key); +} + +// A function that needs to handle potential errors from other functions. +@function mix-primary-on-surface($values) { + $primary: get-value($values, 'primary'); + $surface: get-value($values, 'surface'); + $error: throw.get-error($primary, $surface); + @if $error { + // Return early to guard logic against additional errors since + // $primary or $surface may be a string instead of a color. + @return $error; + } + + @return color.mix($primary, $surface, 10%); +} +``` + +Note: `throw.error()` and `throw.get-error()` are only useful when testing +error behavior using `sass-true`. If you are not testing a function, use +`@error` instead. + +```scss +// In a `sass-true` test, `throw.get-error()` can be used to assert that +// an error is thrown. +@use 'true' as test with ($catch-errors: true); + +@include test.describe('module.get-value()') { + @include test.it('throws an error if the value is not a map') { + $result: module.get-value('not a map', 'primary'); + @include test.assert-truthy(throw.get-error($result), '$result is an error'); + } +} +``` + +## `type` + +### `matches` {#type.matches} + +```scss +type.matches($value, $type-string) //=> boolean + +/// @param {*} $value - The value to check the type of. +/// @param {string} $type-string - The type to check. May be multiple types separated by `|`. +/// @return {boolean} True if the value matches the type string. +``` + +Returns true if the given value matches the provided type string. + +The type string supports multiple types separated by `|`, such as +`'string|null'`. Type options are any values returned by `meta.type-of()`. + +```scss +@function is-empty($value) { + @if type.matches($value, 'list|map') { + @return list.length($value) == 0; + } + @if type.matches($value, 'string') { + @return $value == ''; + } + @return type.matches($value, 'null'); +} +``` + +## `var` + +### `create` {#var.create} + +```scss +var.create($name, $fallback) //=> string + +/// @param {string} $name - The name of the custom property. +/// @param {*} $fallback [null] - Optional `var()` fallback value. +/// @return {string} A custom property `var()` string. +``` + +Creates a custom property `var()` string. + +```scss +@debug var.create(--foo); // "var(--foo)" +@debug var.create(--foo, 8px); // "var(--foo, 8px)" +``` + +### `name` {#var.name} + +```scss +var.name($var) //=> string + +/// @param {string} $var - A custom property `var()` string. +/// @return {string} The custom property variable name. +/// @throw If the value is not a custom property. +``` + +Returns the custom property variable name of `var()` string. + +```scss +$var: var(--foo); +@debug var.name($var); // "--foo" +``` + +### `fallback` {#var.fallback} + +```scss +var.fallback($var) //=> string + +/// @param {string} $var - A custom property `var()` string. +/// @return {string} The fallback value of the `var()` string. May be null if the `var()` does not have a fallback value. +/// @throw If the value is not a custom property. +``` + +Returns the fallback value of a custom property `var()` string. The value +may be null if the `var()` does not have a fallback value. + +```scss +$var: var(--foo, var(--bar, 8px)); +@debug var.fallback($var); // "var(--bar, 8px)" +``` + +### `deep-fallback` {#var.deep-fallback} + +```scss +var.deep-fallback($var) //=> string + +/// @param {string} $var - A custom property `var()` string. +/// @return {string} The deep fallback value of the `var()` string. May be null if the `var()` does not have a fallback value. +/// @throw If the value is not a custom property. +``` + +Returns the deep fallback value of a custom property `var()` string. The +value may be null if the `var()` does not have a fallback value. + +If a fallback value is another `var()`, this function will return the final +concrete value in the chain. + +```scss +$var: var(--foo, var(--bar, 8px)); +@debug var.deep-fallback($var); // "8px" +``` + +### `set-fallback` {#var.set-fallback} + +```scss +var.set-fallback($var, $new-fallback) //=> string + +/// @param {string} $var - A custom property `var()` string. +/// @param {*} $new-fallback - The new fallback value. May be null to remove a value. +/// @return {string} A custom property `var()` string with the new fallback value. +/// @throw If the value is not a custom property. +``` + +Creates a new custom property `var()` string and returns it with the +specified new fallback value. + +```scss +$var: var(--foo, var(--bar, 8px)); +$new-var: set-fallback($var, 16px); +@debug $new-var; // "var(--foo, 16px)" +``` + +### `deep-set-fallback` {#var.deep-set-fallback} + +```scss +var.deep-set-fallback($var, $new-fallback) //=> string + +/// @param {string} $var - A custom property `var()` string. +/// @param {*} $new-fallback - The new fallback value. May be null to remove a value. +/// @return {string} A custom property `var()` string with the new deep fallback value. +/// @throw If the value is not a custom property. +``` + +Creates a new custom property `var()` string and returns it with the +specified new deep fallback value. + +If the provided `var()` string's fallback value is another `var()`, this +function will set the final fallback value in the chain. + +```scss +$var: var(--foo, var(--bar, 8px)); +$new-var: var.deep-set-fallback($var, 16px); +@debug $new-var; // "var(--foo, var(--bar, 16px))" +``` + +### `is-var` {#var.is-var} + +```scss +var.is-var($var) //=> boolean + +/// @param {*} $var - The value to test. +/// @return {boolean} True if the value is a custom property `var()` string, or false if not. +``` + +Indicates whether or not a value is a custom property `var()` string. + +```scss +$var: var(--foo); +@debug var.is-var($var); // true +``` diff --git a/package.json b/package.json index 86648f1fce..577ee434b2 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "build:catalog": "wireit", "build:scripts": "wireit", "update-docs": "wireit", + "update-docs:components": "wireit", + "update-docs:sass-ext": "wireit", "update-size": "wireit" }, "type": "module", @@ -169,6 +171,12 @@ "clean": "if-file-deleted" }, "update-docs": { + "dependencies": [ + "update-docs:components", + "update-docs:sass-ext" + ] + }, + "update-docs:components": { "command": "node scripts/analyzer/update-docs.js", "files": [ "docs/components/*.md", @@ -184,6 +192,19 @@ "build:scripts" ] }, + "update-docs:sass-ext": { + "command": "node scripts/update-sass-ext-docs.js --input=./sass/ext/ --output=./docs/sass/sass-ext.md", + "files": [ + "docs/sass/sass-ext.md", + "sass/ext/_*.scss", + "!sass/ext/*_test.scss", + "scripts/update-sass-ext-docs.js" + ], + "output": [], + "dependencies": [ + "build:scripts" + ] + }, "update-size": { "command": "node scripts/size/update-size.js", "dependencies": [ diff --git a/sass/ext/_assert.scss b/sass/ext/_assert.scss index ad19557d8f..c708d243cb 100644 --- a/sass/ext/_assert.scss +++ b/sass/ext/_assert.scss @@ -50,8 +50,8 @@ @return throw.error($message, $source); } -/// Asserts that the argument is a specific type. If it is, the argument is -/// returned, otherwise an error is thrown. +/// Asserts that the argument is not a specific type. The argument is returned +/// if it does not match. An error is thrown if the argument matches the type. /// /// @example scss /// @function get-strict($map, $key) { diff --git a/sass/ext/_throw.scss b/sass/ext/_throw.scss index cca14dfcf9..8316cf6127 100644 --- a/sass/ext/_throw.scss +++ b/sass/ext/_throw.scss @@ -32,10 +32,10 @@ /// @function mix-primary-on-surface($values) { /// $primary: get-value($values, 'primary'); /// $surface: get-value($values, 'surface'); -/// $error: throw.get-error($primary, $secondary); +/// $error: throw.get-error($primary, $surface); /// @if $error { /// // Return early to guard logic against additional errors since -/// // $primary or $secondary may be a string instead of a color. +/// // $primary or $surface may be a string instead of a color. /// @return $error; /// } /// diff --git a/sass/ext/_var.scss b/sass/ext/_var.scss index 52e141a197..7170c06f87 100644 --- a/sass/ext/_var.scss +++ b/sass/ext/_var.scss @@ -46,7 +46,7 @@ /// /// @example scss /// $var: var(--foo); -/// @debug var.name($var); // "foo" +/// @debug var.name($var); // "--foo" /// /// @param {string} $var - A custom property `var()` string. /// @return {string} The custom property variable name. diff --git a/scripts/update-sass-ext-docs.ts b/scripts/update-sass-ext-docs.ts new file mode 100644 index 0000000000..38c523830e --- /dev/null +++ b/scripts/update-sass-ext-docs.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as util from 'util'; + +/** + * A Sassdoc comment block. + */ +interface SassdocComment { + /** + * The content of the Sassdoc comment block, without the forward slashes + * (`///`). + */ + content: string; + /** + * The Sass symbol the comment block is attached to (ex: `@function foo`, + * `@mixin bar`, `$baz`). + */ + symbol: string; +} + +/** + * A sassdoc file. + */ +interface SassdocModule { + /** + * The Sass file's module name (ex: `foo-ext` for `_foo-ext.scss`). + */ + name: string; + /** + * The Sassdoc comments in the file. + */ + comments: SassdocComment[]; +} + +function parseSassFile(sassFile: string): SassdocModule { + const content = fs.readFileSync(sassFile, 'utf-8'); + + const comments: SassdocComment[] = []; + let sassdocLines: string[] = []; + for (const line of content.split('\n')) { + if (line.startsWith('///')) { + sassdocLines.push(line.replace(/^\/\/\/\s?/, '')); + continue; + } + + const symbolMatch = line.match(/(?:@function|@mixin)\s*(\$?[\w-]+)/); + if (symbolMatch && sassdocLines.length) { + const symbol = symbolMatch[1]; + // Ignore private documented symbols. + if (!symbol.startsWith('_')) { + comments.push({ + symbol, + content: sassdocLines.join('\n'), + }); + } + + sassdocLines = []; + continue; + } + } + + return { + name: path.basename(sassFile).replace(/^_|\.scss$/g, ''), + comments, + }; +} + +const TWO_SPACES = ' '; +const FOUR_SPACES = ' '; + +function sassdocToMarkdown( + comment: SassdocComment, + moduleName: string, +): string { + const header = `### \`${comment.symbol}\` {#${moduleName}.${comment.symbol}}\n\n`; + let markdown = ''; + + const lines = comment.content.split('\n'); + const annotations: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + const exampleMatch = line.match(/^@example(?:\s-?(\w+))?/); + if (exampleMatch) { + const exampleLines = []; + while (lines[i + 1] === '' || lines[i + 1]?.startsWith(TWO_SPACES)) { + exampleLines.push(lines[i + 1].replace(/^\s\s/, '')); + i++; + } + + const exampleLang = exampleMatch[1] || 'scss'; + const exampleMarkdown = + '```' + `${exampleLang}\n` + exampleLines.join('\n') + '```\n\n'; + markdown += exampleMarkdown; + continue; + } + + const annotationMatch = line.match(/^@(\w+)/); + if (annotationMatch) { + const annotationLines = [line]; + // Collect multi-line annotation lines. + while (lines[i + 1]?.startsWith(FOUR_SPACES)) { + annotationLines.push(lines[i + 1].trim()); + i++; + } + + // Annotations are listed at the end of the markdown. + annotations.push(annotationLines.join(' ')); + continue; + } + + markdown += `${line}\n`; + } + + const params = annotations + .filter((annotation) => annotation.startsWith('@param')) + .map((annotation) => annotation.match(/(\$[\w-]+)/)?.[1]); + const returnType = annotations + .find((annotation) => annotation.startsWith('@return')) + ?.match(/@return\s{(\w+)}/)?.[1]; + const returnComment = returnType ? ` //=> ${returnType}` : ''; + const signature = + '```scss\n' + + `${moduleName}.${comment.symbol}(${params.join(', ')})${returnComment}` + + '\n\n' + + annotations.map((a) => `/// ${a}`).join('\n') + + '\n```\n\n'; + + return header + signature + markdown; +} + +const {values} = util.parseArgs({ + options: { + input: {type: 'string'}, + output: {type: 'string'}, + }, +}); + +if (!values.input || !values.output) { + throw new Error( + 'Usage: update-sass-ext-docs --input=path/to/sass/ext/ --output=path/to/sass-ext.md', + ); +} + +const outputPath = values.output; +const sassExtPath = values.input; +const sassdocModules = fs + .readdirSync(sassExtPath) + .filter( + (file) => + file.startsWith('_') && file.endsWith('.scss') && !file.includes('test'), + ) + .map((file) => parseSassFile(path.join(sassExtPath, file))); + +let generatedDocs = ``; +for (const sassdocModule of sassdocModules) { + generatedDocs += `## \`${sassdocModule.name}\`\n\n`; + for (const comment of sassdocModule.comments) { + generatedDocs += sassdocToMarkdown(comment, sassdocModule.name); + } +} + +const mdContent = fs.readFileSync(outputPath, 'utf-8'); +const MARKER = '\n'; +const newMdContent = + mdContent.substring(0, mdContent.indexOf(MARKER)) + + MARKER + + '\n' + + generatedDocs.trim() + + '\n'; + +fs.writeFileSync(outputPath, newMdContent); + +console.log('Updated sass-ext.md');