Wysiwyg example

How it works

One wrapper class styles whatever a rich text editor saved. No BEM classes on the inside.

A content component (c--content-*) styles the raw HTML that comes out of a rich text editor: headings, paragraphs, links, lists, quotes and images, exactly as the CMS serialised them. All the styling hangs off one wrapper class; the elements inside carry no classes of ours, because an editor cannot add them. Everything on this page is driven by the @terrahq/wysiwyg package, which ships the mixins and knows the markup of each CMS.

<!-- One wrapper. Everything inside is what the editor saved, with no classes of ours -->
<div class="c--content-a">
  <h2>Title</h2>
  <p>Paragraph with <strong>bold</strong>, <em>italic</em> and <a href="#">a link</a>.</p>
  <ul>
    <li>Item</li>
  </ul>
</div>

Sanity or WordPress

The package has one entry per CMS, and the import decides which markup is expected. @terrahq/wysiwyg/sanity styles the clean HTML of Portable Text: plain blockquote with text inside, figure with an optional size class, lists without classes. @terrahq/wysiwyg/wordpress knows the Gutenberg output too (wp-block-* classes, buttons, columns, tables, separators, cite in quotes). This project imports the Sanity flavour; the maps a flavour does not support throw a Sass error, so a wrong import fails at build time, not in the browser.

The two mixins

make-content() lays the groundwork once: it resets the lists and draws the bullets and the counters, sizes the figures, makes em italic, and removes the top padding of the first child and the bottom margin of the last one so the block sits flush in its column. make-content-modifier() is where the look comes from: it takes one option map per family of elements and only writes the properties you set. Calling it again inside a modifier (&--second, for a dark background) overrides just the keys that change.

The option maps

Every map is a Sass map. Spacing and colours are plain values ($measure * 4, map.get($color-options, a)), and type is set through "className": a space-separated list of foundation classes (f--font-e f--color-a) that the mixin applies with @extend. That keeps the content component on the same type scale as the rest of the site, with nothing in the markup. The font keys (className, margin-bottom, padding-top, font-weight, bold-font-weight…) are the same wherever text is styled: a heading, a paragraph, a caption, the numbers of a list.

Where it lives

The component is src/scss/framework/components/content/_c--content-a.scss, registered in src/scss/style.scss like any other component. It is also the block that wraps the prose of every page of these docs, so the file below is the one you are reading with.

//* content
@use "@scss/framework/components/content/_c--content-a.scss";
@use "@terrahq/wysiwyg/sanity" as *;
@use "sass:color";
@use "sass:map";
@use "@scss/framework/_vars/_vars.scss" as *;
@use "@scss/framework/utilities/utilities.scss" as *;
@use "@scss/framework/foundation/foundation.scss" as *;

.c--content-a{
    @include make-content();
    @include make-content-modifier(
        //* headings
        $headings-options: (
            "h2": (
                "className": f--font-a f--color-a,
                "margin-bottom": $measure * 4,
                "padding-top": $measure * 13,
            ),
            "h3": (
                "className": f--font-d f--color-a,
                "margin-bottom": $measure * 2,
                "padding-top": $measure * 5,
            ),
            "h4": (
                "className": f--font-e f--color-a,
                "font-weight": 700,
                "margin-bottom": $measure,
                "padding-top": $measure * 3,
            ),
            "h5": (
                "className": f--font-e f--color-a,
                "font-weight": 600,
                "margin-bottom": $measure,
                "padding-top": $measure * 2,
            ),
            "h6": (
                "className": f--font-f f--color-a,
                "font-weight": 600,
                "letter-spacing": 0.04em,
                "margin-bottom": $measure,
                "padding-top": $measure * 2,
            ),
        ),
        //* paragraphs
        $paragraphs-options: (
            "className": f--font-e f--color-a,
            "margin-bottom": $measure * 2,
            "bold-font-weight": 500,
        ),
        //* links
        $link-options: (
            "className": f--color-e,
        ),
        //* lists
        $lists-options: (
            "first-level-artwork-width": 6px,
            "first-level-artwork-top": 10px,
            "first-level-artwork-background": map.get($color-options, a),
            "first-level-artwork-border-radius": 50%,
            "first-level-artwork-border-width": 0,
            "first-level-artwork-border-color": map.get($color-options, a),
            "first-number-width": 1rem,
            "first-level-number-color": map.get($color-options, a),
            "number-font": (
                "className": f--font-e,
            ),
            "margin-bottom": $measure * 3,
            "nested-list-spacing": $measure,
            "font-styles": (
                "className": f--font-e f--color-a,
                "margin-bottom": $measure,
            ),
        ),
        //* images
        $image-options: (
            "border-radius": $measure,
            "margin-bottom": $measure * 4,
            "caption": (
                "className": f--font-f f--color-a,
                "padding-top": $measure,
                "text-align": center,
            ),
        ),
        //* quotes
        $quote-options: (
            "padding": $measure * 2 $measure * 3,
            "margin-top": $measure * 3,
            "margin-bottom": $measure * 4,
            "border": (
                "border-color": map.get($color-options, e),
                "border-style": solid,
                "border-width": 0 0 0 3px,
            ),
        ),
        //* custom
        $custom-options: (
            "blockquote": (
                "class": (f--font-d, f--color-a),
                "font-style": italic,
            ),
            "a:not([class])": (
                "text-decoration": underline,
                "text-underline-offset": 0.15em,
            ),
        ),
    );

    margin-bottom: $measure * 8;
}

