Skip to content

Repository files navigation

shortcodes.js npm version

Shortcodes.js is a completely front-end solution that uses sequences of DOM elements for templating dynamic and beautiful layouts while preserving SEO where backend development is limited, e.g. Shopify.

Being completely frontend solution means there will be some CLS, or the page will flicker on initial load, but it's a small price to pay for the flexibility and ease of use.

DEMO HERE ⤻

How it works?

Shortcodes.js works by interpreting sequences of different text formatting, set in WYSIWYG editor, under the shortcode tag, predefined for each tag via provided binding descriptor and it's respective HTML template.

For example, a simple marquee shortcode will interpret an H1 (Header size 1) as a title of the marque and an image inserted into the text as a background image.

Binding Descriptor

The binding descriptor is a set of instructions on what sequence to expect and how to perform the binding of the HTML template. It is the second parameter of the shortcode.register() function, being that the first parameter is the name of the shortcode.

Basic setup

Load the script, make a Shortcodes object, register each shortcode with its template and descriptor, then run it on the container that holds the editor's output.

<scriptsrc="https://unpkg.com/shortcodes.js"></script><script>varshortcodes=newShortcodes()</script>

main is a browser build that sets window.Shortcodes. Under a bundler import { Shortcodes } from 'shortcodes.js' gets the ES module source. The package is "type": "module", so require() throws ERR_REQUIRE_ESM. book-of-spells is the only dependency.

The template sits in the page hidden, marked with the template class, and is cloned per use. The landing is where the result goes:

<sectionclass="hero template" hidden><h1class="title-landing"></h1><divclass="text-landing"></div></section><divclass="shortcode-landing"></div>

The descriptor says what to take from the text and where to put it — here the first image becomes the background, the first heading the title, everything else the text:

shortcodes.register('hero',{template: 'section.hero',anchor: '.shortcode-landing',bind_fn: 'append',elements: {img: {extract: 'src',bind_extracted: {style_property: 'background-image',extracted_property: 'src'},anchor_element: 'template'},h1: {extract: 'html',bind_extracted: 'html',anchor: '.title-landing',anchor_element: 'template'},rest: {extract: 'self',bind_extracted: 'append',anchor: '.text-landing',anchor_element: 'template'},},})shortcodes.execute(document.querySelector('.rte'))

What the end user types in the WYSIWYG editor:

<p>[hero]</p><p><imgsrc="sunrise.jpg"></p><h1>Morning</h1><p>First paragraph.</p><p>Second paragraph.</p><p>[/hero]</p>

section.hero lands in .shortcode-landing with the image as its background, "Morning" as the title and both paragraphs as the text. The closing [/hero] is for when the text goes on after the hero — without it, everything up to the next tag belongs to the hero.

What the end user types

The rules for the text. The end user never sees a descriptor.

  • A tag is a paragraph that says nothing else.[hero], [hero placement="top" wide]. A sentence that mentions [hero] is a sentence; two tags in one paragraph are a sentence too.
  • Only registered names count.[shortcodes.js] as a heading stays a heading unless something is registered as shortcodes.
  • Everything after the tag belongs to it, up to the next tag or its closing tag [/hero]. Text before the first tag is left alone.
  • A closing tag that closes nothing stays on the page as [/hero], so the mistake is visible where it was made.
  • Empty paragraphs are skipped. An <img> or an <hr> is never empty.
  • A paragraph holding only images is each of those images.<p><img><img></p> is two.
  • A paragraph is unwrapped only when the element is all it holds.<p><a>Buy</a></p> is the link; <p>Read the <a>docs</a> first</p> is a paragraph and goes to rest — unwrapping it would drop the sentence. A line break beside the element does not count.
  • Keys are tried in the descriptor's order.<p><a><img></a></p> is the image under a descriptor that lists img before a, the link under one that lists a first.
  • A list keeps its images.<ul><li><img></li></ul> is a list.
  • Code is never a tag. A <pre>, or a paragraph containing one, is not read for tags.
  • In a repeater, {red} on a line of its own starts the next item and gives it the class red; {red big} gives it two. A repeated key starts the next item as well — a second image is a second slide.

