├── .nvmrc ├── .browserslistrc ├── test ├── js │ ├── fixtures │ │ ├── fonts │ │ │ └── sample.ttf │ │ ├── img │ │ │ └── favicon.ico │ │ ├── icons │ │ │ ├── not-an-svg-icon.png │ │ │ ├── ok.svg │ │ │ └── warning.svg │ │ ├── markdown │ │ │ ├── complex.md │ │ │ ├── simple.md │ │ │ ├── input.json │ │ │ └── expected.json │ │ ├── scss │ │ │ ├── import.scss │ │ │ └── tools.scss │ │ ├── css │ │ │ ├── main.css.map │ │ │ ├── other.css.map │ │ │ ├── json.css │ │ │ ├── main.css │ │ │ └── custom-preview.css │ │ ├── templates │ │ │ ├── base.njk │ │ │ └── macros.njk │ │ └── groupName │ │ │ ├── input.json │ │ │ └── expected.json │ ├── testGetMarkdown.js │ ├── testMarkdown.js │ ├── testGroupName.js │ ├── testByGroup.js │ ├── testAssets.js │ ├── testGetNunjucksEnv.js │ ├── test.js │ ├── testRender.js │ └── testTemplates.js ├── sass │ ├── test.scss │ ├── _utilities.scss │ ├── test_sass.js │ └── utilities │ │ ├── _json-api.scss │ │ ├── _maps.scss │ │ └── _json-encode.scss └── clientjs │ ├── utils.js │ └── setupTests.js ├── .prettierrc.yml ├── __mocks__ ├── fileMock.js └── jsonMock.js ├── assets ├── img │ └── favicon.ico ├── svg │ ├── menu.svg │ └── logo.svg └── js │ ├── init.js │ └── base.js ├── dist └── img │ └── favicon.ico ├── scss ├── iframes │ ├── _index.scss │ ├── _font.scss │ ├── _base.scss │ ├── _size.scss │ └── _color.scss ├── layout │ ├── _root.scss │ ├── _index.scss │ ├── _main.scss │ ├── _regions.scss │ ├── _nav.scss │ └── _banner.scss ├── patterns │ ├── _index.scss │ ├── _forms.scss │ └── _type.scss ├── initial │ ├── _index.scss │ ├── _sample-previews.scss │ ├── _icons.scss │ └── _root.scss ├── previews │ ├── _index.scss │ ├── _examples.scss │ ├── _icon.scss │ ├── _code.scss │ └── _highlight.scss ├── iframes.scss ├── component │ ├── _index.scss │ ├── _search.scss │ ├── _breadcrumb.scss │ ├── _project-meta.scss │ ├── _footer.scss │ ├── _item.scss │ └── _nav.scss ├── json.scss ├── samples │ ├── _index.scss │ ├── _icons.scss │ ├── _variables.scss │ └── _mixins-functions.scss ├── main.scss ├── config │ ├── _index.scss │ ├── _z-index.scss │ ├── _fonts.scss │ ├── _banner.scss │ ├── _type.scss │ ├── _abstracts.scss │ ├── _scale.scss │ └── _colors.scss ├── _utilities.scss └── utilities │ ├── _config.scss │ └── _json-encode.scss ├── .prettierignore ├── fonts └── rockingham │ ├── rockingham-bold-webfont.ttf │ ├── rockingham-bold-webfont.woff │ ├── rockingham-bold-webfont.woff2 │ ├── rockingham-italic-webfont.ttf │ ├── rockingham-italic-webfont.woff │ ├── rockingham-regular-webfont.ttf │ ├── rockingham-italic-webfont.woff2 │ ├── rockingham-regular-webfont.woff │ ├── rockingham-regular-webfont.woff2 │ ├── rockingham-bolditalic-webfont.ttf │ ├── rockingham-bolditalic-webfont.woff │ └── rockingham-bolditalic-webfont.woff2 ├── .vscode ├── extensions.json └── settings.json ├── postcss.config.js ├── .stylelintignore ├── templates ├── client │ ├── search_result.njk │ └── search_results.njk ├── _icon_template.lodash ├── doc.njk ├── group.njk ├── example │ └── base.njk ├── search.njk ├── ratios │ └── base.njk ├── index.njk ├── colors │ └── base.njk ├── sizes │ └── base.njk ├── _icons.svg ├── icons │ └── base.njk ├── fonts │ ├── base.njk │ └── font_face.njk ├── item │ ├── _item.njk │ └── example.macros.njk ├── base.njk ├── utility.macros.njk └── nav.macros.njk ├── .yarnrc.yml ├── .nycrc ├── .gitignore ├── lib ├── utils │ ├── getMarkdown.js │ ├── prose.js │ ├── getCustomNunjucksEnv.js │ ├── assets.js │ ├── ensureSassJson.js │ ├── byGroup.js │ ├── getCustomPreviewCss.js │ ├── sprites.js │ ├── sass.js │ ├── fonts.js │ ├── render.js │ ├── groupName.js │ ├── markdown.js │ └── templates.js ├── annotations │ ├── name.js │ ├── access.js │ ├── colors.js │ ├── ratios.js │ ├── sizes.js │ ├── icons.js │ └── example.js └── renderHerman.js ├── sass-json-loader.js ├── babel.config.js ├── scripts └── compile-sprites.js ├── .github ├── dependabot.yml └── workflows │ ├── publish-docs.yml │ └── test.yml ├── LICENSE ├── .stylelintrc.yml ├── sassdoc-webpack-plugin.js ├── CONTRIBUTING.md ├── index.js ├── eslint.config.js ├── README.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v24 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /test/js/fixtures/fonts/sample.ttf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/js/fixtures/img/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | -------------------------------------------------------------------------------- /test/js/fixtures/icons/not-an-svg-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/js/fixtures/markdown/complex.md: -------------------------------------------------------------------------------- 1 | # A complex file 2 | -------------------------------------------------------------------------------- /test/js/fixtures/markdown/simple.md: -------------------------------------------------------------------------------- 1 | # A simple file 2 | -------------------------------------------------------------------------------- /test/js/fixtures/scss/import.scss: -------------------------------------------------------------------------------- 1 | body { 2 | border: 1px; 3 | } 4 | -------------------------------------------------------------------------------- /test/js/fixtures/scss/tools.scss: -------------------------------------------------------------------------------- 1 | @mixin color { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = 'test-file-stub'; 4 | -------------------------------------------------------------------------------- /test/sass/test.scss: -------------------------------------------------------------------------------- 1 | // Sass Tests 2 | // ========== 3 | 4 | // tests 5 | @use 'utilities'; 6 | -------------------------------------------------------------------------------- /assets/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/assets/img/favicon.ico -------------------------------------------------------------------------------- /dist/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/dist/img/favicon.ico -------------------------------------------------------------------------------- /scss/iframes/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'base'; 2 | @forward 'font'; 3 | @forward 'size'; 4 | @forward 'color'; 5 | -------------------------------------------------------------------------------- /scss/layout/_root.scss: -------------------------------------------------------------------------------- 1 | // Root Layout 2 | // =========== 3 | 4 | [data-herman] { 5 | overflow-x: hidden; 6 | } 7 | -------------------------------------------------------------------------------- /scss/patterns/_index.scss: -------------------------------------------------------------------------------- 1 | // Pattern Manifest 2 | // ================ 3 | 4 | @forward 'type'; 5 | @forward 'forms'; 6 | -------------------------------------------------------------------------------- /test/js/fixtures/css/main.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["../scss/import.scss"],"names":[],"mappings":"AAAA;EACE","file":"main.css"} -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | !.* 2 | .git/ 3 | .husky/ 4 | .pnp.cjs 5 | .pnp.loader.mjs 6 | .vscode/ 7 | .yarn/ 8 | .yarnrc.yml 9 | coverage/ 10 | dist/ 11 | -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-bold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-bold-webfont.ttf -------------------------------------------------------------------------------- /scss/initial/_index.scss: -------------------------------------------------------------------------------- 1 | // Initial Manifest 2 | // ================ 3 | 4 | @forward 'root'; 5 | @forward 'icons'; 6 | @forward 'sample-previews'; 7 | -------------------------------------------------------------------------------- /test/js/fixtures/css/other.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["../scss/import.scss"],"names":[],"mappings":"AAAA;EACE","file":"main.css"} -------------------------------------------------------------------------------- /test/js/fixtures/templates/base.njk: -------------------------------------------------------------------------------- 1 | Title | Herman Documentation 2 |
3 |

I say: Hello {{ name }}!