Headings

One entry per level in $headings-options; levels you leave out keep the browser default.

$headings-options h2h3h4h5h6

The map has one key per level, "h1" to "h6", and each takes the font keys. In Sanity the rule targets the direct children of the wrapper (& > h2), so a heading nested in another block is left alone. Here h2 is the page title size (f--font-a) with a tall padding-top to separate the sections of an article, h3 is f--font-d, and h4 to h6 stay on the paragraph scale and differ by weight. h1 is not set: a content block never holds the page title.

$headings-options: (
    "h2": (
        "className": f--font-a f--color-a,
        "margin-bottom": $measure * 4,
        "padding-top": $measure * 13,
    ),
    "h3": (
        "className": f--font-d f--color-a,
        "margin-bottom": $measure * 2,
        "padding-top": $measure * 5,
    ),
    "h4": (
        "className": f--font-e f--color-a,
        "font-weight": 700,
        "margin-bottom": $measure,
        "padding-top": $measure * 3,
    ),
    "h5": (
        "className": f--font-e f--color-a,
        "font-weight": 600,
        "margin-bottom": $measure,
        "padding-top": $measure * 2,
    ),
    "h6": (
        "className": f--font-f f--color-a,
        "font-weight": 600,
        "letter-spacing": 0.04em,
        "margin-bottom": $measure,
        "padding-top": $measure * 2,
    ),
),

The live examples on this page use the Sanity test content that ships with the package, lorem ipsum included: it is the fixture the mixins are tested against, so every element the package knows is covered.

<h2>Sanity content test</h2>
<h3>Headings</h3>
<h4>heading h4</h4>
<h5>heading h5</h5>
<h6>heading h6</h6>
Live example
h2h3h4h5h6

Sanity content test

Headings

heading h4

heading h5
heading h6

Paragraphs

$paragraphs-options for the text, $link-options for the links inside it. Bold and italic come for free.

$paragraphs-options classNamemargin-bottombold-font-weight$link-options

$paragraphs-options is one font map applied to the direct p children of the wrapper. "className" sets the type and colour, "margin-bottom" the rhythm between paragraphs, and "bold-font-weight" the weight of strong and b inside them (the test content has a bold run and an italic-and-bold one). em is italic from make-content(); nothing to set.

$paragraphs-options: (
    "className": f--font-e f--color-a,
    "margin-bottom": $measure * 2,
    "bold-font-weight": 500,
),

Links

$link-options has a single key, "className", applied to every a without a class. In a project with a link component you pass it here (c--link-a) and import its file at the top; this framework has none, so the colour comes from a foundation class and the underline from $custom-options further down. Sanity wraps the link in a span, which the selector does not mind.

$link-options: (
    "className": f--color-e,
),
<p>
  ipsum dolor sit amet const
  <span><a href="#" rel="nofollow noopener">this is a link</a></span>
  const lorem ipsum dolor <strong>this is bold</strong> dolor sit amet const lorem
  ipsum dolor sit amet const lorem ipsum <em>this is italic</em> lorem ipsum dolor sit
  amet const lorem ipsum dolor sit amet const lorem ipsum
  <em><strong>this is italic and bold</strong></em>