Attributes

On the tag: [hero placement="top" color="white" wide].

AttributeDoes
placement="top"renders into .shortcode-landing-top; the prefix is the placement_class_prefix option
placement="content"renders in place, where the tag was typed. Wants a [/hero] when text follows
body-class="x"adds the class to <body>
header-class="x"adds the class to the descriptor's header_selector, else <header>, else <body>
background-color, background-image, colorinline style on the rendered template; background-image is quoted and escaped on its way into url()
anything else, with or without a valuea class on the rendered template, by name: widewide, highlighted="yes"highlighted

Values arrive typed — "3" is 3, "false" is false, entities decoded. A descriptor's attribute_parsers adds parsers by attribute name, or replaces the built-in ones.

Descriptor keys

KeyDoes
templateselector of the template to clone, found together with the template_class: section.hero.template. None matching throws, naming the selector
anchorwhere the rendered template goes: a selector, or 'self' for where the tag stood. placement on the tag overrides it
bind_fnhow it goes there: append, prepend, html, text, style — copies the bind_property styles from the template onto the anchor — or function(templates, anchors, bind_property, shortcode_obj)
bind_propertythe style properties bind_fn: 'style' copies
item_template, item_anchora repeater: item_template is cloned once per item and bound into item_anchor inside the template
elementswhat to take from the text, by selector — below
pre(shortcode_obj)runs before templating, attributes already applied
callback(template, shortcode_obj)runs after; template is the rendered clone, or the anchor element when there is no template
attribute_parsers{ name: function(shortcode_obj, value) }, by attribute name
header_selectorwhere header-class lands
detachElementsremove the collected elements from the source; overrides the option of the same name

elements

Each key is a selector — img, h1, 'h2,h3,h4,h5,h6', .pull-quote — tried against the text's elements in the order written. rest takes what matched none.

KeyDoes
counthow many the key takes: a number, 'many', 1 when unset; rest takes all. Past the count an element is left in the source, uncollected, and the console says which
extractwhat to read from the element: html, text, self (the element itself), an attribute name, { name, extract_fn(element) }, { name: 'style', properties: [...] } — or an array of these. Nothing to extract, nothing bound
parse_extracted(extracted, element, dest, props, shortcode_obj, num)edits the extract before it is bound; a string names a global function
bind_extractedwhere each extract goes: html, text, append or prepend of self, an attribute name — set from the extract of that name, skipped when the source had none — { style_property, extracted_property }, or function(destination, extracted, props, shortcode_obj, num). Or an array of these
anchorselector of the landing inside the template, or the template itself when it matches. Unset, the template
anchor_elementwhen present, the landing is searched inside the template — the item template in a repeater. Without it, inside the page anchor, for binding into something already on the page. The value is not read

In a repeater every key takes one element per item. count still caps how many are collected in total, so leave it 'many' there.

Options

new Shortcodes({ ... }):

OptionDefaultDoes
template_class'template'marks templates; the clone loses it, and hidden and aria-hidden with it
self_anchor_class'self-anchor'class of the <div> left where a tag stood — the landing for placement="content"
placement_class_prefix'shortcode-landing'placement="top".shortcode-landing-top
detachElementsfalseremove the collected elements from the source. false leaves them, and extract: 'self' moves them anyway
shopify_full_sizetruestrips the _600x600 size Shopify's editor puts on an image src, so the template gets the original. Touches *.shopify.* URLs only

API

CallDoes
register(name, descriptor)[name] renders by the descriptor
execute(container, callback)renders every tag under container, the element holding the editor's output; the container is hidden while it runs. callback(shortcodes_by_uid, exec_fns) after
reinitialize(container, callback)clear(), then execute()
clear()removes every rendered template (.shortcode-js) and every self anchor of the configured class from the document
getTemplate(selector)a clone of the template, the way the engine takes one
shopifyImageLink(src, width)the same Shopify image src at width

