0.61.0 • Published 4 days ago

@valbuild/next v0.61.0

Weekly downloads
-
License
-
Repository
-
Last release
4 days ago

🐉 HERE BE DRAGONS 🐉

Val is PRE-ALPHA - MOST features are broken and in state of flux.

This is release is only for INTERNAL TESTING PURPOSES.

Table of contents

Introduction

Val is a CMS where content is code stored in your git repo.

As a CMS, Val is useful because:

  • editors can change content without having to ask developers to do it for them (and nobody wants that)
  • a well-documented way to structure content
  • image support is built-in
  • richtext support is built-in
  • built-in visual editing which lets editors click-then-edit content (and therefore code!) directly in your app

    Visual editing

But, with the benefits of hard-coded content:

  • works seamlessly locally or with git branches
  • content is type-checked so you can spend less time on figuring out why something isn't working Type check error
  • content can be refactored (change names, etc) just as if it was hard-coded (because it is) Renaming
  • works as normal with your favorite IDE without any plugins: search for content, references to usages, ... References
  • no need for code-gen and extra build steps
  • fast since the content is literally hosted with the application
  • content is always there and can never fail (since it is not loaded from somewhere)
  • no need to manage different environments containing different versions of content
  • resolve conflicts like you normally resolve conflicts: in git