</p>
<p>
  ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit
  amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem
  ipsum dolor sit amet const lorem ipsum dolor sit amet const
</p>
Live example
classNamemargin-bottombold-font-weight$link-options

ipsum dolor sit amet const this is a link const lorem ipsum dolor this is bold dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum this is italic lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum this is italic and bold

ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const

Lists

Bullets and counters drawn by the package, sized and coloured by $lists-options.

$lists-options first-level-artwork-*first-number-widthnumber-fontfont-stylesnested-list-spacing

make-content() removes the native markers of ul and ol and draws its own with ::before: a shape for the bullets, a CSS counter for the numbers that reads 1., then 1.1. on the second level. $lists-options shapes them. The first-level-artwork-* keys are the bullet: its size, offset from the top, background, radius and border. The number has "first-number-width", the room reserved for it (always set it, in rem, or the numbers overlap the text), "first-level-number-color" and "number-font", a font map. The same keys exist with second-level- and third-level- prefixes for nested lists; left out, the first level cascades down.

The text of the items is "font-styles", a font map, and the spacing is "margin-bottom" under the whole list and "nested-list-spacing" above a nested one. The test content nests a list directly inside its parent list, as the fixture is written; the selectors cope with it.

$lists-options: (
    "first-level-artwork-width": 6px,
    "first-level-artwork-top": 10px,
    "first-level-artwork-background": map.get($color-options, a),
    "first-level-artwork-border-radius": 50%,
    "first-level-artwork-border-width": 0,
    "first-level-artwork-border-color": map.get($color-options, a),
    "first-number-width": 1rem,
    "first-level-number-color": map.get($color-options, a),
    "number-font": (
        "className": f--font-e,
    ),
    "margin-bottom": $measure * 3,
    "nested-list-spacing": $measure,
    "font-styles": (
        "className": f--font-e f--color-a,
        "margin-bottom": $measure,
    ),
),
<h3>ul lists</h3>
<ul>
  <li>lorem ipsum dolor sit amet const</li>
  <ul>
    <li>lorem ipsum dolor sit amet const</li>
    <li>lorem ipsum dolor sit amet const</li>
    <ul>
      <li>lorem ipsum dolor sit amet const</li>
    </ul>
  </ul>
  <li>lorem ipsum dolor sit amet const</li>
</ul>
<h3>ol lists</h3>
<ol>
  <li>lorem ipsum dolor sit amet const</li>
  <ol>
    <li>lorem ipsum dolor sit amet const</li>
    <li>lorem ipsum dolor sit amet const</li>
  </ol>
  <li>lorem ipsum dolor sit amet const</li>
  <li>lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const</li>
  <ol>
    <li>lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const</li>
    <ol>
      <li>lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const</li>
    </ol>
  </ol>
</ol>
Live example
first-level-artwork-*first-number-widthnumber-fontfont-stylesnested-list-spacing

ul lists

  • lorem ipsum dolor sit amet const
    • lorem ipsum dolor sit amet const
    • lorem ipsum dolor sit amet const
      • lorem ipsum dolor sit amet const
  • lorem ipsum dolor sit amet const

ol lists

  1. lorem ipsum dolor sit amet const
    1. lorem ipsum dolor sit amet const
    2. lorem ipsum dolor sit amet const
  2. lorem ipsum dolor sit amet const
  3. lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const
    1. lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const
      1. lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const

Blockquote

The box from $quote-options, the text from $custom-options: a Sanity quote has no paragraph inside.

$quote-options paddingmargin-topmargin-bottomborder$custom-options

$quote-options styles the blockquote element: "padding", "margin-top", "margin-bottom" and "border", a map with border-color, border-style, border-width and border-radius. The width is a shorthand, so 0 0 0 3px is a single rule on the left. Set the three spacing keys: when the map is not empty, the package fills in a default for any of them you leave out.

The map also has a "paragraph" key, but it targets blockquote p and a Sanity quote is raw text inside the element, so it would match nothing. The type of the quote is set in $custom-options instead, where "blockquote" gets a "class" list (f--font-d, f--color-a) and an italic style. "cite" is WordPress-only and throws in Sanity.

