@valbuild/next
v0.72.0
Published
Val NextJS: hard-coded content - super-charged
Downloads
790
Readme
🐉 HERE BE DRAGONS 🐉
Val is currently in beta - the API can be considered relatively stable, but expect some features to be broken and the UX to be changing.
Join us on discord to get help or give us feedback.
Table of contents
Installation
- Make sure your project is using
- Install the packages:
npm install @valbuild/core@latest @valbuild/next@latest
- Optionally, but recommend add the eslint-plugin package:
npm install -D @valbuild/eslint-plugin@latest
- Run the init script:
npx @valbuild/init@latest
If you do not wish to use the init script, or having issues with it, checkout the manual configuration guide.
Additional setup
- If you have a monorepo, or have a project where the project is located in a subdirectory relative to the github repository see the monorepos section
- See formatting published content if you use prettier (or similar) Val to do it as well.
- If you want editors to update content in production, read up on how to setup remote mode.
Getting started
Create your first Val content file
Content in Val is always defined in .val.ts
(or .js
) files.
NOTE: the init script will generate an example Val content file (unless you opt out of it).
Val content files are evaluated by Val, therefore they need to abide a set of requirements.
If you use the eslint plugins these requirements will be enforced. You can also validate val files using the @valbuild/cli: npx -p @valbuild/cli val validate
.
For reference these requirements are:
- they must export a default content definition (
c.define
) where the first argument equals the path of the file relative to theval.config
file; and - they must be declared in the
val.modules
file; and - they must have a default export that is
c.define
; and - they can only import Val related files or types (using
import type { MyType } from "./otherModule.ts"
)
Val content file example
// ./examples/val/example.val.ts
import { s /* s = schema */, c /* c = content */ } from "../../val.config";
/**
* This is the schema for the content. It defines the structure of the content and the types of each field.
*/
export const schema = s.object({
/**
* Basic text field
*/
text: s.string(),
});
/**
* This is the content definition. Add your content below.
*
* NOTE: the first argument is the path of the file.
*/
export default c.define("/examples/val/example.val.ts", schema, {
text: "Basic text content",
});
The val.modules
file
Once you have created your Val content file, it must be declared in the val.modules.ts
(or .js
) file in the project root folder.
Example:
import { modules } from "@valbuild/next";
import { config } from "./val.config";
export default modules(config, [
// Add your modules here
{ def: () => import("./examples/val/example.val") },
]);
Using Val in Client Components
In client components you can access your content with the useVal
hook:
// ./app/page.tsx
"use client";
import { useVal } from "../val/val.client";
import exampleVal from "../examples/val/example.val";
export default function Home() {
const { text } = useVal(exampleVal);
return <main>{text}</main>;
}
Using Val in React Server Components
In React Server components you can access your content with the fetchVal
function:
// ./app/page.tsx
"use server";
import { fetchVal } from "../val/val.rsc";
import exampleVal from "../examples/val/example.val";
export default async function Home() {
const { text } = await fetchVal(exampleVal);
return <main>{text}</main>;
}
Remote Mode
Enable remote mode to allow editors to update content online (outside of local development) by creating a project at app.val.build.
NOTE: Your content remains yours. Hosting content from your repository does not require a subscription. However, to edit content online, a subscription is needed — unless your project is a public repository or qualifies for the free tier. Visit the pricing page for details.
WHY: Updating code involves creating a commit, which requires a server. We offer a hosted service for simplicity and efficiency, as self-hosted solutions takes time to setup and maintain. Additionally, the val.build team funds the ongoing development of this library.
Remote Mode Configuration
Once your project is set up in app.val.build, configure your application to use it by setting the following:
Environment Variables
VAL_API_KEY
: Obtain this from your project's configuration page.VAL_SECRET
: Generate a random secret to secure communication between the UX client and your Next.js application.
val.config
Properties
Set these properties in the val.config
file:
project
: The fully qualified name of your project, formatted as<team>/<name>
.gitBranch
: The Git branch your application uses. For Vercel, useVERCEL_GIT_COMMIT_REF
.gitCommit
: The current Git commit your application is running on. For Vercel, useVERCEL_GIT_COMMIT_SHA
.root
: Optional. The path to theval.config
file. Typically empty or undefined. If the project folder is underweb
, root would be:/web
.
Example val.config.ts
import { initVal } from "@valbuild/next";
const { s, c, val, config } = initVal({
project: "myteam/myproject",
//root: "/subdir", // only required for monorepos. Use the path where val.config is located. The path should start with /
gitBranch: process.env.VERCEL_GIT_COMMIT_REF,
gitCommit: process.env.VERCEL_GIT_COMMIT_SHA,
});
export type { t } from "@valbuild/next";
export { s, c, val, config };
Formatting published content
If you are using prettier
or another code formatting tool, it is recommended to setup formatting of code after changes have been applied.
Setting up formatting using Prettier
Install
prettier
as RUNTIME dependency, by moving theprettier
dependency fromdevDependencies
todependencies
. The reason you need to do this, is that Val will be using it at runtime in production, and it has to be part of your build for this to work.Optionally create a
.prettierrc.json
file unless you have one already. We recommend doing this, so that you can be sure that formatting is applied consistently in both your development environment and by Val. You can set this to be an empty object, if you are want to keep usingprettier
s defaults:{}
Add a formatter to the
/val/val.server
:formatter: (code: string, filePath: string) => { return prettier.format(code, { filepath: filePath, ...prettierOptions, // <- use the same rules as in development } as prettier.Options); },
Unless you have any modifications in your
val.server
file, the complete file should now look like this:import "server-only"; import { initValServer } from "@valbuild/next/server"; import { config } from "../val.config"; import { draftMode } from "next/headers"; import valModules from "../val.modules"; import prettier from "prettier"; import prettierOptions from "../.prettierrc.json"; const { valNextAppRouter } = initValServer( valModules, { ...config }, { draftMode, formatter: (code: string, filePath: string) => { return prettier.format(code, { filepath: filePath, ...prettierOptions, // <- use the same rules as in development } as prettier.Options); }, }, ); export { valNextAppRouter };
You should now be able to hit the save button locally and see prettier rules being applied.
Other formatters
Val is formatter agnostic, so it is possible to use the same flow as the one described for prettier
above to any formatter you might want to use.
NOTE: this will be applied at runtime in production so you need make sure that the formatting dependencies are in the dependencies
section of your package.json
Monorepos
Val supports projects that are not under the root path in GitHub, and therefore monorepos.
To configure your project for monorepos, you can use the root
parameter described in the config section.
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>
Nullable
All schema types can be nullable (optional). A nullable schema creates a union of the type and null
.
import { s } from "./val.config";
s.string().nullable(); // <- 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
style: {
bold: true, // enables bold
italic: true, // enables italic text
lineThrough: true, // enables line/strike-through
},
// tags:
block: {
//ul: true, // enables unordered lists
//ol: true, // enables ordered lists
// headings:
h1: true,
h2: true,
// h3: true,
// h4: true,
// h5: true,
// h6: true
},
inline: {
//a: true, // enables links
//img: true, // enables images
},
});
export default c.define("/src/app/content", schema, [
{
tag: "p",
children: ["This is richtext"],
},
{
tag: "p",
children: [{ tag: "span", styles: ["bold"], children: ["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={{
style: {
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({ style: { bold: true } })
requires that you add a bold
theme.
<ValRichText
theme={{
h1: "text-4xl font-bold",
bold: "font-bold",
img: null, // either a string or null is required
}}
>
{content}
</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 / override 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/val
folder.
import { s, c } from "../val.config";
export const schema = s.image();
export default c.define("/image", schema, c.image("/public/myfile.jpg"));
NOTE: This will not validate, since images requires width
, height
and mimeType
. 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); // schema of contentVal: s.object({ image: s.image() })
return <ValImage src={content.image} />;
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
You can use keyOf
to reference a key in a record of a Val module.
NOTE: currently you must reference keys in Val modules, you cannot reference keys of values nested inside a Val module. This is a feature on the roadmap.
const schema = s.record(s.object({ nested: s.record(s.string()) }));
export default c.define("/keyof.val.ts", schema, {
"you-can-reference-me": {
// <- this can be referenced
nested: {
"but-not-me": ":(", // <- this cannot be referenced
},
},
});
KeyOf Schema
import otherVal from "./other.val"; // NOTE: this must be 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.record(s.object({ name: s.string() }))
const nameOfAuthor = authors[articleVal.author].name;