4 |
5 | -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-bold-webfont.woff -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-bold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-bold-webfont.woff2 -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-italic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-italic-webfont.ttf -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-italic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-italic-webfont.woff -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-regular-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-regular-webfont.ttf -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-italic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-italic-webfont.woff2 -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-regular-webfont.woff -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-regular-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-regular-webfont.woff2 -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-bolditalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-bolditalic-webfont.ttf -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-bolditalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-bolditalic-webfont.woff -------------------------------------------------------------------------------- /fonts/rockingham/rockingham-bolditalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddbird/sassdoc-theme-herman/HEAD/fonts/rockingham/rockingham-bolditalic-webfont.woff2 -------------------------------------------------------------------------------- /scss/previews/_index.scss: -------------------------------------------------------------------------------- 1 | // Preview Manifest 2 | // ================ 3 | 4 | @forward 'highlight'; 5 | @forward 'code'; 6 | @forward 'examples'; 7 | @forward 'icon'; 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "stylelint.vscode-stylelint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /__mocks__/jsonMock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | sizes: { 5 | 'layout-sizes': { 6 | 'nav-break': '65em', 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const autoprefixer = require('autoprefixer'); 4 | 5 | module.exports = { 6 | plugins: [autoprefixer({ cascade: false })], 7 | }; 8 | -------------------------------------------------------------------------------- /test/sass/_utilities.scss: -------------------------------------------------------------------------------- 1 | // Utility Tests 2 | // ============= 3 | 4 | @forward 'utilities/json-encode'; 5 | @forward 'utilities/json-api'; 6 | @forward 'utilities/maps'; 7 | -------------------------------------------------------------------------------- /scss/layout/_index.scss: -------------------------------------------------------------------------------- 1 | // Layout Manifest 2 | // =============== 3 | 4 | @forward 'root'; 5 | @forward 'regions'; 6 | @forward 'banner'; 7 | @forward 'nav'; 8 | @forward 'main'; 9 | -------------------------------------------------------------------------------- /scss/iframes.scss: -------------------------------------------------------------------------------- 1 | // Herman SassDoc Theme for 57 | 58 | {% endif %} 59 | 60 | {% endmacro %} 61 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish documentation 2 | on: 3 | release: # Run when stable releases are published 4 | types: [released] 5 | workflow_dispatch: # Run on-demand 6 | inputs: 7 | ref: 8 | description: Git ref to build docs from 9 | required: true 10 | default: main 11 | type: string 12 | 13 | jobs: 14 | push-branch: 15 | name: Build & push docs 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.ref }} 21 | steps: 22 | - name: Check out from release 23 | if: github.event_name == 'release' 24 | uses: actions/checkout@v6 25 | - name: Check out from manual input 26 | if: github.event_name == 'workflow_dispatch' 27 | uses: actions/checkout@v6 28 | with: 29 | ref: ${{ inputs.ref }} 30 | - run: corepack enable 31 | - uses: actions/setup-node@v6 32 | with: 33 | node-version-file: .nvmrc 34 | cache: yarn 35 | - run: yarn install 36 | - run: yarn build 37 | - name: Clone docs branch 38 | uses: actions/checkout@v6 39 | with: 40 | path: docs-branch 41 | ref: oddleventy-docs 42 | - name: Commit & push to docs branch 43 | run: | 44 | SHA=$(git rev-parse HEAD) 45 | cd docs-branch 46 | rm -rf herman/docs 47 | mkdir -p herman/docs 48 | cp -r ${{ github.workspace }}/docs/ herman/ 49 | git config user.name github-actions 50 | git config user.email github-actions@github.com 51 | git add -A . 52 | git commit --allow-empty \ 53 | -m "Update from https://github.com/${{ github.repository }}/commit/$SHA" \ 54 | -m "Full log: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" 55 | git push origin oddleventy-docs 56 | -------------------------------------------------------------------------------- /scss/component/_footer.scss: -------------------------------------------------------------------------------- 1 | // Footer Credit 2 | // ============= 3 | /// The OddBird credit in the navigation footer can be toggled 4 | /// using the [SassDoc `display.watermark` configuration setting](http://sassdoc.com/customising-the-view/#watermark-display). 5 | /// @group component-footer 6 | /// @example njk 7 | /// {% import 'utility.macros.njk' as utility %} 8 | /// 25 | 26 | @use 'pkg:accoutrement' as tools; 27 | @use '../config'; 28 | 29 | /// Layout for the footer credit. 30 | /// @group component-footer 31 | .footer-credit { 32 | border-top: 1px solid tools.color('theme-light'); 33 | display: flow-root; 34 | flex: 0 0 auto; 35 | font-size: tools.size('footer'); 36 | line-height: tools.size('rhythm'); 37 | padding-top: tools.size('gutter'); 38 | 39 | span { 40 | display: block; 41 | overflow: hidden; 42 | white-space: nowrap; 43 | } 44 | } 45 | 46 | /// Layout for the footer logo-icon. 47 | /// @group component-footer 48 | .footer-icon { 49 | #{config.$link} { 50 | color: inherit; 51 | float: left; 52 | margin-right: tools.size('half-shim'); 53 | } 54 | } 55 | 56 | /// Special styles for the footer links. 57 | /// @group component-footer 58 | .footer-link { 59 | #{config.$link} { 60 | font-weight: bold; 61 | text-decoration-color: transparent; 62 | } 63 | 64 | #{config.$focus} { 65 | text-decoration-color: currentcolor; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/utils/render.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const util = require('util'); 5 | 6 | const cheerio = require('cheerio'); 7 | const rename = require('gulp-rename'); 8 | const { Transform } = require('readable-stream'); 9 | const typogr = require('typogr'); 10 | const vfs = require('vinyl-fs'); 11 | 12 | // Asynchronously render the template ``tpl`` to the file ``dest`` using 13 | // Nunjucks env ``nunjucksEnv`` and context ``ctx``. 14 | module.exports = function render(nunjucksEnv, tpl, dest, ctx, rendered) { 15 | const parsedDest = path.parse(dest); 16 | const renderStr = util.promisify(nunjucksEnv.renderString); 17 | const transform = new Transform({ 18 | objectMode: true, 19 | transform: (file, enc, cb) => 20 | renderStr 21 | .call(nunjucksEnv, file.contents.toString(enc), ctx) 22 | .then((html) => typogr(html).widont()) 23 | .then((html) => { 24 | // Store page title and contents for site search indexing 25 | if (rendered) { 26 | const $ = cheerio.load(html); 27 | let title = $('title').first().text().trim(); 28 | // Strip " | Documentation" from page titles 29 | if (title.includes(' | ') && title.endsWith(' Documentation')) { 30 | title = title.substring(0, title.indexOf(' | ')); 31 | } 32 | const contents = $('[data-page]') 33 | .text() 34 | .replace(/\s+/g, ' ') 35 | .trim(); 36 | rendered.push({ 37 | filename: parsedDest.base, 38 | title, 39 | contents, 40 | }); 41 | } 42 | file.contents = Buffer.from(html); 43 | cb(null, file); 44 | }) 45 | .catch(cb), 46 | }); 47 | 48 | return new Promise((resolve, reject) => { 49 | vfs 50 | .src(tpl) 51 | .pipe(transform) 52 | .pipe(rename(parsedDest.base)) 53 | .pipe(vfs.dest(parsedDest.dir)) 54 | .on('error', reject) 55 | .on('finish', resolve); 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /sassdoc-webpack-plugin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | 'use strict'; 4 | 5 | const fs = require('fs/promises'); 6 | const path = require('path'); 7 | 8 | const yaml = require('js-yaml'); 9 | const set = require('lodash/set'); 10 | const sassdoc = require('sassdoc'); 11 | 12 | const getAsset = function (entry, ext = 'css') { 13 | if (!entry) { 14 | return undefined; 15 | } 16 | let asset; 17 | for (const thisPath of entry) { 18 | if (thisPath.slice(0 - (ext.length + 1)) === `.${ext}`) { 19 | asset = thisPath; 20 | } 21 | } 22 | return asset; 23 | }; 24 | 25 | class SassDocPlugin { 26 | constructor(opts, pluginOptions) { 27 | let options = opts; 28 | if (!options) { 29 | try { 30 | // Load .sassdocrc configuration 31 | options = yaml.load( 32 | fs.readFileSync(path.join(process.cwd(), '.sassdocrc'), 'utf-8'), 33 | ); 34 | } catch (err) { 35 | console.warn(err); 36 | throw new Error(`Invalid or no .sassdocrc found in: ${process.cwd()}`); 37 | } 38 | } 39 | 40 | if (!options.src) { 41 | throw new Error('SassDoc Webpack Plugin: `src` is not defined'); 42 | } 43 | 44 | this.options = options; 45 | this.pluginOptions = pluginOptions; 46 | } 47 | 48 | apply(compiler) { 49 | const self = this; 50 | 51 | compiler.hooks.afterEmit.tapPromise('SassDocPlugin', (compilation) => { 52 | if (self.pluginOptions && self.pluginOptions.assetPaths) { 53 | const statsJSON = compilation.getStats().toJson(); 54 | const outputPath = self.pluginOptions.outputPath || process.cwd(); 55 | for (const { entry, ext, optPath } of self.pluginOptions.assetPaths) { 56 | const asset = getAsset(statsJSON.assetsByChunkName[entry], ext); 57 | if (asset) { 58 | set(self.options, optPath, path.join(outputPath, asset)); 59 | } 60 | } 61 | } 62 | 63 | return sassdoc(self.options.src, self.options).catch(console.error); 64 | }); 65 | } 66 | } 67 | 68 | module.exports = SassDocPlugin; 69 | -------------------------------------------------------------------------------- /scss/config/_type.scss: -------------------------------------------------------------------------------- 1 | // Typography Config 2 | // ================= 3 | 4 | @use 'pkg:accoutrement' as tools; 5 | 6 | /// ## macro `link_if()` 7 | /// This Nunjucks utility macro returns either 8 | /// an anchor tag, or span, depending on 9 | /// the truthyness of the `url` argument. 10 | /// 11 | /// - `content` :: {`string`}
12 | /// The text/html contents of the link. 13 | /// - `url=none` :: {`string` | `none`}
14 | /// The link-target URL, if any is available. 15 | /// When there is no URL provided, we return a span. 16 | /// - `attrs={}` :: {`dictionary`}
17 | /// A dictionary of arbitrary html attributes 18 | /// to include on the returned link or span. 19 | /// 20 | /// @group config-utils 21 | /// 22 | /// @example njk 23 | /// {% import 'utility.macros.njk' as utility %} 24 | /// {{ utility.link_if(content='stacy', url='#', attrs={'data-sassdoc': 'font-name'} ) }} 25 | /// {{ utility.link_if(content='not stacy', url=none) }} 26 | 27 | // Link 28 | // ---- 29 | /// Shortcut for `link` and `visited` pseudo-classes. 30 | /// @group config-utils 31 | /// @example scss 32 | /// a { 33 | /// #{config.$link} { 34 | /// color: blue; 35 | /// } 36 | /// } 37 | $link: '&:link, &:visited'; 38 | 39 | // Focus 40 | // ----- 41 | /// Shortcut for `hover`, `focus`, and `active` pseudo-classes. 42 | /// @group config-utils 43 | /// @example scss 44 | /// a { 45 | /// #{config.$focus} { 46 | /// color: red; 47 | /// } 48 | /// } 49 | $focus: '&:hover, &:focus, &:active'; 50 | 51 | // Invert Colors 52 | // ------------- 53 | /// Invert the colors of a block, creating light-on-dark text and links. 54 | /// @group config-utils 55 | /// @example scss 56 | /// .invert-colors { 57 | /// @include config.invert-colors; 58 | /// } 59 | /// @example html 60 | ///
61 | /// You shall sojourn at Paris, Rome, and Naples. 62 | ///
63 | @mixin invert-colors { 64 | @include tools.contrasted('theme-dark'); 65 | 66 | [href] { 67 | #{$link} { 68 | color: inherit; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /scss/iframes/_size.scss: -------------------------------------------------------------------------------- 1 | @use 'pkg:accoutrement' as tools; 2 | 3 | [data-herman-size='overflow'] { 4 | max-width: 100%; 5 | overflow-x: auto; 6 | } 7 | 8 | [data-herman-size~='highlight'] { 9 | --herman-size-bar: #{tools.color('action')}; 10 | --herman-cell-color: #{tools.color('callout')}; 11 | --herman-label-color: #{tools.color('callout')}; 12 | } 13 | 14 | [data-herman-viz] { 15 | margin: var(--herman-viz-before, 0.25rem) auto 16 | var(--herman-viz-after, 0.25rem) 0; 17 | } 18 | 19 | [data-herman-size~='viz-row'] { 20 | [data-herman-viz='bar'] { 21 | --herman-viz-before: 0; 22 | --herman-viz-after: 0.5rem; 23 | } 24 | } 25 | 26 | [data-herman-table~='text'] { 27 | --herman-viz-before: 1rem; 28 | --herman-viz-after: 1rem; 29 | --herman-label-color: #{tools.color('callout')}; 30 | } 31 | 32 | [data-herman-viz~='bar'] { 33 | background-color: var(--herman-size-bar, #{tools.color('border')}); 34 | background-image: var( 35 | --herman-size-bar-image, 36 | linear-gradient( 37 | to left, 38 | tools.color('underline') 1px, 39 | transparent 1px, 40 | transparent 41 | ), 42 | linear-gradient( 43 | to left, 44 | tools.color( 45 | 'border-light', 46 | ( 47 | 'rgba': 0.5, 48 | ) 49 | ) 50 | 1px, 51 | transparent 1px, 52 | transparent 53 | ), 54 | linear-gradient( 55 | to left, 56 | tools.color( 57 | 'border-light', 58 | ( 59 | 'rgba': 0.25, 60 | ) 61 | ) 62 | 1px, 63 | transparent 1px, 64 | transparent 65 | ) 66 | ); 67 | background-position: 0 100%; 68 | background-repeat: repeat-x; 69 | background-size: 70 | 100px 75%, 71 | 10px 50%, 72 | 5px 25%; 73 | border-radius: tools.size('quarter-shim'); 74 | display: block; 75 | min-height: 1.5em; 76 | } 77 | 78 | [data-herman-viz~='border'] { 79 | --herman-size-bar: #{tools.color('slight')}; 80 | --herman-size-bar-image: none; 81 | 82 | border-color: var(--herman-border-color, #{tools.color('border')}); 83 | border-style: solid; 84 | } 85 | -------------------------------------------------------------------------------- /lib/annotations/sizes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const renderIframe = require('../renderIframe'); 4 | const ensureSassJson = require('../utils/ensureSassJson'); 5 | 6 | /** 7 | * Custom `@sizes` annotation. Accepts optional 8 | * map-variable name (used to access data from Sass JSON), 9 | * followed by optional curly-bracketed `style` argument 10 | * (defaults to `text`). 11 | */ 12 | module.exports = (env) => { 13 | // Matches first word and text within `{}` 14 | const RE = /([\w-]+)?\s*(?:\{(.*)\})?/; 15 | return { 16 | name: 'sizes', 17 | multiple: false, 18 | // expects e.g. 'my-style {ruler}' 19 | // returns object { 20 | // key: "my-style", 21 | // style: "ruler", 22 | // } 23 | parse: (raw) => { 24 | const obj = { 25 | key: '', 26 | style: '', 27 | }; 28 | const match = RE.exec(raw.trim()); 29 | if (match[1]) { 30 | obj.key = match[1]; 31 | } 32 | if (match[2]) { 33 | obj.style = match[2]; 34 | } 35 | return obj; 36 | }, 37 | resolve: (data) => { 38 | const promises = []; 39 | data.forEach((item) => { 40 | if (!item.sizes) { 41 | return; 42 | } 43 | let promise = ensureSassJson(env, '@sizes'); 44 | promise = promise.then(() => { 45 | const key = 46 | item.sizes.key || 47 | (item.context && item.context.origName) || 48 | (item.context && item.context.name); 49 | item.sizes.key = key; 50 | const sizesData = 51 | env.sassjson && env.sassjson.sizes && env.sassjson.sizes[key]; 52 | if (!sizesData) { 53 | env.logger.warn( 54 | `Sassjson file is missing sizes "${key}" data. ` + 55 | 'Did you forget to `@include herman.add()` for these sizes?', 56 | ); 57 | return Promise.reject(); 58 | } 59 | return Promise.resolve(); 60 | }); 61 | promise = promise.then(() => renderIframe(env, item, 'sizes')); 62 | promises.push(promise); 63 | }); 64 | return Promise.all(promises); 65 | }, 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Herman 2 | 3 | Thanks for contributing to Herman development! 4 | 5 | Feature requests and bug reports 6 | can be filed on [GitHub][github]: 7 | 8 | - Write a title that summarizes the specific problem 9 | or feature request 10 | - Introduce the problem with steps to reproduce 11 | - Help reduce the problem to the smallest code sample possible, 12 | and provide the relevant code 13 | 14 | [github]: https://github.com/oddbird/sassdoc-theme-herman/issues 15 | 16 | If you are contributing code 17 | with new features or bug-fixes: 18 | 19 | - Fork the project, and create a branch for your contribution 20 | - Follow the development guide below to get Herman running locally 21 | - Write tests and documentation as necessary, 22 | and make sure all tests are passing 23 | - Open a pull request on [GitHub][github] 24 | 25 | We love having more people involved in the project, 26 | and everyone is welcome. 27 | As maintainers, we review all the code, 28 | and may provide feedback before accepting a PR. 29 | We're happy to work with you to make this the best 30 | (and friendliest) project we can. 31 | 32 | ## Development 33 | 34 | To install the necessary Node dependencies, run `yarn`. 35 | 36 | You can format and lint the project with `yarn lint` 37 | and run the unit tests with `yarn test`. 38 | 39 | To compile and minify the static assets -- 40 | as well as generate the documentation -- 41 | run `yarn build`. 42 | 43 | You can start up a local development server with `yarn serve`. 44 | 45 | Access the running server at `http://localhost:8080`. 46 | 47 | ## Code of Conduct 48 | 49 | As a company, 50 | we want to embrace the very differences 51 | that have made our collaborations successful, 52 | and work together to provide the best environment 53 | for learning, growing, working, and sharing ideas. 54 | It is imperative that [OddBird][oddbird] continue to be 55 | a welcoming, challenging, fun, and fair place to contribute. 56 | 57 | See our [Code of Conduct][coc] for details. 58 | We also recommend following the [Sass community guidelines][sass]. 59 | 60 | [oddbird]: https://www.oddbird.net/ 61 | [coc]: https://www.oddbird.net/conduct/ 62 | [sass]: https://sass-lang.com/community-guidelines 63 | -------------------------------------------------------------------------------- /lib/utils/groupName.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Based on sassdoc-extras `groupName`, 5 | * but allows for nested subgroups. 6 | * See http://sassdoc.com/extra-tools/#groups-aliases-groupname 7 | * and https://github.com/SassDoc/sassdoc-extras/blob/master/src/groupName.js 8 | */ 9 | 10 | const lowerCaseSlug = (obj, slug) => { 11 | if (slug !== slug.toLowerCase()) { 12 | obj[slug.toLowerCase()] = obj[slug]; 13 | delete obj[slug]; 14 | } 15 | }; 16 | 17 | module.exports = (ctx) => { 18 | const subgroupsByGroup = (ctx.subgroupsByGroup = {}); 19 | const orderedGroups = (ctx.orderedGroups = []); 20 | 21 | // Store list of subgroups with parent group name 22 | for (let slug of Object.keys(ctx.groups)) { 23 | if (typeof ctx.groups[slug] === 'string') { 24 | lowerCaseSlug(ctx.groups, slug); 25 | slug = slug.toLowerCase(); 26 | orderedGroups.push(slug); 27 | } else { 28 | const orderedSubgroups = []; 29 | for (let subgroup of Object.keys(ctx.groups[slug])) { 30 | lowerCaseSlug(ctx.groups[slug], subgroup); 31 | subgroup = subgroup.toLowerCase(); 32 | 33 | if (Object.prototype.hasOwnProperty.call(subgroupsByGroup, subgroup)) { 34 | ctx.logger.warn(`Group slugs must be unique: "${subgroup}"`); 35 | } else { 36 | orderedSubgroups.push(subgroup); 37 | subgroupsByGroup[subgroup] = slug; 38 | } 39 | } 40 | if (orderedSubgroups.length) { 41 | orderedGroups.push({ parent: slug, subgroups: orderedSubgroups }); 42 | } 43 | } 44 | } 45 | 46 | for (const item of ctx.data) { 47 | const group = {}; 48 | 49 | for (let slug of item.group) { 50 | slug = slug.toLowerCase(); 51 | 52 | if (Object.prototype.hasOwnProperty.call(ctx.groups, slug)) { 53 | group[slug] = ctx.groups[slug]; 54 | } else if (Object.prototype.hasOwnProperty.call(subgroupsByGroup, slug)) { 55 | const parentGroup = subgroupsByGroup[slug]; 56 | group[slug] = ctx.groups[parentGroup][slug]; 57 | } else { 58 | group[slug] = ctx.groups[slug] = slug; 59 | orderedGroups.push(slug); 60 | } 61 | } 62 | 63 | item.groupName = group; 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /lib/annotations/icons.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs/promises'); 4 | const path = require('path'); 5 | 6 | const nunjucks = require('nunjucks'); 7 | 8 | const renderIframe = require('../renderIframe'); 9 | const { templates } = require('../utils/templates'); 10 | 11 | const nunjucksEnv = nunjucks.configure(templates.baseDir); 12 | 13 | /** 14 | * Custom `@icons` annotation. Expects one argument: `iconspath`. 15 | * 16 | * Argument should be the relative path to a directory of icon svg files. 17 | * Sends to template context an `icons` item which is a list of icons, 18 | * where each one is an object with properties `name`, `path`, and `rendered` 19 | * (where the latter is the result of rendering the icon macro 20 | * with the icon's name as first argument). 21 | */ 22 | module.exports = (env) => ({ 23 | name: 'icons', 24 | multiple: false, 25 | parse: (raw) => raw.trim(), 26 | resolve: (data) => { 27 | const promises = []; 28 | data.forEach((item) => { 29 | if (!item.icons) { 30 | return; 31 | } 32 | let iconsPath = item.icons; 33 | if (!iconsPath.endsWith(path.sep)) { 34 | iconsPath = `${iconsPath}${path.sep}`; 35 | } 36 | item.iconsPath = iconsPath; 37 | const promise = fs 38 | .readdir(iconsPath) 39 | .then((iconFiles) => { 40 | const renderTpl = 41 | `{% import "utility.macros.njk" as it %}` + 42 | `{{ it.icon(iconName) }}`; 43 | item.icons = []; 44 | iconFiles.forEach((iconFile) => { 45 | if (path.extname(iconFile) === '.svg') { 46 | const iconName = path.basename(iconFile, '.svg'); 47 | const icon = { 48 | name: iconName, 49 | path: iconsPath, 50 | rendered: nunjucksEnv 51 | .renderString(renderTpl, { iconName }) 52 | .trim(), 53 | }; 54 | item.icons.push(icon); 55 | } 56 | }); 57 | return renderIframe(env, item, 'icon'); 58 | }) 59 | .catch((err) => { 60 | env.logger.warn( 61 | `Error reading directory: ${iconsPath}\n${err.message}`, 62 | ); 63 | }); 64 | promises.push(promise); 65 | }); 66 | return Promise.all(promises); 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /scss/component/_item.scss: -------------------------------------------------------------------------------- 1 | // Item Styles 2 | // =========== 3 | 4 | @use 'pkg:accoutrement' as tools; 5 | 6 | .item { 7 | margin-bottom: tools.size('spacer'); 8 | } 9 | 10 | [data-item-section] { 11 | margin-bottom: tools.size('gutter-plus'); 12 | } 13 | 14 | [data-item-section='header'] { 15 | .code-block, 16 | .text-block { 17 | margin-bottom: tools.size('shim'); 18 | } 19 | } 20 | 21 | .item-title { 22 | font-size: tools.size('h2'); 23 | } 24 | 25 | .item-subtitle { 26 | border-bottom: 1px solid tools.color('border-light'); 27 | color: tools.color('text-light'); 28 | font-weight: normal; 29 | margin-bottom: tools.size('shim'); 30 | } 31 | 32 | .item-subtitle-main { 33 | letter-spacing: 0.05em; 34 | text-transform: uppercase; 35 | 36 | &:not(:last-child) { 37 | @include tools.after(': '); 38 | } 39 | } 40 | 41 | .item-subtitle-supplement { 42 | @include tools.font-family('code'); 43 | 44 | letter-spacing: 0; 45 | padding-left: tools.size('half-shim'); 46 | } 47 | 48 | .item-type, 49 | .item-name, 50 | .item-value, 51 | .alias-title { 52 | @include tools.font-family('code'); 53 | 54 | display: inline-block; 55 | } 56 | 57 | .item-type, 58 | .item-value, 59 | .value-type, 60 | .item-note { 61 | color: tools.color('text-light'); 62 | display: inline-block; 63 | font-weight: normal; 64 | } 65 | 66 | .alias { 67 | color: tools.color('text-light'); 68 | font-style: italic; 69 | } 70 | 71 | .alias-title { 72 | font-style: normal; 73 | font-weight: bolder; 74 | } 75 | 76 | .param-list { 77 | margin-bottom: tools.size('shim'); 78 | } 79 | 80 | .param-title { 81 | color: tools.color('text-light'); 82 | font-size: tools.size('h3'); 83 | } 84 | 85 | .param-details { 86 | margin-top: tools.size('half-shim'); 87 | 88 | @include tools.above('item-break') { 89 | margin-left: tools.size('gutter'); 90 | } 91 | } 92 | 93 | // Requires 94 | // -------- 95 | .requires-wrapper { 96 | @include tools.above('item-break') { 97 | display: flex; 98 | margin-bottom: tools.size('gutter-plus'); 99 | 100 | [data-item-section] { 101 | flex: 1 1 40%; 102 | margin-bottom: 0; 103 | max-width: 100%; 104 | 105 | &:first-child { 106 | margin-right: tools.size('gutter'); 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /scss/iframes/_color.scss: -------------------------------------------------------------------------------- 1 | // Preview Layouts 2 | // =============== 3 | 4 | @use 'pkg:accoutrement' as tools; 5 | 6 | // Color Preview 7 | // ------------- 8 | 9 | [data-herman-preview='colors'] { 10 | margin: 0; 11 | } 12 | 13 | [data-herman-color-palette] { 14 | display: flex; 15 | flex-wrap: wrap; 16 | margin: tools.negative('shim'); 17 | 18 | @supports (display: grid) { 19 | display: grid; 20 | grid-auto-rows: auto; 21 | gap: tools.size('gutter'); 22 | grid-template-columns: repeat( 23 | auto-fit, 24 | minmax(tools.size('color-preview'), 1fr) 25 | ); 26 | margin: 0; 27 | } 28 | } 29 | 30 | [data-herman-color-preview] { 31 | align-items: stretch; 32 | display: flex; 33 | flex: 1 0 tools.size('color-preview'); 34 | flex-direction: column; 35 | font-size: tools.size('code'); 36 | margin: tools.size('shim'); 37 | 38 | @supports (display: grid) { 39 | margin: 0; 40 | } 41 | } 42 | 43 | // Color Swatch 44 | // ------------ 45 | 46 | [data-herman-color-swatch] { 47 | border: 1px solid tools.color('border'); 48 | height: tools.size('color-swatch'); 49 | min-width: tools.size('color-swatch'); 50 | position: relative; 51 | } 52 | 53 | [data-herman-transparency-grid], 54 | [data-herman-color-overlay] { 55 | inset: 0; 56 | position: absolute; 57 | } 58 | 59 | [data-herman-transparency-grid] { 60 | background: url('data:image/svg+xml;utf8,') 61 | center repeat scroll; 62 | background-size: 0.5em 0.5em; 63 | left: 50%; 64 | } 65 | 66 | // Color Info 67 | // ---------- 68 | 69 | [data-herman-color-name], 70 | [data-herman-color-value] { 71 | display: block; 72 | padding: tools.size('half-shim'); 73 | } 74 | 75 | [data-herman-label='color'] { 76 | --herman-label-margin: #{tools.size('half-shim')} 0 0; 77 | } 78 | 79 | [data-herman-color-name-option] { 80 | display: inline-block; 81 | } 82 | 83 | [data-herman-color-value] { 84 | background-color: tools.color('slight'); 85 | border-radius: tools.size('quarter-shim'); 86 | box-shadow: 0 0 tools.size('quarter-shim') tools.color('code-shadow') inset; 87 | color: tools.color('text-light'); 88 | font-weight: normal; 89 | margin-top: tools.size('quarter-shim'); 90 | white-space: nowrap; 91 | } 92 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sassdocAnnotations = require('sassdoc/dist/annotation/annotations'); 4 | 5 | const access = require('./lib/annotations/access'); 6 | const colors = require('./lib/annotations/colors'); 7 | const example = require('./lib/annotations/example'); 8 | const font = require('./lib/annotations/font'); 9 | const icons = require('./lib/annotations/icons'); 10 | const name = require('./lib/annotations/name'); 11 | const ratios = require('./lib/annotations/ratios'); 12 | const sizes = require('./lib/annotations/sizes'); 13 | const prepareContext = require('./lib/prepareContext'); 14 | const { renderHerman } = require('./lib/renderHerman'); 15 | const { isProse } = require('./lib/utils/prose'); 16 | 17 | /** 18 | * Actual theme function. It takes the destination directory `dest`, 19 | * and the context variables `ctx`. 20 | */ 21 | const herman = (dest, ctx) => 22 | prepareContext(ctx).then((preparedContext) => 23 | renderHerman(dest, preparedContext), 24 | ); 25 | 26 | // Because Herman handles "prose" blocks differently than SassDoc, 27 | // autofilled annotations are often incorrect when used with Herman. 28 | // So we iterate through the core annotations that have autofill logic, 29 | // and override that to abort if the item will be treated as "prose" by Herman. 30 | // See: https://www.oddbird.net/herman/docs/demo_test-sassdoc#extra-commentary 31 | const customAnnotationNames = [ 32 | 'access', 33 | 'colors', 34 | 'example', 35 | 'font', 36 | 'icons', 37 | 'name', 38 | 'ratios', 39 | 'sizes', 40 | ]; 41 | const annotations = sassdocAnnotations 42 | .filter((a) => { 43 | const obj = a({ logger: console }); 44 | return ( 45 | !customAnnotationNames.includes(obj.name) && 46 | Object.prototype.hasOwnProperty.call(obj, 'autofill') 47 | ); 48 | }) 49 | .map((a) => () => { 50 | const obj = a({ logger: console }); 51 | return { 52 | ...obj, 53 | autofill: (item) => { 54 | if (isProse(item)) { 55 | return undefined; 56 | } 57 | return obj.autofill(item); 58 | }, 59 | }; 60 | }); 61 | 62 | herman.annotations = [ 63 | ...annotations, 64 | access, 65 | colors, 66 | example, 67 | font, 68 | icons, 69 | name, 70 | ratios, 71 | sizes, 72 | ]; 73 | 74 | // make sure sassdoc will preserve comments not attached to Sass 75 | herman.includeUnknownContexts = true; 76 | 77 | module.exports = herman; 78 | -------------------------------------------------------------------------------- /lib/utils/markdown.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const markdown = require('./getMarkdown'); 4 | 5 | /** 6 | * Based on sassdoc-extras `markdown`, 7 | * but uses `markdown-it` (https://github.com/markdown-it/markdown-it) parser. 8 | * See http://sassdoc.com/extra-tools/#markdown-markdown 9 | * and https://github.com/SassDoc/sassdoc-extras/blob/master/src/markdown.js 10 | */ 11 | 12 | module.exports = (ctx) => { 13 | /** 14 | * Wrapper for `markdown-it` that takes only one argument to avoid 15 | * problem with `map` additional arguments. 16 | */ 17 | const md = (str) => markdown.render(str); 18 | 19 | /** 20 | * Return a function that will apply `fn` on `obj[key]` to generate 21 | * `obj[newKey]`. 22 | */ 23 | const applyKey = (fn, key) => (obj) => { 24 | if (key in obj) { 25 | obj[key] = fn(obj[key]); 26 | } 27 | 28 | return obj; 29 | }; 30 | 31 | if (ctx.package && ctx.package.description) { 32 | ctx.package.description = md(ctx.package.description); 33 | } 34 | 35 | if (ctx.description) { 36 | ctx.description = md(ctx.description); 37 | } 38 | 39 | ctx.data.forEach((item) => { 40 | if ('description' in item) { 41 | item.description = md(item.description); 42 | } 43 | 44 | if ('output' in item) { 45 | item.output = md(item.output); 46 | } 47 | 48 | if ('content' in item && item.content.description) { 49 | item.content.description = md(item.content.description); 50 | } 51 | 52 | if ('return' in item && item.return.description) { 53 | item.return.description = md(item.return.description); 54 | } 55 | 56 | if ('deprecated' in item) { 57 | item.deprecated = md(item.deprecated); 58 | } 59 | 60 | if ('author' in item) { 61 | item.author = item.author.map(md); 62 | } 63 | 64 | if ('throw' in item) { 65 | item.throw = item.throw.map(md); 66 | } 67 | 68 | if ('todo' in item) { 69 | item.todo = item.todo.map(md); 70 | } 71 | 72 | if ('example' in item) { 73 | item.example = item.example.map(applyKey(md, 'description')); 74 | } 75 | 76 | if ('parameter' in item) { 77 | item.parameter = item.parameter.map(applyKey(md, 'description')); 78 | } 79 | 80 | if ('property' in item) { 81 | item.property = item.property.map(applyKey(md, 'description')); 82 | } 83 | 84 | if ('since' in item) { 85 | item.since = item.since.map(applyKey(md, 'description')); 86 | } 87 | }); 88 | }; 89 | -------------------------------------------------------------------------------- /test/js/testTemplates.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | const sinon = require('sinon'); 6 | 7 | const { 8 | makeNunjucksColors, 9 | getNunjucksEnv, 10 | } = require('../../lib/utils/templates'); 11 | 12 | const nunjucksEnv = getNunjucksEnv({}); 13 | 14 | describe('split filter', () => { 15 | it('returns array of split str', () => { 16 | const val = 'foo;bar'; 17 | const expected = ['foo', 'bar']; 18 | const actual = nunjucksEnv.filters.split(val, ';'); 19 | 20 | assert.deepStrictEqual(actual, expected); 21 | }); 22 | }); 23 | 24 | describe('isString filter', () => { 25 | it('returns true if val is a string', () => { 26 | const aString = 'foo'; 27 | const notAString = []; 28 | 29 | assert.ok(nunjucksEnv.filters.isString(aString)); 30 | assert.strictEqual(nunjucksEnv.filters.isString(notAString), false); 31 | }); 32 | }); 33 | 34 | describe('makeNunjucksColors', () => { 35 | beforeEach(function () { 36 | this.ctx = { 37 | herman: { 38 | displayColors: undefined, 39 | }, 40 | logger: { 41 | warn: sinon.fake(), 42 | }, 43 | }; 44 | this.colors = makeNunjucksColors(this.ctx); 45 | }); 46 | 47 | it('exits early on invalid colors', function () { 48 | const actual = this.colors('not a color'); 49 | assert.strictEqual(actual, null); 50 | sinon.assert.calledOnce(this.ctx.logger.warn); 51 | }); 52 | 53 | it('exits early on CSS custom properties', function () { 54 | const actual = this.colors('var(--my-color)'); 55 | assert.strictEqual(actual, null); 56 | sinon.assert.notCalled(this.ctx.logger.warn); 57 | }); 58 | 59 | it('switches on formats', function () { 60 | const actual = this.colors('#fefced'); 61 | const expected = { 62 | hex: '#fefced', 63 | rgb: 'rgb(99.608% 98.824% 92.941%)', 64 | hsl: 'hsl(52.941 89.474% 96.275%)', 65 | }; 66 | assert.deepStrictEqual(actual, expected); 67 | }); 68 | 69 | it('handles rgba and hsla', () => { 70 | const colors = makeNunjucksColors({ 71 | herman: { 72 | displayColors: ['rgba', 'hsla'], 73 | }, 74 | }); 75 | const actual = colors('hsl(53deg 89% 96% / 0.5)'); 76 | const expected = { 77 | rgb: 'rgb(99.56% 98.729% 92.44% / 0.5)', 78 | hsl: 'hsl(53 89% 96% / 0.5)', 79 | }; 80 | assert.deepStrictEqual(actual, expected); 81 | }); 82 | 83 | it('passes on unknown format', () => { 84 | const colors = makeNunjucksColors({ 85 | herman: { 86 | displayColors: ['blorble'], 87 | }, 88 | }); 89 | const actual = colors('#fefced'); 90 | const expected = {}; 91 | assert.deepStrictEqual(actual, expected); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /templates/base.njk: -------------------------------------------------------------------------------- 1 | {% import 'nav.macros.njk' as nav %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% set project_title = package.title or package.name or 'Herman' %} 10 | {% block title %}{{ project_title }} Documentation{% endblock %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% if googleAnalytics %} 18 | 19 | 20 | 27 | {% endif %} 28 | 29 | {% if trackingCode %} 30 | 31 | {{ trackingCode|safe }} 32 | {% endif %} 33 | 34 | 35 | {% include '_icons.svg' %} 36 | 37 |
38 |
39 | {{ nav.toggle() }} 40 | 41 |
42 | {{ project_title }} 43 | {% if package.version %} 44 | {{ package.version }} 45 | {% endif %} 46 |
47 |
48 | 49 |
50 | {{ nav.menu( 51 | project=project_title, 52 | byGroup=byGroup, 53 | groups=groups, 54 | orderedGroups=orderedGroups, 55 | activeGroup=activeGroup, 56 | extraDocs=extraDocs, 57 | extraLinks=extraLinks, 58 | watermark=display.watermark 59 | ) }} 60 | 61 |
62 | {% block breadcrumb %}{% endblock %} 63 | 64 |
65 | {% block main %}{% endblock %} 66 |
67 |
68 |
69 |
70 | 71 | 72 | 73 | 74 | {% block extrajs %}{% endblock %} 75 | 76 | 77 | -------------------------------------------------------------------------------- /scss/component/_nav.scss: -------------------------------------------------------------------------------- 1 | // Navigation Styles 2 | // ================= 3 | /// # Herman Navigation Components 4 | /// @group component-nav 5 | 6 | @use 'pkg:accoutrement' as tools; 7 | @use '../config'; 8 | 9 | // Nav Lists 10 | // --------- 11 | /// Remove list styles from navigation lists 12 | /// @group component-nav 13 | [data-region='nav'] ul { 14 | list-style: none; 15 | } 16 | 17 | // Nav Subsections 18 | // --------------- 19 | /// Group the main navigation into sub-sections… 20 | /// @group component-nav 21 | .nav-subsection { 22 | flex: 0 0 auto; 23 | 24 | &:last-of-type { 25 | flex: 1 0 auto; 26 | } 27 | 28 | & + .nav-item { 29 | border-top: 1px solid; 30 | margin-top: tools.size('double-gutter'); 31 | padding-top: tools.size('gutter'); 32 | } 33 | } 34 | 35 | // Nav Home 36 | // -------- 37 | /// There is a home link at the top of the navigation… 38 | /// @group component-nav 39 | .nav-home { 40 | font-size: tools.size('large'); 41 | font-weight: bold; 42 | margin-top: tools.size('gutter-plus'); 43 | } 44 | 45 | // Nav Titles 46 | // ---------- 47 | /// Each subsection may optionally have a title… 48 | /// @group component-nav 49 | .nav-title { 50 | border-top: 1px solid; 51 | font-size: inherit; 52 | font-weight: bold; 53 | margin: tools.size('gutter-plus') 0 tools.size('shim'); 54 | padding-top: tools.size('quarter-shim'); 55 | } 56 | 57 | // Nav Items 58 | // --------- 59 | /// Each list-item in the navigation menus… 60 | /// @group component-nav 61 | .nav-item { 62 | list-style: none; 63 | margin: tools.size('half-shim') 0; 64 | } 65 | 66 | // Nav Links 67 | // --------- 68 | /// Navigation items, with inactive and active states. 69 | /// @group component-nav 70 | /// @example html 71 | ///
72 | /// not active
73 | /// active 74 | ///
75 | [data-nav] { 76 | #{config.$link} { 77 | background: linear-gradient( 78 | to right, 79 | tools.color('theme-light'), 80 | tools.color('theme-light') 81 | ) 82 | no-repeat; 83 | background-size: 0 tools.size('nav-underline'); 84 | background-position: bottom left; 85 | color: tools.color('background'); 86 | line-height: 1.1; 87 | padding-bottom: tools.size('quarter-shim'); 88 | text-decoration: none; 89 | transition: 90 | color 0.4s, 91 | background-size 0.4s; 92 | } 93 | 94 | #{config.$focus} { 95 | background-size: 100% tools.size('nav-underline'); 96 | } 97 | 98 | &[data-nav='is-active'] { 99 | #{config.$link}, 100 | #{config.$focus} { 101 | background-size: 100% tools.size('nav-underline'); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /scss/config/_abstracts.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | @use 'sass:list'; 3 | @use 'sass:meta'; 4 | @use '../utilities'; 5 | @use 'pkg:accoutrement' as tools; 6 | 7 | // Accoutrement Utils 8 | // ================== 9 | 10 | /// # Herman Config: Private Helpers 11 | /// @group config-utils 12 | 13 | // Functions 14 | // --------- 15 | /// These functions will be made available to 16 | /// accoutrement tools 17 | /// for use in configuration maps. 18 | /// Register additional functions as needed, 19 | /// or establish alias names for existing functions. 20 | /// 21 | /// @group config-utils 22 | /// @type map 23 | /// 24 | /// @link https://www.oddbird.net/accoutrement Accoutrement 25 | /// 26 | /// @prop {function | string} - 27 | /// Use `get-function()` to capture a first-class function, 28 | /// or use a string to reference existing functions and alias-keys 29 | $functions: ( 30 | 'adjust': meta.get-function('adjust', $module: 'color'), 31 | 'rgba': meta.get-function('rgba'), 32 | 'convert': '#convert-units', 33 | ); 34 | 35 | tools.$functions: $functions; 36 | 37 | // Config 38 | // ------ 39 | /// Internal utility for managing Herman and Accoutrement maps 40 | /// in one single mixin. 41 | /// In preparation for the Sass 3.5+ modular syntax, 42 | /// this can only be done in the project being documented, 43 | /// and is not included as part of the Herman API. 44 | /// 45 | /// @group config-utils 46 | /// @link https://www.oddbird.net/accoutrement Accoutrement 47 | /// 48 | /// @param {string} $type - 49 | /// The type of data being added, e.g. 50 | /// `color/s`, `font/s`, `ratio/s`, `size/s`. 51 | /// @param {string} $name - 52 | /// The reference key for accessing this data. 53 | /// This should generally be the same as the variable name -- 54 | /// e.g. `'brand-colors'` for the `$brand-colors` variable. 55 | /// @param {map} $value - 56 | /// The map data to be added to both 57 | /// Accoutrement globals and Herman export. 58 | /// 59 | /// @example scss 60 | /// $brand-colors: ( 61 | /// 'brand-orange': hsl(24, 100%, 39%), 62 | /// 'brand-blue': hsl(195, 85%, 35%), 63 | /// 'brand-pink': hsl(330, 85%, 48%) ('shade': 25%), 64 | /// ); 65 | /// 66 | /// @include config.add('colors', 'brand-colors', $brand-colors); 67 | @mixin add($type, $name, $value) { 68 | @if list.index('color' 'colors', $type) { 69 | @include tools.add-colors($value); 70 | @include utilities.add($type, $name, tools.compile-colors($value)); 71 | } @else if list.index('size' 'sizes', $type) { 72 | @include tools.add-sizes($value); 73 | @include utilities.add($type, $name, tools.compile-sizes($value)); 74 | } @else if list.index('ratio' 'ratios', $type) { 75 | @include tools.add-ratios($value); 76 | @include utilities.add($type, $name, tools.compile-ratios($value)); 77 | } @else if list.index('font' 'fonts', $type) { 78 | @include tools.add-font($name, $value); 79 | @include utilities.add($type, $name, tools.font($name)); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [reopened] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - run: corepack enable 19 | - uses: actions/setup-node@v6 20 | with: 21 | node-version-file: .nvmrc 22 | cache: yarn 23 | - run: yarn install --immutable 24 | - run: yarn build 25 | 26 | test-old-node: 27 | name: Test on Node ${{ matrix.node }} 28 | runs-on: ubuntu-latest 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | node: ['20', '22', 'lts/*'] 33 | steps: 34 | - uses: actions/checkout@v6 35 | - run: corepack enable 36 | - uses: actions/setup-node@v6 37 | with: 38 | node-version: ${{ matrix.node }} 39 | cache: yarn 40 | - run: yarn install 41 | - run: yarn test:js:src 42 | 43 | lint: 44 | name: Lint 45 | runs-on: ubuntu-latest 46 | needs: build 47 | steps: 48 | - uses: actions/checkout@v6 49 | - run: corepack enable 50 | - uses: actions/setup-node@v6 51 | with: 52 | node-version-file: .nvmrc 53 | cache: yarn 54 | - run: yarn install 55 | - run: yarn lint:ci 56 | 57 | test: 58 | name: Test 59 | runs-on: ubuntu-latest 60 | needs: build 61 | steps: 62 | - uses: actions/checkout@v6 63 | - run: corepack enable 64 | - uses: actions/setup-node@v6 65 | with: 66 | node-version-file: .nvmrc 67 | cache: yarn 68 | - run: yarn install 69 | - run: yarn test:sass 70 | - env: 71 | MOCHA_FILE: './coverage/src/junit.xml' 72 | run: yarn test:js:src:ci 73 | - env: 74 | JEST_JUNIT_OUTPUT_DIR: './coverage/client' 75 | run: yarn test:js:client:ci 76 | - name: Upload coverage results 77 | uses: actions/upload-artifact@v5 78 | with: 79 | name: frontend-coverage 80 | path: coverage 81 | 82 | coverage: 83 | name: Check coverage 84 | runs-on: ubuntu-latest 85 | needs: test 86 | steps: 87 | - uses: actions/checkout@v6 88 | - run: corepack enable 89 | - uses: actions/setup-node@v6 90 | with: 91 | node-version-file: .nvmrc 92 | cache: yarn 93 | - run: yarn install 94 | - name: Download coverage results 95 | uses: actions/download-artifact@v6 96 | with: 97 | name: frontend-coverage 98 | path: coverage 99 | - name: Check src coverage 100 | run: yarn nyc check-coverage --temp-dir coverage/src 101 | - name: Check client coverage 102 | run: yarn nyc check-coverage --temp-dir coverage/client 103 | -------------------------------------------------------------------------------- /templates/utility.macros.njk: -------------------------------------------------------------------------------- 1 | {# Utility Macros #} 2 | 3 | 4 | {% macro attr_if(attr, value) %} 5 | {%- if value === true -%} 6 | {{ attr }} 7 | {%- elif value -%} 8 | {{ attr }}="{{ value }}" 9 | {%- endif -%} 10 | {% endmacro %} 11 | 12 | 13 | {% macro show_attrs(attrs) %} 14 | {%- for name, value in attrs.items() %}{{ attr_if(name, value) }}{% endfor -%} 15 | {% endmacro %} 16 | 17 | 18 | {% macro link_if(content='', url=none, attrs={}) -%} 19 | {%- if url -%} 20 | {{ content|trim() }} 21 | {%- else -%} 22 | {{ content|trim() }} 23 | {%- endif -%} 24 | {%- endmacro %} 25 | 26 | 27 | {# SVG Icon #} 28 | {% macro icon(name, alt=none, size=none, class=none) %} 29 | 30 | {%- if alt -%} 31 | {{ alt }} 32 | {%- endif -%} 33 | 34 | 35 | {% endmacro %} 36 | 37 | 38 | {# Pre-Formated & Highlighted Code Block #} 39 | {% macro code_block(language=none, content=none, description=none) %} 40 | {% if content and (content != '') %} 41 |
42 | {% if language or description %} 43 |
44 | 45 | {{ language }} 46 | 47 | 48 | {% if description %} 49 | 50 | {{ description|striptags }} 51 | 52 | {% endif %} 53 |
54 | {% endif %} 55 | 56 | {% set language = 'jinja' if language == 'njk' else language %} 57 | {% set language = ['lang', language]|join('-') if language !== none %} 58 |
{{ content|replace('\t', '  ')|escape|safe }}
59 |
60 | {% endif %} 61 | {% endmacro %} 62 | 63 | 64 | {# Toggle collapsible elements (see `static/js/app/base.js`) #} 65 | {% macro toggle_button(id, expanded=false, button=false, synced=false) -%} 66 | data-toggle="button" aria-controls="{{ id }}" aria-pressed="{{ 'true' if expanded else 'false' }}"{% if button %} type="button"{% else %} role="button"{% endif %} data-toggle-synced="{{ 'true' if synced else 'false' }}" 67 | {%- endmacro %} 68 | 69 | {% macro toggle_target(id, expanded=false, auto_closing=false, auto_closing_on_any_click=false, auto_closing_exception=none) -%} 70 | data-toggle="target" id="{{ id }}" data-target-id="{{ id }}" aria-expanded="{{ 'true' if expanded else 'false' }}" data-auto-closing="{{ 'true' if (auto_closing or auto_closing_on_any_click) else 'false' }}" data-auto-closing-on-any-click="{{ 'true' if auto_closing_on_any_click else 'false' }}"{% if auto_closing_exception %} data-auto-closing-exception="{{ auto_closing_exception }}"{% endif %} 71 | {%- endmacro %} 72 | 73 | {% macro toggle_close(id) -%} 74 | data-toggle="close" aria-controls="{{ id }}" 75 | {%- endmacro %} 76 | -------------------------------------------------------------------------------- /test/sass/utilities/_maps.scss: -------------------------------------------------------------------------------- 1 | // Map Utility Tests 2 | // ================= 3 | 4 | @use 'sass:color'; 5 | @use 'sass:map'; 6 | @use 'sass:meta'; 7 | @use 'pkg:sass-true' as true; 8 | @use 'pkg:accoutrement' as tools with ( 9 | $functions: ( 10 | 'adjust': meta.get-function('adjust', $module: 'color'), 11 | ) 12 | ); 13 | @use '../../../scss/utilities/json-api' as *; 14 | @use '../../../scss/utilities/maps' as *; 15 | 16 | $colors-compiled: ( 17 | 'brand-blue': #0d7fa5, 18 | 'light-gray': #dedede, 19 | 'gray': #555b5e, 20 | 'black': #3b4042, 21 | ); 22 | $darken: ( 23 | 'brand-blue': rgb(10.4, 101.6, 132), 24 | 'light-gray': rgb(177.6, 177.6, 177.6), 25 | 'gray': rgb(68, 72.8, 75.2), 26 | 'black': rgb(47.2, 51.2, 52.8), 27 | ); 28 | tools.$sizes: ( 29 | 'root': 20px, 30 | 'rhythm': '#root' 31 | ( 32 | 'scale': '_fifth' 1, 33 | 'convert': 'rem', 34 | ) 35 | ); 36 | 37 | $sizes-compiled: ( 38 | 'root': 20px, 39 | 'rhythm': 1.5rem, 40 | ); 41 | 42 | // Each Value 43 | // ---------- 44 | @include true.describe('each-value [function]') { 45 | @include true.it('returns a map with values run through a given function') { 46 | @include true.assert-equal( 47 | each-value( 48 | $colors-compiled, 49 | meta.get-function('scale', $module: 'color'), 50 | $lightness: -20% 51 | ), 52 | $darken 53 | ); 54 | } 55 | } 56 | 57 | // Map Compile 58 | // ----------- 59 | @include true.describe('each-key [function]') { 60 | @include true.it('returns a map with keys run through a given function') { 61 | @include true.assert-equal( 62 | each-key(tools.$sizes, meta.get-function('size', $module: 'tools')), 63 | $sizes-compiled 64 | ); 65 | } 66 | } 67 | 68 | // Herman Add 69 | // ---------- 70 | @include true.describe('add [mixin]') { 71 | $herman: () !global; 72 | $empty: (); 73 | 74 | @include true.it('adds a map to the $herman global') { 75 | @include true.assert-equal($herman, $empty); 76 | @include add('colors', 'compiled', $colors-compiled); 77 | 78 | $expect: ( 79 | 'colors': ( 80 | 'compiled': $colors-compiled, 81 | ), 82 | ); 83 | 84 | @include true.assert-equal($herman, $expect); 85 | } 86 | 87 | @include true.it('adds to existing data of the type') { 88 | $existing: map.get($herman, 'colors'); 89 | $new: ( 90 | 'darken': $darken, 91 | ); 92 | $expect: ( 93 | 'colors': map.merge($existing, $new), 94 | ); 95 | 96 | @include add('colors', 'darken', $darken); 97 | @include true.assert-equal($herman, $expect); 98 | } 99 | 100 | @include true.it('fixes pluralization mistakes with known data types') { 101 | $existing: map.get($herman, 'colors'); 102 | $new: ( 103 | 'darken-2': $darken, 104 | ); 105 | $expect: ( 106 | 'colors': map.merge($existing, $new), 107 | ); 108 | 109 | @include add('color', 'darken-2', $darken); 110 | @include true.assert-equal($herman, $expect); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /scss/layout/_banner.scss: -------------------------------------------------------------------------------- 1 | // Banner Styles 2 | // ============= 3 | 4 | @use 'pkg:accoutrement' as tools; 5 | @use '../config'; 6 | 7 | // Banner Region 8 | // ------------- 9 | /// Layout styles for Herman's top-banner region. 10 | /// 11 | /// @group style-banner 12 | /// @example njk 13 | /// {% import 'utility.macros.njk' as utility %} 14 | ///
15 | /// 18 | ///
19 | /// Herman 20 | /// 1.0.0 21 | ///
22 | ///
23 | [data-region='banner'] { 24 | @include tools.z-index('banner'); 25 | 26 | align-items: center; 27 | display: flex; 28 | padding: tools.size('shim'); 29 | 30 | @include config.banner-arrow; 31 | } 32 | 33 | // Project Title 34 | // ------------- 35 | /// Used for the top-banner project title, 36 | /// including both the name and version of the project. 37 | /// 38 | /// @group style-banner 39 | /// @example html 40 | ///
41 | /// Herman 42 | /// 1.0.0 43 | ///
44 | .project-title { 45 | align-items: baseline; 46 | display: flex; 47 | margin-left: auto; 48 | margin-right: auto; 49 | } 50 | 51 | // Project Name 52 | // ------------ 53 | /// Specific styling for the top-banner project name. 54 | /// 55 | /// @group style-banner 56 | /// @example html 57 | /// Herman 58 | .project-name { 59 | font-size: tools.size('h1'); 60 | font-weight: bold; 61 | padding-left: tools.size('half-shim'); 62 | padding-right: tools.size('half-shim'); 63 | 64 | #{config.$link} { 65 | text-decoration: none; 66 | } 67 | } 68 | 69 | // Project Title 70 | // ------------- 71 | /// Less prominent text for the top-banner project version. 72 | /// 73 | /// @group style-banner 74 | /// @example html 75 | /// 1.0.0 76 | .project-version { 77 | @include tools.font-family('sans'); 78 | 79 | color: tools.color('text-light'); 80 | font-size: tools.size('h3'); 81 | } 82 | 83 | // Nav Toggle 84 | // ---------- 85 | /// The navigation-menu toggle is only visible on small screens. 86 | /// 87 | /// @group style-banner 88 | /// @example njk 89 | /// {% import 'utility.macros.njk' as utility %} 90 | /// 93 | [data-nav-toggle] { 94 | color: tools.color('action'); 95 | padding: tools.size('half-shim'); 96 | 97 | @include tools.above('page-break') { 98 | padding-left: tools.size('shim'); 99 | padding-right: tools.size('shim'); 100 | } 101 | 102 | @include tools.above('nav-break') { 103 | display: none; 104 | } 105 | 106 | #{config.$focus}, 107 | &[aria-pressed='true'] { 108 | color: tools.color('focus'); 109 | outline: 0; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/utils/templates.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | const Color = require('colorjs.io').default; 6 | const nunjucks = require('nunjucks'); 7 | 8 | const baseDir = path.resolve(__dirname, '../../templates'); 9 | const colors_iFrameTpl = path.join(baseDir, 'colors', 'base.njk'); 10 | const docTpl = path.join(baseDir, 'doc.njk'); 11 | const example_iFrameTpl = path.join(baseDir, 'example', 'base.njk'); 12 | const fontFaceTpl = path.join(baseDir, 'fonts', 'font_face.njk'); 13 | const fonts_iFrameTpl = path.join(baseDir, 'fonts', 'base.njk'); 14 | const groupTpl = path.join(baseDir, 'group.njk'); 15 | const icons_iFrameTpl = path.join(baseDir, 'icons', 'base.njk'); 16 | const icons_tpl = path.join(baseDir, '_icon_template.lodash'); 17 | const indexTpl = path.join(baseDir, 'index.njk'); 18 | const ratios_iFrameTpl = path.join(baseDir, 'ratios', 'base.njk'); 19 | const searchTpl = path.join(baseDir, 'search.njk'); 20 | const sizes_iFrameTpl = path.join(baseDir, 'sizes', 'base.njk'); 21 | 22 | const templates = { 23 | baseDir, 24 | colors_iFrame: colors_iFrameTpl, 25 | index: indexTpl, 26 | search: searchTpl, 27 | group: groupTpl, 28 | extraDoc: docTpl, 29 | example_iFrame: example_iFrameTpl, 30 | fonts_iFrame: fonts_iFrameTpl, 31 | icons_iFrame: icons_iFrameTpl, 32 | fontFace: fontFaceTpl, 33 | icons_tpl, 34 | ratios_iFrame: ratios_iFrameTpl, 35 | sizes_iFrame: sizes_iFrameTpl, 36 | }; 37 | 38 | const makeNunjucksColors = (ctx) => (input) => { 39 | let color; 40 | if (!input || input.match(/^var\(--.+\)$/)) { 41 | // Don't try to parse CSS custom properties 42 | return null; 43 | } 44 | try { 45 | color = new Color(input); 46 | } catch (err) { 47 | ctx.logger.warn(`Error parsing color value: ${input}\n${err.message}`); 48 | return null; 49 | } 50 | const obj = {}; 51 | const formats = ctx.herman?.displayColors || ['hex', 'rgb', 'hsl']; 52 | formats.forEach((format) => { 53 | try { 54 | let targetColor; 55 | switch (format) { 56 | case 'hex': 57 | targetColor = color.to('srgb'); 58 | obj.hex = targetColor.toString({ format: 'hex' }); 59 | break; 60 | case 'rgb': 61 | case 'rgba': 62 | targetColor = color.to('srgb'); 63 | obj.rgb = targetColor.toString(); 64 | break; 65 | case 'hsl': 66 | case 'hsla': 67 | targetColor = color.to('hsl'); 68 | obj.hsl = targetColor.toString(); 69 | break; 70 | } 71 | } catch (err) { 72 | /* istanbul ignore next */ 73 | ctx.logger.warn( 74 | `Error converting color ${input} to format: ${format}\n${err.message}`, 75 | ); 76 | } 77 | }); 78 | return obj; 79 | }; 80 | 81 | nunjucks.installJinjaCompat(); 82 | let nunjucksEnv; 83 | 84 | const getNunjucksEnv = (ctx) => { 85 | if (!nunjucksEnv) { 86 | nunjucksEnv = nunjucks.configure(baseDir, { noCache: true }); 87 | nunjucksEnv.addFilter('split', (str, separator) => str.split(separator)); 88 | nunjucksEnv.addFilter('isString', (val) => typeof val === 'string'); 89 | nunjucksEnv.addFilter('startsWith', (str, search) => 90 | str.startsWith(search), 91 | ); 92 | 93 | // Accepts a color (in any format) and returns an object with hex, rgba, and 94 | // hsla strings. 95 | nunjucksEnv.addFilter('colors', makeNunjucksColors(ctx)); 96 | } 97 | return nunjucksEnv; 98 | }; 99 | 100 | module.exports = { 101 | getNunjucksEnv, 102 | makeNunjucksColors, 103 | templates, 104 | }; 105 | -------------------------------------------------------------------------------- /scss/initial/_root.scss: -------------------------------------------------------------------------------- 1 | // Root Typography 2 | // =============== 3 | 4 | @use 'pkg:accoutrement' as tools; 5 | @use '../config'; 6 | 7 | /// # Herman Typography 8 | /// Initial styling for typographic elements in Herman. 9 | /// 10 | /// @link https://www.oddbird.net/accoutrement/ Accoutrement 11 | /// @group style-typography 12 | 13 | // Block Reset 14 | // ----------- 15 | /// Since this was initially built with accoutrement/init -- 16 | /// a more opinionated reset -- 17 | /// this simplified block reset 18 | /// helps cover the basic assumptions of the code-base 19 | /// @group style-typography 20 | * { 21 | border: solid 0 currentcolor; 22 | font-size: inherit; 23 | line-height: inherit; 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | /// The root typographic styles provide a default font, 29 | /// responsive font-size, fallback font-size, and line-height. 30 | /// @group style-typography 31 | :root { 32 | @include tools.font-family('sans'); 33 | 34 | font-size: tools.size('root'); 35 | font-size: tools.size('responsive'); 36 | line-height: tools.ratio('line-height'); 37 | } 38 | 39 | // Hidden 40 | // ------ 41 | /// Selected text uses a light color from the Herman color palette. 42 | /// @group style-typography 43 | [hidden] { 44 | display: none !important; /* stylelint-disable-line declaration-no-important */ 45 | } 46 | 47 | // Images 48 | // ------ 49 | /// Undo the block image display 50 | /// caused by changing our reset 51 | /// from accoutrement/init to cssremedy - 52 | /// since that would be a noticeable user-facing change. 53 | /// @group style-typography 54 | img { 55 | display: revert; 56 | } 57 | 58 | // Selection 59 | // --------- 60 | /// Selected text uses a light color from the Herman color palette. 61 | /// @group style-typography 62 | ::selection { 63 | background-color: tools.color('callout'); 64 | } 65 | 66 | // Mark 67 | // ---- 68 | /// For search-results, we mark target text with a background color. 69 | /// @group style-typography 70 | mark { 71 | background-color: tools.color('callout'); 72 | display: inline-block; 73 | padding: 0 tools.size('quarter-shim'); 74 | } 75 | 76 | // Links 77 | // ----- 78 | /// Default links use both color and text-underline 79 | /// to stand out from basic text. 80 | /// @group style-typography 81 | /// @see $link 82 | /// @see $focus 83 | [href] { 84 | #{config.$link} { 85 | color: tools.color('action'); 86 | text-decoration-color: tools.color('underline'); 87 | text-decoration-skip: auto; 88 | transition: 89 | text-decoration-color 250ms, 90 | color 200ms; 91 | } 92 | 93 | #{config.$focus} { 94 | color: tools.color('focus'); 95 | text-decoration-color: currentcolor; 96 | } 97 | } 98 | 99 | // Pre 100 | // --- 101 | /// Pre-formatted blocks use our monospace `code` font-stack, 102 | /// and a smaller font-size. 103 | /// @group style-typography 104 | pre { 105 | @include tools.font-family('code'); 106 | 107 | font-size: tools.size('code'); 108 | overflow: auto; 109 | white-space: pre; 110 | } 111 | 112 | // Code 113 | // ---- 114 | /// Code uses a monospace font-stack, 115 | /// and slightly bolder text when inline - 116 | /// but allows for syntax-highlighting in a block context. 117 | /// 118 | /// @group style-typography 119 | /// @see {css} Code Blocks 120 | code { 121 | @include tools.font-family('code'); 122 | 123 | font-weight: bolder; 124 | 125 | pre & { 126 | display: block; 127 | font-weight: normal; 128 | } 129 | } 130 | 131 | // Is Hidden 132 | // --------- 133 | /// Text that is provided for screen-reader accessibility only, 134 | /// can be visually hidden without removing from the DOM. 135 | /// @group style-typography 136 | .is-hidden { 137 | @include tools.is-hidden; 138 | } 139 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const babelParser = require('@babel/eslint-parser'); 2 | const js = require('@eslint/js'); 3 | const prettier = require('eslint-config-prettier'); 4 | const importPlugin = require('eslint-plugin-import'); 5 | const jest = require('eslint-plugin-jest'); 6 | const jestDOM = require('eslint-plugin-jest-dom'); 7 | const simpleImportSort = require('eslint-plugin-simple-import-sort'); 8 | const globals = require('globals'); 9 | 10 | module.exports = [ 11 | { 12 | ignores: [ 13 | '.git/*', 14 | '.nyc_output/*', 15 | '.vscode/*', 16 | '.yarn/*', 17 | '.yarnrc.yml', 18 | 'coverage/*', 19 | 'dist/*', 20 | 'docs/*', 21 | 'herman/*', 22 | 'node_modules/*', 23 | ], 24 | }, 25 | js.configs.recommended, 26 | importPlugin.flatConfigs.recommended, 27 | prettier, 28 | { 29 | files: ['**/*.{js,mjs,cjs,ts,cts,mts}'], 30 | languageOptions: { 31 | parser: babelParser, 32 | globals: { 33 | ...globals.node, 34 | ...globals.es2021, 35 | }, 36 | parserOptions: { 37 | sourceType: 'script', 38 | }, 39 | }, 40 | settings: { 41 | 'import/resolver': { 42 | node: {}, 43 | webpack: { 44 | config: { 45 | resolve: { 46 | modules: ['templates/client', 'scss', 'node_modules'], 47 | alias: { 48 | jquery: 'jquery/dist/jquery.slim', 49 | nunjucks: 'nunjucks/browser/nunjucks-slim', 50 | }, 51 | }, 52 | resolveLoader: { 53 | alias: { 54 | sassjson: 'sass-json-loader', 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | 'import/external-module-folders': ['node_modules'], 61 | }, 62 | rules: { 63 | 'no-console': 1, 64 | 'no-warning-comments': ['warn', { terms: ['todo', 'fixme', '@@@'] }], 65 | 'import/first': 'warn', 66 | 'import/newline-after-import': 'warn', 67 | 'import/no-duplicates': ['error', { 'prefer-inline': true }], 68 | 'import/order': [ 69 | 'warn', 70 | { 'newlines-between': 'always', alphabetize: { order: 'asc' } }, 71 | ], 72 | 'import/named': 'warn', 73 | }, 74 | }, 75 | { 76 | files: ['assets/js/**/*.{js,mjs,cjs,ts,cts,mts}'], 77 | languageOptions: { 78 | globals: { 79 | ...globals.browser, 80 | ...globals.commonjs, 81 | ...globals.jquery, 82 | ...globals.es2021, 83 | }, 84 | parserOptions: { 85 | sourceType: 'module', 86 | }, 87 | }, 88 | plugins: { 'simple-import-sort': simpleImportSort }, 89 | rules: { 90 | 'simple-import-sort/imports': 'warn', 91 | 'simple-import-sort/exports': 'warn', 92 | 'import/order': 'off', 93 | }, 94 | }, 95 | { 96 | files: ['test/**/*.{js,ts}'], 97 | languageOptions: { 98 | globals: { 99 | ...globals.mocha, 100 | ...globals.es2021, 101 | }, 102 | }, 103 | rules: { 104 | 'func-names': 'off', 105 | }, 106 | }, 107 | { 108 | files: ['test/clientjs/**/*.{js,ts}'], 109 | languageOptions: { 110 | globals: { 111 | ...jest.environments.globals.globals, 112 | ...globals.browser, 113 | ...globals.jquery, 114 | ...globals.es2021, 115 | }, 116 | parserOptions: { 117 | sourceType: 'module', 118 | }, 119 | }, 120 | plugins: { 121 | jest, 122 | 'jest-dom': jestDOM, 123 | 'simple-import-sort': simpleImportSort, 124 | }, 125 | rules: { 126 | 'simple-import-sort/imports': 'warn', 127 | 'simple-import-sort/exports': 'warn', 128 | 'import/order': 'off', 129 | }, 130 | }, 131 | ]; 132 | -------------------------------------------------------------------------------- /templates/nav.macros.njk: -------------------------------------------------------------------------------- 1 | {% import 'utility.macros.njk' as utility %} 2 | 3 | 4 | {% macro toggle() %} 5 | 8 | {% endmacro %} 9 | 10 | 11 | {% macro menu( 12 | project, 13 | byGroup, 14 | groups, 15 | orderedGroups, 16 | activeGroup, 17 | extraDocs, 18 | extraLinks, 19 | watermark=true 20 | ) %} 21 | 102 | {% endmacro %} 103 | 104 | 105 | {% macro navitem(slug, display, activeGroup, byGroup) %} 106 | {# Do not display if the group was defined but has no contents. #} 107 | {%- if byGroup[slug] -%} 108 | {%- set activeNav = 'is-active' if (activeGroup == slug) else 'is-not-active' %} 109 | 112 | {% endif -%} 113 | {% endmacro %} 114 | -------------------------------------------------------------------------------- /test/sass/utilities/_json-encode.scss: -------------------------------------------------------------------------------- 1 | // JSON Encoding Tests 2 | // =================== 3 | 4 | @use 'pkg:sass-true' as true; 5 | @use '../../../scss/utilities/json-encode' as *; 6 | 7 | // JSON Encode 8 | // ----------- 9 | @include true.describe('encode [function]') { 10 | @include true.it('returns a boolean without encoding') { 11 | @include true.assert-equal(encode(true), true); 12 | } 13 | 14 | @include true.it('returns a string for null values') { 15 | @include true.assert-equal(encode(null), 'null'); 16 | } 17 | 18 | @include true.it('encodes each value in a list') { 19 | @include true.assert-equal(encode(one two 3), '["one", "two", 3]'); 20 | } 21 | 22 | @include true.it('encodes each key/value in a map') { 23 | $map: ( 24 | 1: ( 25 | a, 26 | b, 27 | c, 28 | ), 29 | 'two': 3em, 30 | 'three' 'four': 'hello "world"', 31 | ); 32 | 33 | @include true.assert-equal( 34 | encode($map), 35 | '{"1": ["a", "b", "c"], "two": "3em", "three four": "hello \\"world\\""}' 36 | ); 37 | } 38 | 39 | @include true.it('returns a string value of unitless numbers') { 40 | @include true.assert-equal(encode(3), '3'); 41 | } 42 | 43 | @include true.it('returns quoted color strings') { 44 | $hex: #333; 45 | $rgba: rgb(0, 255, 130, 75%); 46 | $hsla: hsl(120deg, 50%, 80%, 75%); 47 | 48 | @each $color in ($hex, $rgba, $hsla) { 49 | @include true.assert-equal(encode($color), '"#{$color}"'); 50 | } 51 | } 52 | 53 | @include true.it('returns quoted and escaped strings') { 54 | @include true.assert-equal(encode('hello "world"'), '"hello \\"world\\""'); 55 | } 56 | 57 | @include true.it('returns escaped backslashes in strings') { 58 | @include true.assert-equal(encode('hello\\world'), '"hello\\\\world"'); 59 | } 60 | } 61 | 62 | // Encode List 63 | // ----------- 64 | @include true.describe('encode-list [function]') { 65 | @include true.it('encodes each value in a list') { 66 | @include true.assert-equal(encode-list(one two 3), '["one", "two", 3]'); 67 | } 68 | } 69 | 70 | // Encode Map 71 | // ---------- 72 | @include true.describe('encode-map [function]') { 73 | @include true.it('encodes each key/value in a map') { 74 | $map: ( 75 | 1: ( 76 | a, 77 | b, 78 | c, 79 | ), 80 | 'two': 3em, 81 | three four: 'hello "world"', 82 | ); 83 | 84 | @include true.assert-equal( 85 | encode-map($map), 86 | '{"1": ["a", "b", "c"], "two": "3em", "three four": "hello \\"world\\""}' 87 | ); 88 | } 89 | } 90 | 91 | // Encode Number 92 | // ------------- 93 | @include true.describe('encode-number [function]') { 94 | @include true.it('returns a string value of unitless numbers') { 95 | @include true.assert-equal(encode-number(3), '3'); 96 | } 97 | 98 | @include true.it('returns a scare-quoted value of lengths') { 99 | @include true.assert-equal(encode-number(3em), '"3em"'); 100 | } 101 | } 102 | 103 | // Herman Quote 104 | // ------------ 105 | @include true.describe('quote [function]') { 106 | @include true.it('converts values to strings') { 107 | @include true.assert-equal(quotes(1em), '"1em"'); 108 | @include true.assert-equal(quotes('one' 'two' 'three'), '"one two three"'); 109 | } 110 | } 111 | 112 | // String Replace 113 | // -------------- 114 | @include true.describe('escape-quotes [function]') { 115 | @include true.it('replaces a single sub-string') { 116 | @include true.assert-equal( 117 | escape-quotes('hello "world"'), 118 | 'hello \\"world\\"' 119 | ); 120 | } 121 | } 122 | 123 | // Escape Backslashes 124 | // ------------------ 125 | @include true.describe('escape-backslashes [function]') { 126 | @include true.it('escapes backslashes in string') { 127 | @include true.assert-equal( 128 | escape-backslashes('hello\\world'), 129 | 'hello\\\\world' 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /scss/utilities/_json-encode.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:math'; 2 | @use 'sass:meta'; 3 | @use 'sass:string'; 4 | 5 | // Json Encoding 6 | // ============= 7 | 8 | // JSON Ecode 9 | // ---------- 10 | /// Encode any Sass value as a JSON-ready string. 11 | /// 12 | /// @group config_api-utilities 13 | /// 14 | /// @param {*} $value [$herman] - 15 | /// Data to be encoded for JSON exporting 16 | @function encode($value) { 17 | $type: meta.type-of($value); 18 | 19 | @if ($type == 'bool') { 20 | @return $value; 21 | } @else if ($type == 'null') { 22 | @return 'null'; 23 | } @else if ($type == 'list') { 24 | @return encode-list($value); 25 | } @else if ($type == 'map') { 26 | @return encode-map($value); 27 | } @else if ($type == 'number') { 28 | @return encode-number($value); 29 | } 30 | 31 | @return quotes($value); 32 | } 33 | 34 | // Encode List 35 | // ----------- 36 | /// Encode each item in a Sass list, 37 | /// and convert to a JSON-ready square-bracketed list. 38 | /// 39 | /// @group config_api-utilities 40 | /// 41 | /// @param {list} $list - 42 | /// List to be encoded for JSON exporting 43 | @function encode-list($list) { 44 | $str: ''; 45 | 46 | @each $item in $list { 47 | $str: if($str != '', $str + ', ', $str); 48 | $str: $str + encode($item); 49 | } 50 | 51 | @return '[#{$str}]'; 52 | } 53 | 54 | // Encode Map 55 | // ---------- 56 | /// Encode each key/value in a Sass map, 57 | /// and convert to a JSON-ready object. 58 | /// 59 | /// @group config_api-utilities 60 | /// 61 | /// @param {map} $map - 62 | /// Map to be encoded for JSON exporting 63 | @function encode-map($map) { 64 | $str: ''; 65 | 66 | @each $key, $value in $map { 67 | $str: if($str != '', $str + ', ', $str); 68 | $str: $str + quotes($key) + ': ' + encode($value); 69 | } 70 | 71 | @return '{#{$str}}'; 72 | } 73 | 74 | // Encode Number 75 | // ------------- 76 | /// Encode a number for JSON, 77 | /// adding proof-quotes for length values. 78 | /// 79 | /// @group config_api-utilities 80 | /// 81 | /// @param {number} $number - 82 | /// Number to be encoded for JSON exporting 83 | @function encode-number($number) { 84 | @if math.is-unitless($number) { 85 | @return meta.inspect($number); 86 | } 87 | 88 | @return quotes($number); 89 | } 90 | 91 | // Quote 92 | // ----- 93 | /// Convert any value to a double-quoted string. 94 | /// 95 | /// @group config_api-utilities 96 | /// @access private 97 | /// 98 | /// @param {*} $value - 99 | /// The value to inspect and quote. 100 | @function quotes($value) { 101 | $value: '#{$value}'; 102 | $value: escape-backslashes($value); 103 | $value: escape-quotes($value); 104 | 105 | @return '"#{$value}"'; 106 | } 107 | 108 | // Escape Quotes 109 | // ------------- 110 | /// Return a string, with internal quotes escaped. 111 | /// 112 | /// @group config_api-utilities 113 | /// @access private 114 | /// 115 | /// @param {string} $string - 116 | /// The string to be manipulated 117 | @function escape-quotes($string) { 118 | $return: $string; 119 | $old: '"'; 120 | $new: '\\"'; 121 | $i: string.index($string, $old); 122 | $n: string.length($old); 123 | 124 | @if ($string == $old) { 125 | $return: $new; 126 | } @else if $i { 127 | $a: if($i > 1, string.slice($string, 1, $i - 1), ''); 128 | $z: string.slice($string, $i + $n); 129 | $z: escape-quotes($z); 130 | $return: $a + if($new, $new, '') + $z; 131 | } 132 | 133 | @return $return; 134 | } 135 | 136 | // Escape Backslashes 137 | // ------------------ 138 | /// Return a string, with internal backslashes escaped. 139 | /// 140 | /// @group config_api-utilities 141 | /// @access private 142 | /// 143 | /// @param {string} $string - 144 | /// The string to be manipulated 145 | @function escape-backslashes($string) { 146 | $return: $string; 147 | $old: '\\'; 148 | $new: '\\\\'; 149 | $i: string.index($string, $old); 150 | $n: string.length($old); 151 | 152 | @if ($string == $old) { 153 | $return: $new; 154 | } @else if $i { 155 | $a: if($i > 1, string.slice($string, 1, $i - 1), ''); 156 | $z: string.slice($string, $i + $n); 157 | $z: escape-backslashes($z); 158 | $return: $a + if($new, $new, '') + $z; 159 | } 160 | 161 | @return $return; 162 | } 163 | -------------------------------------------------------------------------------- /scss/config/_scale.scss: -------------------------------------------------------------------------------- 1 | @use 'abstracts'; 2 | 3 | // Scale Settings 4 | // ============== 5 | 6 | /// # Herman Config: Sizes 7 | /// @group config-scale 8 | /// @link https://www.oddbird.net/accoutrement/docs/ 9 | /// Accoutrement 10 | 11 | // Text Ratios 12 | // ----------- 13 | /// We only use ratios to establish line-height on Herman, 14 | /// since the font sizes are all responsive calculations. 15 | /// 16 | /// @ratios 17 | /// @group config-scale 18 | $text-ratios: ( 19 | 'line-height': 1.4, 20 | ); 21 | 22 | @include abstracts.add('ratios', 'text-ratios', $text-ratios); 23 | 24 | // Root Sizes 25 | // ---------- 26 | /// We use a `responsive` viewport-based size 27 | /// for the root of our typography, 28 | /// with a `px`-based fallback for older browsers 29 | /// and unit conversions. 30 | /// We also provide `small` and `large` 31 | /// font-sizes to be used in special cases. 32 | /// 33 | /// These sizes should only be accessed for establishing 34 | /// the root size on the `html` tag, 35 | /// or assigned more semantic names before applying to 36 | /// patterns and components. 37 | /// 38 | /// @sizes 39 | /// @group config-scale 40 | $root-sizes: ( 41 | 'root': 18px, 42 | 'responsive': calc(1em + 0.125vw), 43 | 'large': calc(1rem + 0.5vw), 44 | 'small': 0.9rem, 45 | ); 46 | 47 | @include abstracts.add('sizes', 'root-sizes', $root-sizes); 48 | 49 | // Text Sizes 50 | // ---------- 51 | /// The `reset` size (`1rem`) allows you to reset 52 | /// to the root font size from inside any element. 53 | /// We also provide a set of pattern-specific sizes 54 | /// to use directly in component styles. 55 | /// 56 | /// @sizes 57 | /// @group config-scale 58 | $text-sizes: ( 59 | 'reset': 1rem, 60 | 'h1': calc(1rem + 2vw), 61 | 'h2': calc(1rem + 1vw), 62 | 'h3': '#large', 63 | 'quote': '#large', 64 | 'code': '#small', 65 | 'footer': '#small', 66 | 'search': '#small', 67 | ); 68 | 69 | @include abstracts.add('sizes', 'text-sizes', $text-sizes); 70 | 71 | // Spacing Sizes 72 | // ------------- 73 | /// A mostly-linear scale of spacing-sizes 74 | /// based on fractions and multiples of the base line-height. 75 | /// These can be used to add spacing between components, 76 | /// and provide site hierarchy. 77 | /// 78 | /// @sizes {ruler} 79 | /// @group config-scale 80 | $spacing-sizes: ( 81 | 'rhythm': 1rem 82 | ( 83 | 'scale': 'line-height' 1, 84 | ), 85 | 'gutter': '#rhythm', 86 | 'gutter-plus': '#gutter' 87 | ( 88 | 'plus': '#shim', 89 | ), 90 | 'double-gutter': '#gutter' 91 | ( 92 | 'times': 2, 93 | ), 94 | 'triple-gutter': '#gutter' 95 | ( 96 | 'times': 3, 97 | ), 98 | 'flex-gutter': 'calc(#shim + 2.5vw)', 99 | 'spacer': 'calc(#triple-gutter + 2.5vw)', 100 | 'gutter-minus': '#gutter' 101 | ( 102 | 'minus': '#half-shim', 103 | ), 104 | 'shim': '#gutter' 105 | ( 106 | 'times': 0.5, 107 | ), 108 | 'half-shim': '#shim' 109 | ( 110 | 'times': 0.5, 111 | ), 112 | 'quarter-shim': '#shim' 113 | ( 114 | 'times': 0.25, 115 | ), 116 | ); 117 | 118 | @include abstracts.add('sizes', 'spacing-sizes', $spacing-sizes); 119 | 120 | // Pattern Sizes 121 | // ------------- 122 | /// Semantically-named sizes 123 | /// for managing patterns and component layouts. 124 | /// 125 | /// @sizes {ruler} 126 | /// @group config-scale 127 | $pattern-sizes: ( 128 | 'nav-underline': 4px, 129 | 'nav-icon': 28px, 130 | 'arrow-border': 8px, 131 | 'arrow-depth': '#shim', 132 | 'arrow-side': '#gutter', 133 | 'font-preview': 24em, 134 | 'specimen-aa': '#rhythm' 135 | ( 136 | 'times': 3, 137 | ), 138 | 'color-preview': 16em, 139 | 'color-swatch': '#rhythm' 140 | ( 141 | 'times': 4, 142 | ), 143 | 'footer-logo': '#rhythm' 144 | ( 145 | 'times': 2, 146 | ), 147 | ); 148 | 149 | @include abstracts.add('sizes', 'pattern-sizes', $pattern-sizes); 150 | 151 | // Layout Sizes 152 | // ------------- 153 | /// Layout-specific sizes, 154 | /// for establishing larger widths and breakpoints. 155 | /// 156 | /// @sizes {ruler-large} 157 | /// @group config-scale 158 | $layout-sizes: ( 159 | 'page': 50rem, 160 | 'item-break': 40em, 161 | 'page-break': 50em, 162 | 'nav-break': 65em, 163 | ); 164 | 165 | @include abstracts.add('sizes', 'layout-sizes', $layout-sizes); 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Herman][herman] [a [SassDoc][sassdoc] theme] 2 | 3 | [![Build Status](https://github.com/oddbird/sassdoc-theme-herman/actions/workflows/test.yml/badge.svg)](https://github.com/oddbird/sassdoc-theme-herman/actions/workflows/test.yml) 4 | 5 | > **If it's not documented, it doesn't exist.** 6 | > Documentation should become the default -- 7 | > an integrated part of the development process. 8 | > 9 | > ---Miriam Suzanne 10 | 11 | At [OddBird][oddbird], 12 | we wanted better tools for documenting 13 | the entire front end of a project -- 14 | from brand guidelines to UX patterns and code APIs: 15 | 16 | - Documenting the intersection of languages and styles 17 | - Written directly in the code, 18 | and integrated with code architecture 19 | - Automated for a document that grows and changes 20 | along with the life of your project 21 | 22 | Herman is built as an extension to [SassDoc][sassdoc], 23 | and supports all their core functionality 24 | with additional support for 25 | [font specimens][font-docs], [color palettes][color-preview], 26 | [sizes and ratios][size-preview], [SVG icons][icon-docs], 27 | [compiled languages][example-docs], Nunjucks/Jinja macros, HTML previews, 28 | and more. 29 | 30 | [font-docs]: https://www.oddbird.net/herman/docs/demo_fonts 31 | [color-preview]: https://www.oddbird.net/herman/docs/demo_colors 32 | [size-preview]: https://www.oddbird.net/herman/docs/demo_sizes 33 | [icon-docs]: https://www.oddbird.net/herman/docs/demo_icons 34 | [example-docs]: https://www.oddbird.net/herman/docs/demo_examples 35 | 36 | ## Getting Started 37 | 38 | ```bash 39 | npm install sassdoc sassdoc-theme-herman 40 | ``` 41 | 42 | Note: Dart Sass (`sass`) 43 | is required to use Herman 44 | to display samples of Sass/Scss code. 45 | If it's not already installed in your project, 46 | install it along with Herman: 47 | 48 | ```bash 49 | npm install sass 50 | ``` 51 | 52 | See the [SassDoc documentation](http://sassdoc.com/getting-started/) 53 | to run SassDoc via various build tools. 54 | Specify `herman` as the theme 55 | in your SassDoc options: 56 | 57 | ```bash 58 | sassdoc --theme herman 59 | ``` 60 | 61 | ### SassDoc Comments 62 | 63 | Currently, 64 | all SassDoc/Herman annotations are written as Sass comments 65 | starting with `///` to differentiate documentation 66 | from other developer comments (`//`). 67 | 68 | ```scss 69 | // This comment will be ignored by Herman 70 | /// This comment will be rendered in the documentation 71 | ``` 72 | 73 | Annotation comments can be free-floating, 74 | or attached to a particular Sass/CSS object -- 75 | such as a variable, mixin, function, or selector block. 76 | Note that while SassDoc allows annotation comments 77 | to be separated from the documented code by newlines, 78 | Herman considers documentation to be free-floating "prose" if 79 | it is separated from documented code by one or more newlines. 80 | 81 | ```scss 82 | /// this is a free-floating comment 83 | 84 | /// this comment is attached to the following mixin code-block 85 | @mixin sample-object { … } 86 | ``` 87 | 88 | ### Herman Annotations 89 | 90 | In addition to the core SassDoc annotations, 91 | our [`@icons` annotation][icon-docs] allows you to 92 | display SVG icons from a given folder, 93 | and we extend the core [`@example` annotation][example-docs] 94 | to display compiled Sass/Nunjucks output 95 | and render sample components. 96 | We also provide a [`@font` annotation][font-docs] 97 | for displaying font-specimens, 98 | and `@colors`, `@sizes`, and `@ratios` annotations 99 | for displaying [color-palettes][color-preview], 100 | [text and spacing sizes, and modular ratios][size-preview]. 101 | 102 | [herman]: https://www.oddbird.net/herman/ 103 | [oddbird]: https://www.oddbird.net/ 104 | [sassdoc]: http://sassdoc.com/ 105 | 106 | [See the full documentation for details »][docs] 107 | 108 | [docs]: https://www.oddbird.net/herman/docs/configuration 109 | 110 | ## SassDoc Extras 111 | 112 | Herman uses a number of SassDoc Extras: 113 | 114 | - [Description](http://sassdoc.com/extra-tools/#description-description-descriptionpath) 115 | - [Display](http://sassdoc.com/extra-tools/#display-toggle-display) 116 | - [GroupName](http://sassdoc.com/extra-tools/#groups-aliases-groupname) 117 | - [ShortcutIcon](http://sassdoc.com/extra-tools/#shortcut-icon-shortcuticon) 118 | - [Sort](http://sassdoc.com/extra-tools/#sort-sort) 119 | - [ResolveVariables](http://sassdoc.com/extra-tools/#resolved-variables-resolvevariables) 120 | -------------------------------------------------------------------------------- /scss/config/_colors.scss: -------------------------------------------------------------------------------- 1 | @use 'abstracts'; 2 | 3 | // Color Settings 4 | // ============== 5 | 6 | /// # Herman Config: Color Palettes 7 | /// This page contains documentation of the colors used by Herman. 8 | /// We use OddBird's [Accoutrement][ac] Color plugin 9 | /// to store and manage colors directly in Sass map objects. 10 | /// 11 | /// [ac]: https://www.oddbird.net/accoutrement/ 12 | /// 13 | /// For help documenting your brand colors, 14 | /// see the "[Documenting Colors][colors]" page. 15 | /// 16 | /// [colors]: https://www.oddbird.net/herman/docs/demo_colors 17 | /// @group config-colors 18 | /// @link https://www.oddbird.net/accoutrement/docs/ 19 | /// Accoutrement 20 | 21 | // Brand Colors 22 | // ------------ 23 | /// We use bright primary colors for the main brand, 24 | /// everything else is based on these colors. 25 | /// These are based on the OddBird brand colors, 26 | /// but using a darker shade of the OddBird pink. 27 | /// 28 | /// These colors should be assigned to semantic theme-specific names 29 | /// before they are used in styling patterns and components. 30 | /// 31 | /// @colors 32 | /// @group config-colors 33 | /// 34 | /// @type map 35 | /// @prop {Color | String | List} '' - 36 | /// Each `` key can be assigned a color 37 | /// or previously-defined key, 38 | /// followed by an optional map of adjustments 39 | /// (as defined in 40 | /// [Accoutrement](https://www.oddbird.net/accoutrement/docs/)). 41 | $brand-colors: ( 42 | 'brand-orange': hsl(24deg, 100%, 39%), 43 | 'brand-blue': hsl(195deg, 85%, 35%), 44 | 'brand-pink': hsl(330deg, 85%, 48%) 45 | ( 46 | 'shade': 25%, 47 | ), 48 | ); 49 | 50 | @include abstracts.add('colors', 'brand-colors', $brand-colors); 51 | 52 | // Neutral Colors 53 | // -------------- 54 | /// Use these neutral colors 55 | /// to create structure and hierarchy in the document. 56 | /// These colors should be assigned to semantic theme-specific names 57 | /// before they are used in styling patterns and components. 58 | /// 59 | /// @colors 60 | /// @group config-colors 61 | $neutral-colors: ( 62 | 'light-gray': '#brand-blue' 63 | ( 64 | 'tint': 80%, 65 | 'adjust': ( 66 | 'saturation': -80%, 67 | 'space': hsl, 68 | ), 69 | ), 70 | 'gray': '#brand-blue' 71 | ( 72 | 'adjust': ( 73 | 'saturation': -80%, 74 | 'space': hsl, 75 | ), 76 | ), 77 | 'contrast-light': #fff, 78 | 'contrast-dark': '#brand-blue' 79 | ( 80 | 'shade': 30% #000, 81 | 'adjust': ( 82 | 'saturation': -80%, 83 | 'space': hsl, 84 | ), 85 | ), 86 | ); 87 | 88 | @include abstracts.add('colors', 'neutral-colors', $neutral-colors); 89 | 90 | // Theme Colors 91 | // ------------ 92 | /// Rather than directly accessing and adjusting 93 | /// the explicitly named (e.g. "pink" or "gray") 94 | /// brand and neutral colors, 95 | /// we assign them to semantic theme-specific names. 96 | /// These are the colors that should be used 97 | /// in our pattern/component styles. 98 | /// 99 | /// @colors 100 | /// @group config-colors 101 | $theme-colors: ( 102 | 'theme-dark': '#brand-blue', 103 | 'theme-light': '#brand-blue' 104 | ( 105 | 'tint': 80%, 106 | ), 107 | 'background': '#contrast-light', 108 | 'text': '#contrast-dark', 109 | 'text-light': '#gray', 110 | 'action': '#brand-pink', 111 | 'focus': '#theme-dark', 112 | 'underline': '#action' 113 | ( 114 | 'tint': 75%, 115 | ), 116 | 'border': '#gray', 117 | 'border-light': '#light-gray', 118 | 'shadow': '#gray' 119 | ( 120 | 'rgba': 0.5, 121 | ), 122 | 'callout': '#theme-light', 123 | 'slight': '#callout' 124 | ( 125 | 'tint': 90%, 126 | ), 127 | 'code': '#theme-dark', 128 | 'code-shadow': '#code' 129 | ( 130 | 'rgba': 0.2, 131 | ), 132 | ); 133 | 134 | @include abstracts.add('colors', 'theme-colors', $theme-colors); 135 | 136 | // hljs colors 137 | // ----------- 138 | /// Colors for code-highlighting with hljs, 139 | /// based on [Solarized-Light](https://ethanschoonover.com/solarized/) 140 | /// & the Herman brand. 141 | /// @group config-colors 142 | /// @colors 143 | $hljs-colors: ( 144 | 'hljs-comment': #93a1a1, 145 | 'hljs-green': #859900, 146 | 'hljs-cyan': #2aa198, 147 | 'hljs-blue': #268bd2, 148 | 'hljs-yellow': #b58900, 149 | 'hljs-orange': #cb4b16, 150 | 'hljs-red': #dc322f, 151 | 'hljs-formula': #eee8d5, 152 | ); 153 | 154 | @include abstracts.add('colors', 'hljs-colors', $hljs-colors); 155 | -------------------------------------------------------------------------------- /scss/patterns/_type.scss: -------------------------------------------------------------------------------- 1 | // Typography Patterns 2 | // =================== 3 | 4 | @use 'pkg:accoutrement' as tools; 5 | @use '../config'; 6 | 7 | // Text Blocks 8 | // ----------- 9 | /// There are some typographic elements 10 | /// that can't be applied globally, 11 | /// but will be generated in markdown-rendered html-text blocks. 12 | /// The `text-block` class can be used to provide 13 | /// max line-length and other typographic features -- 14 | /// headings, blockquotes, lists, code, emphasis, etc. 15 | /// 16 | /// @group style-typography 17 | /// 18 | /// @example html 19 | ///
20 | ///

HTML Ipsum Presents

21 | /// 22 | ///

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

23 | /// 24 | ///

Header Level 2

25 | /// 26 | ///
    27 | ///
  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. 28 | ///
  3. Aliquam tincidunt mauris eu risus.
  4. 29 | ///
30 | /// 31 | ///
32 | ///

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

33 | /// —Anonymous 34 | ///
35 | /// 36 | ///

Header Level 3

37 | /// 38 | ///
    39 | ///
  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • 40 | ///
  • Aliquam tincidunt mauris eu risus.
  • 41 | ///
42 | /// 43 | ///
#header h1 a {
 44 | ///     display: block;
 45 | ///     width: 300px;
 46 | ///     height: 80px;
 47 | ///   }
48 | ///
49 | .text-block { 50 | max-width: 80ch; 51 | 52 | h1, 53 | h2, 54 | h3, 55 | h4, 56 | h5, 57 | h6 { 58 | margin-top: 0; 59 | } 60 | 61 | h1, 62 | h2, 63 | h3, 64 | h4 { 65 | color: tools.color('text'); 66 | 67 | a { 68 | #{config.$link} { 69 | text-decoration-color: transparent; 70 | } 71 | 72 | #{config.$focus} { 73 | text-decoration-color: currentcolor; 74 | } 75 | } 76 | 77 | + h2, 78 | + h3 { 79 | margin-top: 0; 80 | } 81 | } 82 | 83 | h1 { 84 | font-size: tools.size('h1'); 85 | margin-bottom: tools.size('shim'); 86 | } 87 | 88 | h2, 89 | h3 { 90 | margin-top: tools.size('gutter-plus'); 91 | } 92 | 93 | > h2, 94 | > h3 { 95 | &:first-child { 96 | margin-top: 0; 97 | } 98 | } 99 | 100 | h2 { 101 | font-size: tools.size('h2'); 102 | margin-bottom: tools.size('shim'); 103 | } 104 | 105 | h3 { 106 | font-size: tools.size('h3'); 107 | font-weight: normal; 108 | margin-bottom: tools.size('shim'); 109 | } 110 | 111 | p, 112 | pre { 113 | margin-bottom: tools.size('gutter-minus'); 114 | } 115 | 116 | ol, 117 | ul { 118 | margin: 0 tools.size('gutter') tools.size('gutter-minus'); 119 | } 120 | 121 | li { 122 | > p { 123 | margin: tools.size('shim') 0; 124 | } 125 | } 126 | 127 | blockquote { 128 | border-left: tools.size('half-shim') solid tools.color('border-light'); 129 | font-size: tools.size('quote'); 130 | margin-left: tools.negative('gutter'); 131 | padding-left: tools.size('shim') + tools.size('half-shim'); 132 | 133 | > p { 134 | &:not(:last-child) { 135 | text-indent: -0.4em; 136 | 137 | @include tools.wrap-content { 138 | color: tools.color('theme-dark'); 139 | } 140 | } 141 | } 142 | 143 | cite { 144 | display: block; 145 | font-size: tools.size('reset'); 146 | font-style: normal; 147 | } 148 | } 149 | } 150 | 151 | // Invert Colors 152 | // ------------- 153 | /// Invert the colors of a block, creating a light-on-dark area. 154 | /// 155 | /// @group style-typography 156 | /// @require {mixin} invert-colors 157 | /// 158 | /// @example html 159 | ///
160 | /// You shall sojourn at Paris, Rome, and Naples. 161 | ///
162 | .config.invert-colors { 163 | @include config.invert-colors; 164 | } 165 | -------------------------------------------------------------------------------- /lib/annotations/example.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const beautify = require('html').prettyPrint; 4 | const stripIndent = require('strip-indent'); 5 | 6 | const renderIframe = require('../renderIframe'); 7 | const getCustomNunjucksEnv = require('../utils/getCustomNunjucksEnv'); 8 | const { 9 | getSassImplementation, 10 | getSassCompilerPromise, 11 | getSassImporters, 12 | } = require('../utils/sass'); 13 | 14 | const beautifyOpts = { 15 | indent_size: 2, 16 | max_char: 0, 17 | }; 18 | 19 | /** 20 | * Custom `@example` annotation. 21 | * 22 | * Augments the normal sassdoc @example annotation. 23 | * If example language is 'njk' (nunjucks), render the example 24 | * and put the result in the `rendered` property of the parsed example. 25 | */ 26 | module.exports = (env) => { 27 | const baseExampleFn = 28 | require('sassdoc/dist/annotation/annotations/example').default; 29 | const baseExample = baseExampleFn(); 30 | let sass, sassCompilerPromise; 31 | return { 32 | ...baseExample, 33 | resolve: (data) => { 34 | let customNjkEnv; 35 | let warned = false; 36 | const iFramePromises = []; 37 | const sassPromises = []; 38 | data.forEach((item) => { 39 | if (!item.example) { 40 | return; 41 | } 42 | item.example.forEach((exampleItem) => { 43 | if (exampleItem.type === 'html') { 44 | exampleItem.rendered = beautify( 45 | stripIndent(exampleItem.code), 46 | beautifyOpts, 47 | ); 48 | } else if (exampleItem.type === 'njk') { 49 | if (!customNjkEnv) { 50 | customNjkEnv = getCustomNunjucksEnv( 51 | 'Nunjucks @example', 52 | env, 53 | warned, 54 | ); 55 | } 56 | if (!customNjkEnv) { 57 | warned = true; 58 | return; 59 | } 60 | exampleItem.rendered = beautify( 61 | stripIndent(customNjkEnv.renderString(exampleItem.code)), 62 | beautifyOpts, 63 | ).trim(); 64 | } else if (exampleItem.type === 'scss') { 65 | exampleItem.rendered = undefined; 66 | /* istanbul ignore else */ 67 | if (!sass) { 68 | sass = getSassImplementation(env); 69 | } 70 | /* istanbul ignore if */ 71 | if (!sass) { 72 | return; 73 | } 74 | /* istanbul ignore else */ 75 | if (!sassCompilerPromise) { 76 | sassCompilerPromise = getSassCompilerPromise(env, sass); 77 | } 78 | /* istanbul ignore if */ 79 | if (!sassCompilerPromise) { 80 | return; 81 | } 82 | let sassData = exampleItem.code; 83 | let sassOptions = {}; 84 | /* istanbul ignore else */ 85 | if (env.herman && env.herman.sass) { 86 | if (env.herman.sass.use) { 87 | const arr = env.herman.sass.use; 88 | for (let i = arr.length - 1; i >= 0; i = i - 1) { 89 | const use = arr[i]; 90 | const isString = typeof use === 'string'; 91 | const isObj = !isString && use.file && use.namespace; 92 | if (isObj) { 93 | sassData = `@use '${use.file}' as ${use.namespace};\n${sassData}`; 94 | } else if (isString) { 95 | sassData = `@use '${use}';\n${sassData}`; 96 | } 97 | } 98 | } 99 | if (!env.herman.sass.sassOptions?.importers) { 100 | sassOptions.importers = getSassImporters(env, sass); 101 | } 102 | sassOptions = { 103 | ...sassOptions, 104 | ...env.herman.sass.sassOptions, 105 | }; 106 | } 107 | const promise = sassCompilerPromise 108 | .then((compiler) => 109 | compiler.compileStringAsync(sassData, sassOptions), 110 | ) 111 | .then(({ css }) => { 112 | exampleItem.rendered = css; 113 | }) 114 | .catch((err) => { 115 | env.logger.warn( 116 | 'Error compiling @example scss: \n' + 117 | `${err.message}\n${sassData}`, 118 | ); 119 | }); 120 | sassPromises.push(promise); 121 | } 122 | const iframePromise = Promise.all(sassPromises) 123 | .finally(() => { 124 | if (sassCompilerPromise) { 125 | sassCompilerPromise.then((compiler) => { 126 | compiler.dispose(); 127 | }); 128 | sassCompilerPromise = null; 129 | } 130 | }) 131 | .then(() => renderIframe(env, exampleItem, 'example')); 132 | iFramePromises.push(iframePromise); 133 | }); 134 | }); 135 | return Promise.all(iFramePromises); 136 | }, 137 | }; 138 | }; 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sassdoc-theme-herman", 3 | "title": "Herman", 4 | "description": "An Odd SassDoc theme.", 5 | "version": "7.0.0", 6 | "homepage": "https://www.oddbird.net/herman/", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/oddbird/sassdoc-theme-herman.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/oddbird/sassdoc-theme-herman/issues" 14 | }, 15 | "keywords": [ 16 | "sassdoc-theme" 17 | ], 18 | "main": "./index.js", 19 | "exports": { 20 | "sass": "./scss/_utilities.scss", 21 | "default": "./index.js" 22 | }, 23 | "files": [ 24 | "index.js", 25 | "dist/", 26 | "!dist/webpack/app_styles.min.js", 27 | "!dist/webpack/iframe_styles.min.js", 28 | "!dist/webpack/styleguide_json*", 29 | "lib/", 30 | "templates/", 31 | "scss/utilities/", 32 | "scss/_utilities.scss", 33 | "CHANGELOG.md" 34 | ], 35 | "engines": { 36 | "node": ">=20" 37 | }, 38 | "scripts": { 39 | "build": "run-s build:sprites build:webpack", 40 | "serve": "run-s build:sprites serve:webpack", 41 | "lint": "run-p lint:js lint:sass prettier:other", 42 | "test": "run-s test:sass test:js", 43 | "serve:webpack": "webpack serve", 44 | "build:webpack": "webpack", 45 | "build:sprites": "node scripts/compile-sprites.js", 46 | "prettier:js": "prettier --write \"**/*.js\"", 47 | "prettier:other": "prettier --write \"**/*.{json,yml,md,scss}\"", 48 | "eslint": "yarn lint:js:ci --fix", 49 | "lint:js": "run-s prettier:js eslint", 50 | "lint:js:ci": "eslint \"*.js\" \"**/*.js\"", 51 | "lint:sass": "yarn lint:sass:ci --fix", 52 | "lint:sass:ci": "stylelint \"**/*.scss\"", 53 | "lint:ci": "run-p lint:js:ci lint:sass:ci", 54 | "test:js": "run-s test:js:client test:js:src", 55 | "test:js:client": "jest", 56 | "test:js:src": "nyc mocha -t 5000 -R dot test/js", 57 | "test:js:client:ci": "jest --ci --runInBand --reporters=default --reporters=jest-junit", 58 | "test:js:src:ci": "nyc mocha -t 5000 -R dot -R mocha-junit-reporter test/js", 59 | "test:js:watch": "yarn test:js:client --watchAll", 60 | "test:sass": "mocha -R dot test/sass/test_sass.js", 61 | "prepack": "yarn build" 62 | }, 63 | "dependencies": { 64 | "cheerio": "^1.1.2", 65 | "colorjs.io": "^0.5.2", 66 | "css-tree": "^3.1.0", 67 | "gulp-rename": "^2.1.0", 68 | "gulp-svg-symbols": "^3.2.3", 69 | "html": "^1.0.0", 70 | "lunr": "^2.3.9", 71 | "markdown-it": "^14.1.0", 72 | "markdown-it-anchor": "^9.2.0", 73 | "nunjucks": "^3.2.4", 74 | "readable-stream": "^4.7.0", 75 | "sassdoc-extras": "^3.0.0", 76 | "strip-indent": "^3.0.0", 77 | "typogr": "^0.6.8", 78 | "unixify": "^1.0.0", 79 | "vinyl-fs": "^4.0.2" 80 | }, 81 | "peerDependencies": { 82 | "sassdoc": "^2.5.0" 83 | }, 84 | "devDependencies": { 85 | "@babel/core": "^7.28.5", 86 | "@babel/eslint-parser": "^7.28.5", 87 | "@babel/preset-env": "^7.28.5", 88 | "@eslint/js": "^9.39.1", 89 | "@testing-library/dom": "^10.4.1", 90 | "@testing-library/jest-dom": "^6.9.1", 91 | "accoutrement": "^4.0.6", 92 | "autoprefixer": "^10.4.22", 93 | "babel-jest": "^30.2.0", 94 | "babel-loader": "^10.0.0", 95 | "chalk": "^4.1.2", 96 | "core-js": "^3.47.0", 97 | "css-loader": "^7.1.2", 98 | "css-minimizer-webpack-plugin": "^7.0.2", 99 | "cssremedy": "^0.1.0-beta.2", 100 | "del": "^6.1.1", 101 | "eslint": "^9.39.1", 102 | "eslint-config-prettier": "^10.1.8", 103 | "eslint-import-resolver-node": "^0.3.9", 104 | "eslint-import-resolver-webpack": "^0.13.10", 105 | "eslint-plugin-import": "^2.32.0", 106 | "eslint-plugin-jest": "^29.2.1", 107 | "eslint-plugin-jest-dom": "^5.5.0", 108 | "eslint-plugin-simple-import-sort": "^12.1.1", 109 | "globals": "^16.5.0", 110 | "highlight.js": "^11.11.1", 111 | "jest": "^30.2.0", 112 | "jest-environment-jsdom": "^30.2.0", 113 | "jest-junit": "^16.0.0", 114 | "jinja-loader": "^0.0.8", 115 | "jquery": "^3.7.1", 116 | "jquery-deparam": "^0.5.3", 117 | "js-yaml": "^4.1.1", 118 | "json-loader": "^0.5.7", 119 | "lodash": "^4.17.21", 120 | "mark.js": "^8.11.1", 121 | "matchmedia-polyfill": "^0.3.2", 122 | "mini-css-extract-plugin": "^2.9.4", 123 | "mocha": "^11.7.5", 124 | "mocha-junit-reporter": "^2.2.1", 125 | "npm-run-all": "^4.1.5", 126 | "nyc": "^17.1.0", 127 | "postcss": "^8.5.6", 128 | "postcss-loader": "^8.2.0", 129 | "prettier": "^3.7.3", 130 | "sass": "^1.94.2", 131 | "sass-embedded": "^1.93.3", 132 | "sass-loader": "^16.0.6", 133 | "sass-true": "^10.0.0", 134 | "sassdoc": "^2.7.4", 135 | "sinon": "^21.0.0", 136 | "srcdoc-polyfill": "^1.0.0", 137 | "stylelint": "^16.26.1", 138 | "stylelint-config-standard-scss": "^16.0.0", 139 | "svgo": "^4.0.0", 140 | "webpack": "^5.103.0", 141 | "webpack-cli": "^6.0.1", 142 | "webpack-dev-server": "^5.2.2" 143 | }, 144 | "resolutions": { 145 | "cdocparser": "^0.15.0" 146 | }, 147 | "packageManager": "yarn@4.12.0" 148 | } 149 | -------------------------------------------------------------------------------- /scss/samples/_mixins-functions.scss: -------------------------------------------------------------------------------- 1 | // Mixin and Function Documentation 2 | // ================================ 3 | 4 | /// # SassDoc Annotations 5 | /// 6 | /// Herman supports all the core 7 | /// [SassDoc annotations](http://sassdoc.com/annotations/). 8 | /// This page includes samples of every core annotation type, 9 | /// so you can see what they look like in Herman. 10 | /// @group demo_test-sassdoc 11 | /// @link http://sassdoc.com/annotations/ 12 | 13 | /// Extra Commentary 14 | /// ================ 15 | /// Herman allows you to write "prose" markdown blocks, 16 | /// to help introduce groups, 17 | /// or add narrative flow between documented items. 18 | /// 19 | /// Note that while SassDoc allows annotation comments 20 | /// to be separated from the documented code by newlines, 21 | /// Herman considers documentation to be free-floating "prose" if 22 | /// it is separated from documented code by one or more newlines. 23 | /// 24 | /// This change often breaks the `autofill` functionality 25 | /// of the core SassDoc annotations, so Herman skips the 26 | /// annotation `autofill` functionality for any "prose" items. 27 | /// 28 | /// Prose blocks can also include examples, and other annotations: 29 | /// ``` 30 | /// /// Prose blocks can also include examples, and other annotations: 31 | /// /// @example scss 32 | /// /// .private::after { 33 | /// /// content: samples.$private; 34 | /// /// } 35 | /// /// @link http://sassdoc.com/configuration/#autofill 36 | /// ``` 37 | /// @example scss 38 | /// .private::after { 39 | /// content: samples.$private; 40 | /// } 41 | /// @link http://sassdoc.com/configuration/#autofill 42 | /// @group demo_test-sassdoc 43 | 44 | // Herman Sample 45 | // ------------- 46 | /// This is a sample function. 47 | /// You can actually put much more text in here, 48 | /// and include markdown. 49 | /// Lorem markdownum rapite neque loquentis pro inque; nullaque [triones 50 | /// promisistis](http://duabuset-tandem.com/denos) quaque. Non iura oves creatas 51 | /// mixta gente torum Caune. Huius sacra *corpora refert celeberrima* luctus tibi 52 | /// cornua, nec qui [illum poteram](http://puta-elementa.com/vocequoque), et eundi 53 | /// recalfecit efficiens isdem superi. Alii virginibusque vultus certa socios venae 54 | /// ferrove finis longave, pignus duros nubifer! 55 | /// 56 | /// - Tenuatus percussae tanta iudice Hippolytus miseram inmunis 57 | /// - Ture Iovis holus est et vere 58 | /// - Domus cum quid meae erras 59 | /// - Quam apta fata puppis ergo solis praeteriti 60 | /// 61 | /// @group demo_test-sassdoc 62 | /// @see {function} sample-alias 63 | /// @link https://www.oddbird.net OddBird Home Page 64 | /// @todo Create more samples like this one 65 | /// @todo Add more todo items 66 | /// @author [Miriam Suzanne](https://www.oddbird.net/authors/miriam/) 67 | /// @since 1.0.0 Adding samples files to the documentation. 68 | /// @example scss - describe examples if you want... 69 | /// .example::before { 70 | /// content: samples.herman-sample(1, 2); 71 | /// } 72 | /// @param {number} $one - 73 | /// The first parameter is required, because no default is given 74 | /// @param {number} $two [$one] - 75 | /// Our second parameter defaults to the value of our first one 76 | /// @param {string} $three ['three'] - 77 | /// Looks like this one takes a string instead 78 | /// @returns {list} All three of the arguments in order 79 | @function herman-sample($one, $two: $one, $three: 'three') { 80 | @if (not $one) or (not $two) or (not $three) { 81 | @error 'Please provide values for all three parameters'; 82 | } 83 | 84 | @return $one $two $three; 85 | } 86 | 87 | // Sample Alias 88 | // ------------ 89 | /// This is a sample function alias. 90 | /// @group demo_test-sassdoc 91 | /// @alias herman-sample 92 | /// @deprecated I don't think this alias is useful anymore 93 | @function sample-alias($one, $two: $one, $three: 'three') { 94 | @return herman-sample($one, $two, $three); 95 | } 96 | 97 | // Sample Alias Two 98 | // ---------------- 99 | /// This is a sample function alias. 100 | /// @group demo_test-sassdoc 101 | /// @alias herman-sample 102 | /// @deprecated Doesn't seem to work without a message 103 | @function sample-alias-two($one, $two: $one, $three: 'three') { 104 | @return herman-sample($one, $two, $three); 105 | } 106 | 107 | // Mixin with Content 108 | // ------------------ 109 | /// Mixins can optionally accept a @content block, 110 | /// passed in brackets. This example creates 111 | /// a generated element `:before` the element it is used on. 112 | /// @group demo_test-sassdoc 113 | /// @param {String} $content - 114 | /// A value for the `content` property 115 | /// @param {arglist} $list… - 116 | /// Sass also allows arglists! 117 | /// @content 118 | /// Any other styles that should be applied to the ::before element 119 | /// @output 120 | /// A ::before pseudo-element, 121 | /// with the content property established, 122 | /// and any passed-in styled applied. 123 | /// @example scss 124 | /// .item { 125 | /// @include samples.before-sample('Test One: ') { 126 | /// color: red; 127 | /// } 128 | /// } 129 | @mixin before-sample($content, $list...) { 130 | &::before { 131 | content: $content; 132 | @content; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /assets/js/base.js: -------------------------------------------------------------------------------- 1 | import sassConfig from '../../scss/json.scss'; 2 | 3 | export const initializeToggles = () => { 4 | const body = $('body'); 5 | 6 | body.on('toggle:close', '[data-toggle="button"]', function cb() { 7 | const id = $(this).attr('aria-controls'); 8 | const target = $(`[data-target-id="${id}"]`); 9 | const openToggles = $( 10 | `[data-toggle="button"][aria-controls="${id}"][aria-pressed="true"]`, 11 | ); 12 | openToggles.attr('aria-pressed', 'false'); 13 | target.trigger('target:close'); 14 | }); 15 | 16 | body.on('toggle:open', '[data-toggle="button"]', function cb() { 17 | const toggle = $(this); 18 | const targetID = toggle.attr('aria-controls'); 19 | const target = $(`[data-target-id="${targetID}"]`); 20 | const otherToggles = $( 21 | `[data-toggle="button"][aria-controls="${targetID}"]`, 22 | ).not(toggle); 23 | // If this is a synced toggle, open all other attached toggles 24 | if (toggle.data('toggle-synced')) { 25 | otherToggles 26 | .filter('[data-toggle-synced="true"]') 27 | .attr('aria-pressed', 'true'); 28 | // Otherwise, close other attached toggles. 29 | } else { 30 | otherToggles 31 | .filter('[aria-pressed="true"]') 32 | .attr('aria-pressed', 'false'); 33 | } 34 | toggle.attr('aria-pressed', 'true'); 35 | target.trigger('target:open'); 36 | }); 37 | 38 | body.on('target:close', '[data-toggle="target"]', function cb(evt) { 39 | const target = $(this); 40 | // Prevent event firing on multiple nested targets 41 | if ($(evt.target).is(target)) { 42 | target.attr('aria-expanded', 'false'); 43 | } 44 | }); 45 | 46 | const closeTarget = (target) => { 47 | // Close a target and update any attached toggles 48 | const id = target.attr('data-target-id'); 49 | const openToggles = $( 50 | `[data-toggle="button"][aria-controls="${id}"][aria-pressed="true"]`, 51 | ); 52 | if (openToggles.length) { 53 | openToggles.trigger('toggle:close'); 54 | } else { 55 | target.trigger('target:close'); 56 | } 57 | }; 58 | 59 | body.on('target:open', '[data-toggle="target"]', function cb(evt) { 60 | const target = $(this); 61 | // Prevent event firing on multiple nested targets 62 | if ($(evt.target).is(target)) { 63 | target.attr('aria-expanded', 'true'); 64 | } 65 | }); 66 | 67 | body.on('click', '[data-toggle="button"]', function cb(evt) { 68 | evt.preventDefault(); 69 | const toggle = $(this); 70 | if (toggle.attr('aria-pressed') === 'true') { 71 | toggle.trigger('toggle:close'); 72 | } else { 73 | toggle.trigger('toggle:open'); 74 | } 75 | }); 76 | 77 | body.on('click', '[data-toggle="close"]', function cb(evt) { 78 | evt.preventDefault(); 79 | const target = $(`[data-target-id="${$(this).attr('aria-controls')}"]`); 80 | closeTarget(target); 81 | }); 82 | 83 | const autoClose = (newTarget, target) => { 84 | const targetID = target.attr('data-target-id'); 85 | const toggleClicked = newTarget.closest( 86 | `[aria-controls="${targetID}"]`, 87 | ).length; 88 | const clickedElInDOM = document.contains(newTarget.get(0)); 89 | const clickedOutsideTarget = !newTarget.closest(target).length; 90 | const exception = target.attr('data-auto-closing-exception'); 91 | const clickedException = exception 92 | ? newTarget.closest(exception).length 93 | : false; 94 | if ( 95 | !toggleClicked && 96 | (target.data('auto-closing-on-any-click') || 97 | (clickedElInDOM && clickedOutsideTarget && !clickedException)) 98 | ) { 99 | closeTarget(target); 100 | } 101 | }; 102 | 103 | body.on('click', (evt) => { 104 | const openTargets = $( 105 | '[data-toggle="target"][aria-expanded="true"][data-auto-closing="true"]', 106 | ); 107 | openTargets.each((index, target) => { 108 | autoClose($(evt.target), $(target)); 109 | }); 110 | }); 111 | }; 112 | 113 | export const initializeIframes = () => { 114 | const fitIframeToContent = (iframe) => { 115 | /* istanbul ignore else */ 116 | if (iframe.contentWindow.document.body) { 117 | iframe.height = $(iframe.contentWindow.document).outerHeight(true); 118 | } 119 | }; 120 | const fitIframesToContent = () => { 121 | $('iframe').each(function cb() { 122 | fitIframeToContent(this); 123 | }); 124 | }; 125 | 126 | fitIframesToContent(); 127 | $('iframe').on('load', function cb() { 128 | fitIframeToContent(this); 129 | }); 130 | $(window).on('resize', fitIframesToContent); 131 | }; 132 | 133 | export const initializeNav = () => { 134 | const breakpoint = 135 | sassConfig && 136 | sassConfig.sizes && 137 | sassConfig.sizes['layout-sizes'] && 138 | sassConfig.sizes['layout-sizes']['nav-break']; 139 | 140 | /* istanbul ignore else */ 141 | if (breakpoint) { 142 | const nav = $('#nav'); 143 | const btn = $('[aria-controls="nav"]'); 144 | const mql = window.matchMedia(`(min-width: ${breakpoint})`); 145 | 146 | const screenTest = (e) => { 147 | if (e.matches) { 148 | /* the viewport is wider than the breakpoint */ 149 | nav.attr('aria-expanded', 'true'); 150 | } else { 151 | /* the viewport is narrower than the breakpoint */ 152 | nav.attr('aria-expanded', btn.attr('aria-pressed')); 153 | } 154 | }; 155 | 156 | screenTest(mql); 157 | mql.addListener(screenTest); 158 | } 159 | }; 160 | -------------------------------------------------------------------------------- /lib/renderHerman.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs/promises'); 4 | const path = require('path'); 5 | 6 | const lunr = require('lunr'); 7 | 8 | const copy = require('./utils/assets'); 9 | const parse = require('./utils/parse'); 10 | const render = require('./utils/render'); 11 | const { getNunjucksEnv, templates } = require('./utils/templates'); 12 | 13 | const renderHerman = (origDest, ctx) => { 14 | const indexDest = path.join(origDest, 'index.html'); 15 | const assetsSrc = path.resolve(__dirname, '../dist'); 16 | const nunjucksEnv = getNunjucksEnv(ctx); 17 | 18 | // Store array of rendered text (for site-wide search) 19 | const rendered = []; 20 | 21 | const dest = path.resolve(origDest); 22 | 23 | // check if we need to copy a favicon file or use the default 24 | let copyShortcutIcon = false; 25 | if (!ctx.shortcutIcon) { 26 | ctx.shortcutIcon = { type: 'internal', url: 'assets/img/favicon.ico' }; 27 | } else if (ctx.shortcutIcon.type === 'internal') { 28 | ctx.shortcutIcon.url = `assets/img/${ctx.shortcutIcon.url}`; 29 | copyShortcutIcon = true; 30 | } 31 | 32 | // render the index template and copy the static assets. 33 | const promises = [ 34 | render(nunjucksEnv, templates.index, indexDest, ctx, rendered), 35 | copy( 36 | path.join(assetsSrc, '/**/*.{css,js,ico,map}'), 37 | path.join(dest, 'assets'), 38 | ).then(() => { 39 | if (copyShortcutIcon) { 40 | return copy(ctx.shortcutIcon.path, path.resolve(dest, 'assets/img/')); 41 | } 42 | return Promise.resolve(); 43 | }), 44 | ]; 45 | 46 | // Copy custom CSS asset 47 | if (ctx.customCSS) { 48 | promises.push( 49 | copy(ctx.customCSS.path, path.resolve(dest, 'assets/custom'), { 50 | parser: parse.customCSS, 51 | env: ctx, 52 | }).then(() => { 53 | if (ctx.customCSSFiles && ctx.customCSSFiles.length) { 54 | return copy(ctx.customCSSFiles, path.resolve(dest, 'assets/custom'), { 55 | base: ctx.dir, 56 | }); 57 | } 58 | return Promise.resolve(); 59 | }), 60 | ); 61 | } 62 | 63 | // Copy custom source-map asset 64 | if ( 65 | (ctx.customCSS || ctx.herman.customSourceMap) && 66 | ctx.herman.customSourceMap !== false 67 | ) { 68 | promises.push( 69 | copy( 70 | ctx.herman.customSourceMap || `${ctx.customCSS.path}.map`, 71 | path.resolve(dest, 'assets/custom'), 72 | ), 73 | ); 74 | } 75 | 76 | if (ctx.localFonts && ctx.localFonts.length) { 77 | promises.push( 78 | copy(ctx.localFonts, path.resolve(dest, 'assets/fonts/'), { 79 | base: path.resolve(ctx.dir, ctx.herman.fontPath), 80 | }), 81 | ); 82 | } 83 | 84 | // Render a page for each item in `extraDocs`. 85 | if (ctx.extraDocs) { 86 | for (const { filename, name, text } of ctx.extraDocs) { 87 | const docDest = path.join(dest, `${filename}.html`); 88 | const docCtx = Object.assign({}, ctx, { 89 | pageTitle: name, 90 | activeGroup: filename, 91 | docText: text, 92 | }); 93 | promises.push( 94 | render(nunjucksEnv, templates.extraDoc, docDest, docCtx, rendered), 95 | ); 96 | } 97 | } 98 | 99 | const getRenderCtx = (context, groupName) => { 100 | let title; 101 | if (Object.prototype.hasOwnProperty.call(context.groups, groupName)) { 102 | title = context.groups[groupName]; 103 | } else if ( 104 | Object.prototype.hasOwnProperty.call(context.subgroupsByGroup, groupName) 105 | ) { 106 | title = context.groups[context.subgroupsByGroup[groupName]][groupName]; 107 | } 108 | return Object.assign({}, context, { 109 | pageTitle: title, 110 | activeGroup: groupName, 111 | items: context.byGroup[groupName], 112 | }); 113 | }; 114 | 115 | // Render a page for each group, too. 116 | Object.getOwnPropertyNames(ctx.byGroup).forEach((groupName) => { 117 | const groupDest = path.join(dest, `${groupName}.html`); 118 | const groupCtx = getRenderCtx(ctx, groupName); 119 | promises.push( 120 | render(nunjucksEnv, templates.group, groupDest, groupCtx, rendered), 121 | ); 122 | }); 123 | 124 | // Render a search-results page. 125 | const searchDest = path.join(dest, 'search.html'); 126 | const searchCtx = Object.assign({}, ctx, { activeGroup: 'search' }); 127 | promises.push(render(nunjucksEnv, templates.search, searchDest, searchCtx)); 128 | 129 | return Promise.all(promises).then(() => { 130 | // Write lunr.js search index 131 | const store = {}; 132 | const idx = lunr(function config() { 133 | this.ref('filename'); 134 | this.field('title'); 135 | this.field('contents'); 136 | // Expose position data of search matches 137 | this.metadataWhitelist = ['position']; 138 | 139 | // Sort array for consistent search-data ordering (prevent junk diffs) 140 | rendered.sort((a, b) => { 141 | const nameA = a.filename.toLowerCase(); 142 | const nameB = b.filename.toLowerCase(); 143 | /* istanbul ignore next */ 144 | return nameA < nameB ? -1 : 1; 145 | }); 146 | rendered.forEach((doc) => { 147 | this.add(doc); 148 | store[doc.filename] = doc; 149 | }); 150 | }); 151 | 152 | return fs.writeFile( 153 | path.join(dest, 'search-data.json'), 154 | JSON.stringify({ 155 | idx, 156 | store, 157 | }), 158 | 'utf8', 159 | ); 160 | }); 161 | }; 162 | 163 | module.exports = { 164 | renderHerman, 165 | }; 166 | --------------------------------------------------------------------------------