$quote-options: (
    "padding": $measure * 2 $measure * 3,
    "margin-top": $measure * 3,
    "margin-bottom": $measure * 4,
    "border": (
        "border-color": map.get($color-options, e),
        "border-style": solid,
        "border-width": 0 0 0 3px,
    ),
),
$custom-options: (
    "blockquote": (
        "class": (f--font-d, f--color-a),
        "font-style": italic,
    ),
    "a:not([class])": (
        "text-decoration": underline,
        "text-underline-offset": 0.15em,
    ),
),
<blockquote>
  ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit
  amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem
  ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit
  amet const lorem ipsum dolor sit amet const
</blockquote>
Live example
paddingmargin-topmargin-bottomborder$custom-options
ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const lorem ipsum dolor sit amet const

Figure

An image with its caption. $image-options styles the img and the figcaption; the size comes from a class on the figure.

$image-options border-radiusmargin-bottomcaptionc--content-media--small

Sanity serialises an image as a figure with an img and an optional figcaption. make-content() makes the figure a centred block as wide as its image, and reads two size classes the editor can set: c--content-media--small (25% of the column) and c--content-media--medium (50%). Without one, the image takes the full width.

$image-options has the media keys (border-radius, object-fit, max-height, max-width, the border-* keys…) which go on the img, "margin-bottom", which goes on the figure, and "caption", a font map for the figcaption. Here the image gets a small radius, the figure the same margin as a list, and the caption the smallest font, centred under the image.

$image-options: (
    "border-radius": $measure,
    "margin-bottom": $measure * 4,
    "caption": (
        "className": f--font-f f--color-a,
        "padding-top": $measure,
        "text-align": center,
    ),
),
<h4>image size: small</h4>
<figure class="c--content-media--small">
  <img alt="Image" src="https://html.com/wp-content/uploads/flamingo-fallback.jpg" />
  <figcaption>caption</figcaption>
</figure>
<h4>image size: big</h4>
<figure>
  <img alt="Image" src="https://html.com/wp-content/uploads/flamingo-fallback.jpg" />
  <figcaption>caption</figcaption>
</figure>
Live example
border-radiusmargin-bottomcaptionc--content-media--small

image size: small

Image
caption

image size: big

Image
caption

Options reference

Every map make-content-modifier() takes in the Sanity flavour, and the ones that only exist for WordPress.

Reference

Option maps

MapStylesKeysIn this project
$headings-optionsDirect h1 to h6One font map per level, "h1" to "h6"h2 to h6
$paragraphs-optionsDirect pA font mapclassName, margin-bottom, bold-font-weight
$link-optionsa without a class"className"f--color-e
$lists-optionsul, ol and their lifirst-, second-, third-level-artwork-* (width, top, image, background, border-radius, border-width, border-color), *-level-number-color, first-number-width, number-font, font-styles, margin-bottom, nested-list-spacingFirst level, number-font, font-styles, spacing
$image-optionsfigure, its img and figcaptionMedia keys on the image (border-*, object-fit, object-position, min-/max-width, min-/max-height), margin-bottom, caption (font map)border-radius, margin-bottom, caption
$quote-optionsblockquotepadding, margin-top, margin-bottom, border (map), paragraph (font map, matches nothing in Sanity)Spacing and border
$custom-optionsAny selector inside the wrapperA map of CSS properties per selector; "class" is a list of classes to extendblockquote, a:not([class])

Font map keys

Wherever a map styles text (a heading level, $paragraphs-options, number-font, font-styles, caption) these are the keys it accepts:

KeyWhat it does
classNameFoundation classes to extend, space separated
color, font-family, font-size, font-style, font-weight, letter-spacing, line-height, text-alignWritten as-is on the element
text-decoration, text-decoration-color, text-underline-positionWritten as-is on the element
margin-bottom, padding-topSpacing above and below
margin-bottom-before-listsThe bottom margin when the next sibling is a ul or ol
bold-font-weightWeight of strong and b inside the element
max-linesClamps the text to that many lines with an ellipsis

WordPress only

These maps are accepted by make-content-modifier() but throw Sanity doesn't support … styles at compile time with the Sanity import, because Portable Text has no such blocks: $btn-options, $columns-options, $footnote-options, $highlighted-options, $separator-options, $video-options, $table-options and $utilities-options. The "cite" key of $quote-options throws too. Switch the import to @terrahq/wysiwyg/wordpress and they all become available.