execute consumes the tags: each tag paragraph is removed and a <div class="self-anchor shortcode-hero hero-sc-0"> left in its place. Run it once per container, or on a fresh copy each time — the demo re-renders from a cloneNode(true) of the editor. Afterwards the source still holds what was copied out of it (html, text, an attribute) and not what was moved (self); hide the container, or set detachElements.

Errors and warnings

  • The console says what went missing: an element past its key's count, and where it was left; a placement or anchor nothing on the page matches; an element anchor or an item_anchor the template lacks.
  • A descriptor is checked when registered. A key that is not one, or one from the old per-element api (extract_fn, bind_fn), extract: 'attr', a count that is not a number or 'many', a repeater with no item_anchor — one console line each, with the path and the fix. The descriptor is used as given.
  • A missing template throwsshortcodes.js: no template matches "section.hero.template" — and the container is shown again before the throw leaves execute.
  • No script, no layout. The text stays as typed, tag lines included.

💡 How about optional caching in local storage? We can cache the whole DOM, and then just trigger the callback.

Sample descriptor

Every descriptor key in one place, with what each one does. The sections above have the details. NOTE: Descriptor nomenclature might change with every major release.

varexample_descriptor={template: "section.carousel",anchor: ".carousel-landing",// or "self" if you use a closing tag for shortcode this will inject the shortcode at the place it was founditem_template: ".slide.section-carousel",item_anchor: ".slides-landing",pre: function(shortcode_obj){/*...*/},//callback before templatingattribute_parsers: {custom: function(shortcode_obj,value){}/*...*/},//parsers by attribute name, overriding the built-in ones (placement, body-class, header-class, background-color, background-image, color). This example runs for custom="..."callback: function(template,shortcode_obj){/*...*/},//callback after templating is complete, with arguments sufficient for doing some manual additional programmingbind_fn: "append",// html, text, append, prepend, style, or a function (source, destination, property_name, shortcode_obj, num)elements: {img: {count: "many",bind_extracted: function(destination,extracted,properties,shortcode_obj,num,){// console.log('whoa');},extract: ["src","alt"],//attribute names, a string or an arrayanchor: ".image-landing",anchor_element: "item",},"h1, h2, h3, h4": {count: "many",// count = 'many' or numbind_extracted: "html",// html, text, append, prepend, a { style_property, extracted_property } object, or a function (destination, extracted, props, shortcode_obj, num)extract: "html",// html, text, self, an attribute name, or { name, extract_fn }anchor: ".title-landing",// where on element should it landanchor_element: "item",// when present, bind inside the template (the item template in a repeater); without it, inside the page anchor},rest: {//rest items are usedextract: "self",bind_extracted: "append",anchor: ".rest-landing",anchor_element: "item",},},};shortcodes.register("example",example_descriptor);//will register [example] shortcode based on provided descriptor, which contains all the information required for parsing DOM and templates with the instructions for templating.

A bit of history

  • 2016: Started on a Shopify site where the style was demanding and I didn't want to update the site by hand for the client every few weeks. Shopify had no way to let them do it.
  • Later that year Shopify shipped sections — homepage only. Custom PDP layouts and banners between paragraphs still needed a developer, so shortcodes.js went from project to project as a single file, growing as each site needed.
  • 2021: Online Store 2.0, sections on every page. 2023: metaobjects. Per-product content became native — in slots a developer defines. The writer still cannot put a third image where the theme gave two, and a different layout per product means an alternate template each, assigned by hand, 1,000 per theme.
  • 2023: published to npm, on the last Shopify project I took.
  • 2026: back on that site three years later. Everything native fills fixed slots; a writer composing components inside their own text, in any number and order, still has nothing else. For that reason this old code still deserves a bit of love.

Releases

Used by

Contributors

Languages