Compared to other CMSs, Val has the following advantages:

  • easy to setup and to grok: Val is designed to have a minimum of boilerplate and there's 0 query languages to learn. If you know your way around JSON that's enough (if you don't you might want to learn it)
  • no signup required to use it locally
  • no fees for content that is in your code: your content is your code, and your code is... yours
  • minimal API surface: Val is designed to not "infect" your code base
  • easy to remove: since your content is already in your code and Val is designed to have a minimal surface, it's easy to remove if you want to switch

However, checking in the 10 000th blog entry in git might feel wrong (though we would say it is ok).

Therefore, Val will add remote content support which enables you to seamlessly move content to the cloud and back again as desired. You code will still be the one truth, but the actual content will be hosted on val.build.

.remote() support will also make it possible to have remote images to avoid having to put them in your repository.

There will also be specific support for remote i18n, which will make it possible to split which languages are defined in code, and which are fetched from remote.

More details on .remote() will follow later.

When to NOT use Val

Val is designed to work well on a single web-app, and currently only supports Next 13.4+ (more meta-frameworks will supported) and GitHub (more Git providers will follow).

Unless your application fits these requirements, you should have a look elsewhere (at least for now).

In addition, if you have a "content model", i.e. content schemas, that rarely change and you plan on using them in a lot of different applications (web, mobile, etc), Val will most likely not be a great fit.

If that is the case, we recommend having a look at sanity instead (we have no affiliation, but if we didn't have Val we would use Sanity).

NOTE: Our experience is that, however nice it sounds, it is hard to "nail" the content model down. Usually content is derived from what you want to present, not vice-versa. In addition, you should think carefully whether you really want to present the exact same content on all these different surfaces.

Examples

Check out this README or the examples directory for examples.

Installation

  • Make sure you have TypeScript 5+, Next 13.4+ (other meta frameworks will come), React 18.20.+ (other frontend frameworks will come)
  • Install the packages (@valbuild/eslint-plugin is recommended but not required):
npm install @valbuild/core@latest @valbuild/next@latest @valbuild/eslint-plugin@latest
  • Run the init script:
npx @valbuild/init@latest

Add editor support

To make it possible to do non-local edits, head over to val.build, sign up and import your repository.

NOTE: your content is yours. No subscription (or similar) is required to host content from your repository.

If you do not need to edit content online (i.e. not locally), you do not need to sign up.

WHY: to update your code, we need to create a commit. This requires a server. We opted to create a service that does this easily, instead of having a self-hosted alternative, since time spent is money used. Also, the company behind val.build is the company that funds the development of this software.

Getting started

Create your first Val content file

Content in Val is always defined in .val.ts files.

NOTE: Val also works with .js files.

They must export a default content definition (c.define) where the first argument equals the path of the file relative to the val.config.ts file.

NOTE: val.ts files are evaluated by Val, therefore they have a specific set of requirements:

  • They must have a default export that is c.define, they must have a export const schema with the Schema; and
  • they CANNOT import anything other than val.config and @valbuild/core

Example of a .val.ts file

// ./src/app/content.val.ts

import { s, c } from "../../../val.config";

export const schema = s.object({
  title: s.string().optional(), //  <- NOTE: optional()
  sections: s.array(
    s.object({
      title: s.string(),
      text: s.richtext({
        bold: true, // <- Enables bold in richtext
      }),
    })
  ),
});

export default c.define(
  "/src/app/content", // <- NOTE: this must be the same path as the file
  schema,
  {
    title: "My Page",
    sections: [
      {
        title: "Section 1",
        text: c.richtext`
RichText is **awesome**`,
      },
    ],
  }
);

Use your content

In client components you can access your content with the useVal hook:

NOTE: Support for React Server Components and server side rendering will come soon.

// ./src/app/page.tsx
"use client";
import { NextPage } from "next";
import { useVal } from "./val/val.client";
import contentVal from "./content.val";

const Page: NextPage = () => {
  const { title, sections } = useVal(contentVal);
  return (
    <main>
      {title && (
        <section>
          <h1>{title}</h1>
        </section>
      )}
      {sections.map((section) => (
        <section>
          <h2>{section.title}</h2>
          <ValRichText
            theme={{
              bold: "font-bold",
            }}
          >
            {section.text}
          </ValRichText>
        </section>
      ))}
    </main>
  );
};

export default Page;

Schema types

String

import { s } from "./val.config";

s.string(); // <- Schema<string>

Number

import { s } from "./val.config";

s.number(); // <- Schema<number>

Boolean

import { s } from "./val.config";

s.boolean(); // <- Schema<boolean>

Optional

All schema types can be optional. An optional schema creates a union of the type and null.

import { s } from "./val.config";

s.string().optional(); // <- Schema<string | null>

Array

s.array(t.string()); // <- Schema<string[]>

Record

The type of s.record is Record.

It is similar to an array, in that editors can add and remove items in it, however it has a unique key which can be used as, for example, the slug or as a part of an route.

NOTE: records can also be used with keyOf.

s.record(t.number()); // <- Schema<Record<string, number>>

Object

s.object({
  myProperty: s.string(),
});

RichText

This means that content will be accessible and according to spec out of the box. The flip-side is that Val will not support RichText that includes elements that is not part of the html 5 standard.

This opinionated approach was chosen since rendering anything, makes it hard for developers to maintain and hard for editors to understand.

RichText Schema

s.richtext({
  // options
});

Initializing RichText content

To initialize some text content using a RichText schema, you can use follow the example below:

import { s, c } from "./val.config";

export const schema = s.richtext({
  // styling:
  bold: true, // enables bold
  //italic: true, // enables italic text
  //lineThrough: true, // enables line/strike-through
  // tags:
  //headings: ["h1", "h2", "h3", "h4", "h5", "h6"], // sets which headings are available
  //a: true, // enables links
  //img: true, // enables images
  //ul: true, // enables unordered lists
  //ol: true, // enables ordered lists
});

export default c.define(
  "/src/app/content",
  schema,
  c.richtext`
NOTE: this is markdown.

**Bold** text.
`
);

Rendering RichText

You can use the ValRichText component to render content.

"use client";
import { ValRichText } from "@valbuild/next";
import contentVal from "./content.val";
import { useVal } from "./val/val.client";

export default function Page() {
  const content = useVal(contentVal);
  return (
    <main>
      <ValRichText
        theme={{
          bold: "font-bold", // <- maps bold to a class. NOTE: tailwind classes are supported
          //
        }}
      >
        {content}
      </ValRichText>
    </main>
  );
}

ValRichText: theme property

To add classes to ValRichText you can use the theme property:

<ValRichText
  theme={{
    p: "font-sans",
    // etc
  }}
>
  {content}
</ValRichText>

NOTE: if a theme is defined, you must define a mapping for every tag that the you get. What tags you have is decided based on the options defined on the s.richtext() schema. For example: s.richtext({ headings: ["h1"]; bold: true; img: true}) forces you to map the class for at least: h1, bold and img:

<ValRichText
  theme={{
    h1: "text-4xl font-bold",
    bold: "font-bold",
    img: null, // either a string or null is required
  }}
>
  {content satisfies RichText<{ headings: ["h1"]; bold: true; img: true }>}
</ValRichText>

NOTE: the reason you must define themes for every tag that the RichText is that this will force you to revisit the themes that are used if the schema changes. The alternative would be to accept changes to the schema.

ValRichText: transform property

Vals RichText type maps RichText 1-to-1 with semantic HTML5.

If you want to customize the type of elements which are rendered, you can use the transform property.

<ValRichText
  transform={(node, _children, className) => {
    if (typeof node !== "string" && node.tag === "img") {
      return (
        <div className="my-wrapper-class">
          <img {...node} className={className} />
        </div>
      );
    }
    // if transform returns undefined the default render will be used
  }}
>
  {content}
</ValRichText>

The RichText type

The RichText type is actually an AST (abstract syntax tree) representing semantic HTML5 elements.

That means they look something like this:

type RichTextNode = {
  tag:
    | "img"
    | "a"
    | "ul"
    | "ol"
    | "h1"
    | "h2"
    | "h3"
    | "h4"
    | "h5"
    | "h6"
    | "br"
    | "p"
    | "li"
    | "span";
  classes: "bold" | "line-through" | "italic"; // all styling classes
  children: RichTextNode[] | undefined;
};

RichText: full custom

The RichText type maps 1-to-1 to HTML. That means it is straightforward to build your own implementation of a React component that renders RichText.

This example is a simplified version of the ValRichText component. You can use this as a template to create your own.

NOTE: before writing your own, make sure you check out the theme and transform properties on the ValRichText - most simpler cases should be covered by them.

export function ValRichText({
  children: root,
}: {
  children: RichText<MyRichTextOptions>;
}) {
  function build(
    node: RichTextNode<MyRichTextOptions>,
    key?: number
  ): JSX.Element | string {
    if (typeof node === "string") {
      return node;
    }
    // you can map the classes to something else here
    const className = node.classes.join(" ");
    const tag = node.tag; // one of: "img" | "a" | "ul" | "ol" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "br" | "p" | "li" | "span"

    // Example of rendering img with MyOwnImageComponent:
    if (tag === "img") {
      return <MyOwnImageComponent {...node} />;
    }
    return React.createElement(
      tag,
      {
        key,
        className,
      },
      "children" in node ? node.children.map(build) : null
    );
  }
  return <div {...val.attrs(root)}>{root.children.map(build)}</div>;
}
type MyRichTextOptions = AnyRichTextOptions; // you can reduce the surface of what you need to render, by restricting the `options` in `s.richtext(options)`

Image

Image Schema

s.image();

Initializing image content

Local images must be stored under the .public folder.

import { s, c } from "../val.config";

export const schema = s.image();

export default c.define("/image", schema, c.file("/public/myfile.jpg"));

NOTE: This will not validate, since images requires width, height and a sha256 checksum. You can fix validation errors like this by using the CLI or by using the VS Code plugin.

Rendering images

The ValImage component is a wrapper around next/image that accepts a Val Image type.

You can use it like this:

const content = useVal(contentVal);

return <ValImage src={content.image} alt={content.alt} />;

Using images in components

Images are transformed to object that have a url property which can be used to render them.

Example:

// in a Functional Component
const image = useVal(imageVal);

return <img src={image.url} />;

Union

The union schema can be used to create either "tagged unions" or a union of string literals.

Union Schema tagged unions

A tagged union is a union of objects which all have the same field (of the same type). This field can be used to determine (or "discriminate") the exact type of one of the types of the union.

It is useful when editors should be able to chose from a set of objects that are different.

Example: let us say you have a page that can be one of the following: blog (page) or product (page). In this case your schema could look like this:

s.union(
  "type", // the key of the "discriminator"
  s.object({
    type: s.literal("blogPage"), // <- each type must have a UNIQUE value
    author: s.string(),
    // ...
  }),
  s.object({
    type: s.literal("productPage"),
    sku: s.number(),
    // ...
  })
); // <- Schema<{ type: "blogPage", author: string } | { type: "productPage", sku: number }>

Union Schema: union of string literals

You can also use a union to create a union of string literals. This is useful if you want a type-safe way to describe a set of valid strings that can be chosen by an editor.

s.union(
  s.literal("one"),
  s.literal("two")
  //...
); // <- Schema<"one" | "two">

KeyOf

If you need to reference content in another .val file you can use the keyOf schema.

KeyOf Schema

import otherVal from "./other.val"; // NOTE: this must be an array or a record

s.keyOf(otherVal);

Initializing keyOf

Using keyOf to reference content

const article = useVal(articleVal); // s.object({ author: s.keyOf(otherVal) })
const authors = useVal(otherVal); // s.array(s.object({ name: s.string() }))

const nameOfAuthor = authors[articleVal.author].name;
0.61.0

4 days ago

0.60.27

16 days ago

0.60.22

20 days ago

0.60.23

20 days ago

0.60.24

20 days ago

0.60.21

22 days ago

0.60.19

23 days ago

0.60.20

23 days ago

0.60.17

26 days ago

0.60.18

26 days ago

0.60.15

27 days ago

0.60.16

26 days ago

0.60.13

27 days ago

0.60.14

27 days ago

0.60.12

27 days ago

0.60.11

1 month ago

0.60.10

1 month ago

0.60.7

1 month ago

0.60.6

1 month ago

0.60.9

1 month ago

0.60.8

1 month ago

0.60.3

1 month ago

0.60.5

1 month ago

0.60.4

1 month ago

0.60.2

1 month ago

0.60.1

1 month ago

0.60.0

1 month ago

0.59.0

1 month ago

0.58.0

4 months ago

0.57.0

4 months ago

0.55.2

4 months ago

0.56.0

4 months ago

0.55.0

4 months ago

0.55.1

4 months ago

0.54.0

4 months ago

0.53.0

4 months ago

0.51.0

4 months ago

0.51.1

4 months ago

0.52.0

4 months ago

0.50.0

4 months ago

0.49.0

4 months ago

0.48.0

5 months ago

0.47.1

5 months ago

0.48.1

5 months ago

0.47.0

5 months ago

0.46.1

5 months ago

0.46.0

5 months ago

0.45.0

5 months ago

0.44.0

5 months ago

0.43.1

5 months ago

0.43.0

5 months ago

0.42.0

5 months ago

0.41.0

5 months ago

0.40.0

5 months ago

0.39.0

5 months ago

0.38.0

5 months ago

0.37.0

5 months ago

0.36.0

5 months ago

0.35.0

5 months ago

0.34.0

6 months ago

0.33.0

6 months ago

0.32.0

6 months ago

0.31.0

6 months ago

0.30.0

6 months ago

0.29.0

6 months ago

0.28.0

6 months ago

0.27.0

6 months ago

0.26.0

6 months ago

0.25.0

6 months ago

0.24.0

6 months ago

0.23.0

6 months ago

0.22.0

6 months ago

0.21.2

7 months ago

0.21.1

7 months ago

0.21.0

7 months ago

0.20.2

7 months ago

0.20.1

7 months ago

0.20.0

7 months ago

0.19.0

7 months ago

0.18.0

8 months ago

0.17.0

8 months ago

0.16.0

9 months ago

0.15.0

9 months ago

0.14.0

9 months ago

0.13.10

9 months ago

0.13.9

9 months ago

0.13.4

11 months ago