Follow

Gatsby theme for kickstartDS

kickstartDS components as a Gatsby theme

Gatsby was a common target for content-driven sites, so the design system needed a first-class path into it.

This theme wraps kickstartDS components for Gatsby: a theme package plus an example site, so a project can pull the design system in and start composing pages.


FactDetail
TypeGatsby theme + example workspace
StackJavaScript, Gatsby, React
StartedFebruary 2021

What's in it

Theme + example

The example site is the documentation: how to wire the theme into a project.

One of several targets

Part of making the same design system available wherever content lives — Gatsby, Next.js or plain HTML.

In detail

What is actually inside it

MDX content, in two languages

The example ships example/content/ with MDX pages including de-datenschutz.md and de/settings/* — which is how a theme gets tested against real content shapes instead of fixtures.

Releases were automated

.autorc (auto) plus CircleCI: changelog and release management came from commit conventions, matching how the CLI and schema tooling were run.

DetailValue
TypeGatsby theme + example site
InterfaceGraphQL (`.graphqlrc.yml`)
StartedFebruary 2021

Technical notes

The decisions behind it

Why a Gatsby theme at all?
Because a design system only travels if it fits where content lives. Gatsby was a common target for content-driven sites, so the design system needed a first-class path in — a theme, not a copy-paste recipe.
What is the theme's real contract?
The GraphQL layer: what data the components expect, and how a site supplies it. That is what a theme has to get right; the rendering follows.

The generated GraphQL layer

The theme's real interface is a schema it does not ship

Nothing here hand-writes the GraphQL types its components receive. They are generated from the design system's JSON Schemas and consumed as a build input:

  • @kickstartds/generate-from-schema emits dist/page.graphql and dist/config.yml;
  • GraphQL Codegen, configured in .graphqlrc.yml, reprocesses that schema back into dist/page.graphql (adding the Gatsby directives @dontInfer, @link, @dateformat) and emits dist/page.fragments.js through @kickstartds/graphql-cli-generate-fragments;
  • the root yarn generate script then copies both into example/dist/, and the generated CMS config into example/static/admin/config.yml.

The theme reads ${gqlPath}/page.graphql — default dist — inside createSchemaCustomization and hands the text straight to createTypes(). No published package ships that file, so a site that skips the generator fails on a missing schema before it has read a single content file. (netlifyConfigPath is accepted as an option and defaulted to dist but never read; the copy lives in the root script instead.)

Keys arrive hashed. The JSON-Schema→GraphQL step appends a suffix to avoid collisions, so nodes look like activeEntry__254f, sections__17ac or headline__b113. componentMapper.tsx passes those names through to components and calls cleanObjectKeys from @kickstartds/jsonschema2graphql/build/dehashing to strip them again — the theme is coupled to the tool's hashing and its de-hashing, both reached through an undeclared build/ subpath. Two unions cannot be generated: SectionComponentContent and TextMediaComponentMedia are assembled by hand in gatsby-node.js, both with resolveType: (value) => pascalCase(value.type) + 'Component' and the same // TODO generalize this.

Because a section's content is another component that may itself contain sections, queries cannot be hand-written either. collectGraphQLFragments.js imports Gatsby's private parser (gatsby/dist/query/file-parser) and walks FragmentSpread nodes recursively to inline the transitive closure of named fragments into a query string. createPages.js names sixteen component fragments explicitly just to fill allKickstartDsPage, next to a // TODO dedupe the components fragments below, this should not be necessary!. Five more page queries are regenerated on disk at build time: gatsby-node.js reads its own src/pages/{kickstartDs*Page.slug}.js collection-route templates, regex-replaces everything from export const query onward, and writes the files back.

ArtifactProduced byConsumed at
dist/page.graphql@kickstartds/generate-from-schema, then Codegen injects the Gatsby directivescreateSchemaCustomization → createTypes()
dist/page.fragments.jsGraphQL Codegen (@kickstartds/graphql-cli-generate-fragments)collectGraphQLFragments()
dist/config.ymlgenerate-from-schemacopied to static/admin/config.yml (Netlify CMS)
src/pages/{kickstartDs*Page.slug}.jsrewritten in place by createPages()Gatsby page queries
src/components/ComponentsMap.tspostinstall (scripts/createComponentsMap.js)componentMapper.tsx

One interface, four sources

Where page nodes actually come from

The contentPath directory is plain gatsby-source-filesystem (source name urlSegment). Everything above it is one transformer package per CMS, each minting the shapes the theme queries:

  • Markdown + Netlify CMS — a MarkdownRemark whose frontmatter has an id becomes a KickstartDsContentPage; files under a settings/ folder named *header.md / *footer.md become single KickstartDsHeader / KickstartDsFooter nodes instead. Language variants are just more files, so content/de/settings/header.md sits beside content/settings/header.md.
  • MDX — Mdx nodes become KickstartDsMdxBlogPage, with postHead, postBody, postAside, postReadingTime and postWordCount derived from frontmatter.
  • WordPress — WpPost becomes KickstartDsWordpressBlogPage with a blog/-prefixed slug, layout: 'blog-detail', and reading time at 140 words per minute.
  • Contentful — ContentfulTerm, ContentfulAppearance, ContentfulShowcase and ContentfulTag become the glossary, appearance, showcase and tag pages.

Seven page types implement one KickstartDsPage interface (@dontInfer, implements Node), alongside the two singleton KickstartDsHeader / KickstartDsFooter nodes that do not. That interface is what lets a single allKickstartDsPage query drive generic page creation. The blog is the outlier: three separate implementations — MDX, WordPress and Contentful — satisfy the same KickstartDsBlogPage interface, so a site can swap sources without touching the list or detail templates.

The transformers are not thin adapters. The WordPress author resolver looks up a WpUser, then finds a ContentfulPerson with the same name, logging 'Missing ContentfulPerson for author' when that fails. The Contentful plugin invokes a Supabase edge function (functions.invoke('description', ...) with GATSBY_SUPABASE_URL / GATSBY_SUPABASE_ANON_KEY) to write tag descriptions during the build, and derives glossary source thumbnails from a public Supabase storage bucket. Missing assets are almost always a console.log plus return undefined rather than a thrown error, so a build can complete with holes and only surface them later as a GraphQLDocumentError.

Node typeCreated fromlayout
KickstartDsContentPageMarkdownRemark with a frontmatter iddefault
KickstartDsHeader / KickstartDsFootersettings/*header.md, settings/*footer.md—
KickstartDsMdxBlogPageMdxblog-detail
KickstartDsWordpressBlogPageWpPostblog-detail
KickstartDsGlossaryPageContentfulTermglossary
KickstartDsAppearancePageContentfulAppearanceappearance
KickstartDsShowcasePageContentfulShowcaseshowcase
KickstartDsTagPageContentfulTagtag-label

The example is the documentation

And the only place the shadowing contract is exercised

Both READMEs are the wrong ones. The root README still describes the workspace as a Gatsby starter (gatsby-theme-kickstartDS/gatsby-config.js: 'An empty gatsby-config that you can use as a starting point'); the theme's own README is the unmodified 'The smallest possible Gatsby theme' scaffold text; and example/README.md is the WordPress Twenty Twenty starter readme, complete with WPGraphQL and menu-slug setup steps the example never uses. The working documentation is the example site.

That makes example/ load-bearing: it is a real site, not fixtures, and it is the only place the theme's component-shadowing contract is exercised. Five files under example/src/@kickstartds/gatsby-theme-kickstartds/components/ shadow theme internals — Layout, Providers, ScrollSpy, BlogDetailPage and BlogListPage. Two are instructive:

  • the shadowed Layout adds <IconSprite/>, <Header/> and <Footer/>, because the theme's own Layout renders only <main tabIndex={-1}> plus a provider stack;
  • the shadowed Providers wraps the theme's providers in ButtonProvider, HeadlineProvider, CountUpProvider, VisualProvider, StorytellingProvider and SectionProvider from @kickstartds/design-system, and Layout imports that package's dist/index.css and dist/index.js.

So the theme deliberately contains neither the design system's runtime nor its CSS: the site adds @kickstartds/design-system itself, and the root yarn bundle script copies node_modules/@kickstartds/design-system/dist/* into example/static/. The example enables three of the four transformers — MDX is present but commented out — yet the theme's Layout imports the MDX transformer's src/shiki-twoslash.css anyway, a stylesheet that resolves only because the monorepo hoists the sibling workspace. The example's own content is the test bed: content/de-datenschutz.md sits outside the de/ directory while claiming slug: de/datenschutz.

The component registry is generated too

postinstall runs scripts/createComponentsMap.js: it walks @kickstartds/{base,blog,content}'s exports.json, union-diffs any other package that ships a dist/exports.json (found by globbing node_modules), and writes a gitignored ComponentsMap.ts full of loadable(() => import(...)) entries. It hardcodes the CI checkout path /mnt/ramdisk/ and otherwise guesses the relative depth. There is no committed registry to read.

Patched dependencies and a virtual FS

patches/gatsby-remark-shiki-twoslash+3.0.34.patch and patches/photoswipe+5.3.7.patch are applied by patch-package on postinstall, while root resolutions pin @kickstartds/base|blog|core|form to 2.0.4-next.* and @kickstartds/content to a canary. The MDX transformer feeds Twoslash a virtual filesystem containing @types/react/index.d.ts and GlossaryComponent.d.ts, so fenced code in blog posts is type-checked at build time (theme: 'material-default').

Still-warm code paths

componentAnalytics.js writes per-component usage points to InfluxDB from GATSBY_INFLUX_*, but both call sites are commented out behind // TODO re-activate: this is the entry point for usage based component metrics. The MDX postBio resolver hardcodes two team bios with a // TODO make dynamic again, and computes postReadingTime from getMarkdownExcerpt(node.rawBody) — the first 300 characters only.

Content that tests the edge cases

content/blog/demo.mdx carries an Internal category that createBlogList.js filters out of the listing, described in-code as 'currently used to have posts generated, but not on the list'. The German footer is detected by whether sections__17ac[1].headline__b113 contains 'Kontakt' — the content shape is the configuration.

Sharp edges

What the strings, the writes and the packaging actually cost

Why does a page whose slug mentions 'blog' disappear?
createPages.js skips any page whose slug includes blog, glossary, showcases or appearances — substring checks, not prefixes — because the three list creators and the {Type.slug}.js collection routes own those paths, so a content page like coding/weblog-notes would be dropped silently. Language selection is string-matching too: a page is German when slug.includes('de/') || slug === 'de' || slug.includes('ueber-uns') (which also matches any slug containing de/), the header is chosen by activeEntry__254f.includes('de'), and the footer by the 'Kontakt' probe above. Because the field names come from the generated schema, changing a content shape means changing these probes.
What breaks if I copy the components in instead of using the theme?
You inherit the components and none of the machinery. Gone: the generated page.graphql and fragments plus the Codegen step that produces them; collectGraphQLFragments and its recursive fragment inlining; the cleanObjectKeys de-hashing (props would arrive as activeEntry__254f); the ComponentsMap.ts generated at postinstall with its @loadable/component SSR plugin; the resolveType bridges for SectionComponentContent and TextMediaComponentMedia; the remark and Sharp pipeline from the theme's gatsby-config (quality 50, breakpoints 750/1080/1366/1920, webp + auto, blurred placeholders, gatsby-remark-relative-source htmlSources for <post-video image>); and the four transformer packages that turn Markdown, Mdx, WpPost and Contentful nodes into page nodes. The parts you would have to rebuild are exactly the parts that are not components.
How were releases run — and are the packages actually public?
auto-it (.autorc: the npm and released plugins, baseBranch: master, versionBranches: support/) over Lerna in independent mode. CircleCI's release job uses cimg/node:18.12, works in /mnt/ramdisk on an xlarge runner with caches keyed on yarn.lock plus the config file, writes NPM_TOKEN into a ramdisk .npmrc, runs yarn install --frozen-lockfile, removes the .npmrc, then runs yarn auto shipit with the token coming from the kickstartDS context. The changelog is dominated by 'Pushed to master' entries carrying the raw commit subject, and bumps stayed in patch range (theme 2.5.6 → 2.5.9). Despite the root README describing installation from npm, every package sets license: UNLICENSED and publishConfig.access: restricted; the Contentful space id 6g9liq51xuol is hardcoded in the transformer's gatsby-config.js, with only the token coming from the environment.
Why does the build write into its own package?
The five src/pages/{kickstartDs*Page.slug}.js files ship with a placeholder export const query, and createPages rewrites them in place before creating pages. In the monorepo that mutates the working tree; installed from npm it writes inside node_modules, which fights immutable installs (pnpm, Yarn PnP) and caches keyed on node_modules — the same thing CircleCI restores. The schema it depends on is also supplied from outside the package: yarn generate copies page.graphql, page.fragments.js and the CMS config into the site.
What must the site supply that the theme does not?
The design system runtime, the providers and the per-language chrome. The theme's Providers is a bare wrapper; the example shadows it to add six design-system providers, imports dist/index.css / dist/index.js, and mounts IconSprite, <Header/> and <Footer/> in the shadowed Layout. example/gatsby-config.js then adds everything else the theme does not: siteMetadata (including siteUrl, which the imageUrl resolvers need to build absolute URLs), trailingSlash: 'always', gatsby-plugin-advanced-sitemap with a hand-written query and per-type mapping, and three of the four transformers. The theme sets the options it does take — contentPath, urlSegment, gqlPath — to the values the example passes back verbatim.