Examples
Everything below runs in your browser, on a small dataset of chefs and recipes. The examples share one store, so a change in one tab shows up in the others.
Loading data
import { QueryEngine } from "@comunica/query-sparql-rdfjs";
import { LocalStore } from "@shapething/localstore";
import { DataFactory, Parser } from "n3";
const store = new LocalStore({ baseUri: new URL("https://example.org/") });
// Opens the folder picker of the browser. Chrome based browsers only:
// Firefox and Safari do not implement the File System Access API.
await store.mount();
// Every Turtle file in the folder is a graph, named after its path:
// chefs/julia-child.ttl is the graph <https://example.org/chefs/julia-child>.
const engine = new QueryEngine();
const bindings = await engine.queryBindings(
`SELECT ?name WHERE {
GRAPH ?g {
?chef <http://schema.org/name> ?name
}
}`,
{ sources: [store] },
);
// Writing a graph replaces its file, the prefixes are used in the Turtle.
const graph = DataFactory.namedNode("https://example.org/recipes/pancakes");
const quads = new Parser({ baseIRI: graph.value }).parse(turtle);
await store.replaceGraph(graph, quads, { schema: "http://schema.org/" }); Linked data usually lives in a triplestoreTriplestore A database for RDF. It stores triples and answers SPARQL queries., a database made for it. LocalStore lets you use a folder on your computer instead. It is slower than a real database, but it is a good fit for small datasets.
Picking a folder is optional. Without one, the examples use an in-memory store with the same recipes and chefs.
Your browser can not pick a folder, and that is fine. All the other examples still work: they use the in-memory store, and changes you make stay there until you reload the page.
LocalStore reads and writes files with showDirectoryPicker() from the File System Access API. Only Chrome-based browsers, such as Chrome, Edge, Brave and Opera, have it. Firefox and Safari chose not to add it, because letting a website write to a folder on your computer is a security risk they did not want to take on. Open this page in a Chrome-based browser to try LocalStore with a real folder.
Fetching a list
import { typedSparql } from "@shapething/typed-sparql";
import { defineConfig } from "vite";
// Generates a .rq.d.ts file next to every .rq file in src, and updates it when the query changes.
export default defineConfig({
plugins: [typedSparql()],
}); PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX schema: <http://schema.org/>
SELECT ?recipe ?name ?chefName ?servings (COUNT(?ingredient) AS ?ingredients) WHERE {
?recipe a schema:Recipe ;
schema:name ?name ;
schema:recipeIngredient/rdf:rest*/rdf:first ?ingredient .
FILTER(LANG(?name) = "en")
OPTIONAL {
?recipe schema:author/schema:name ?chefName .
FILTER(LANG(?chefName) = "en")
}
OPTIONAL {
?recipe schema:recipeYield ?servings .
filter(isNumeric(?servings))
}
}
GROUP BY ?recipe ?name ?chefName ?servings import { QueryEngine } from "@comunica/query-sparql-rdfjs";
import type { TypedQuery } from "@shapething/typed-sparql";
import recipesQuery from "./recipes.rq";
const engine = new QueryEngine();
// The type of a row comes from the generated recipes.rq.d.ts.
const select = async <Row extends object>(query) => {
const bindings = await (await engine.queryBindings(query, { sources: [store] })).toArray();
return bindings.map(
(binding) =>
Object.fromEntries([...binding].map(([variable, term]) => [variable.value, term])) as Row,
);
};
const recipes = await select(recipesQuery);
for (const recipe of recipes) {
// chefName is optional in the query, so it can be undefined here.
console.log(recipe.name.value, recipe.chefName?.value);
} SPARQLSPARQL The query language for RDF, comparable to SQL for relational databases. is the query language for linked data, comparable to SQL. TypedSparql is a Vite plugin that generates TypeScript types for the results of your queries, so a change to a query shows up in your code right away. Try changing the query below: the type next to it updates as you type.
Fuzzy search
import { QueryEngine } from "@comunica/query-sparql-rdfjs";
import { TextStore } from "@shapething/textstore";
import { Charset, Encoder } from "flexsearch";
// Wraps any RDF/JS store, such as an N3 Store or a LocalStore.
const textStore = new TextStore({
store,
// Optional, Flexsearch options. This encoder allows typos such as "welington".
indexOptions: {
tokenize: "forward",
encoder: new Encoder(Charset.LatinAdvanced, { minlength: 3 }),
},
});
// Searching is a normal triple pattern with tsst:search as predicate.
// All other patterns are answered by the wrapped store.
const engine = new QueryEngine();
const bindings = await engine.queryBindings(
`PREFIX schema: <http://schema.org/>
PREFIX tsst: <https://textstore.shapething.com/>
SELECT DISTINCT ?resource ?name WHERE {
GRAPH ?resource {
?match tsst:search "welington" .
?resource schema:name ?name .
}
}`,
{ sources: [textStore] },
); Triplestores often support fuzzy search, but stores that run in memory in the browser usually do not. TextStore wraps another store and adds fuzzy search to it.
Below, TextStore wraps the store from Loading data: the folder you picked, or the in-memory store. Searching is a normal SPARQL query with tsst:search as the predicateTriple One RDF statement, made of a subject, a predicate and an object. Compare it to one cell in a table: the row, the column and the value., and typos are allowed.
Fetching a document
import { QueryEngine } from "@comunica/query-sparql-rdfjs";
import { ResourceFetcher } from "@shapething/resource-fetcher";
import { DataFactory, Parser, Store } from "n3";
import shapesTurtle from "./shapes.ttl?raw";
const engine = new QueryEngine();
// The fetcher only needs a way to run SELECT queries: a store, a SPARQL endpoint or anything else.
const queryBindings = async (query: string) =>
(await engine.queryBindings(query, { sources: [store], unionDefaultGraph: true })).toArray();
// Basic: everything about the recipe, including its blank nodes such as the ingredients.
const recipe = new ResourceFetcher({
resourceIri: DataFactory.namedNode("https://example.org/recipes/beef-wellington"),
queryBindings,
});
const { results } = await recipe.execute();
// Advanced: a shape says what else belongs to the document, here the label of the cuisine of a chef.
const shapesGraph = new Store(new Parser().parse(shapesTurtle));
const chef = new ResourceFetcher({
resourceIri: DataFactory.namedNode("https://example.org/chefs/julia-child"),
queryBindings,
shapesGraph,
shapeIris: DataFactory.namedNode("http://example.org/ChefWithCuisineLabelShape"),
});
const { results: chefWithCuisine } = await chef.execute(); @prefix ex: <http://example.org/>.
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
@prefix sh: <http://www.w3.org/ns/shacl#>.
# Everything about the chef is fetched anyway, the shape only adds
# what lies beyond it: the label of the cuisine the chef links to.
ex:ChefWithCuisineLabelShape a sh:NodeShape ;
sh:property [
sh:path ex:cuisine ;
sh:node [
sh:path rdfs:label ;
] ;
] . In linked data, a resourceResource Anything that is described in RDF, such as a recipe or a chef. On this site a resource together with all its data is also called a document. such as a recipe is not stored as one document but as many small pieces, called triplesTriple One RDF statement, made of a subject, a predicate and an object. Compare it to one cell in a table: the row, the column and the value.. That makes a simple question harder to answer: what belongs to this recipe? ResourceFetcher fetches one complete document, and lets you decide what belongs in it.
Below, ResourceFetcher runs on the store from Loading data. Click a resource to fetch it. Without a shape it takes everything about the resource, including its nested parts, called blank nodesBlank node A thing in RDF without its own web address. It only exists as part of another resource, like the ingredient lines of a recipe. Comparable to a nested object in JSON., such as the ingredients of a recipe, each with a name, an amount and a unit. Links to other resources, such as the chef of a recipe, are not followed.
queries
Sometimes a document needs a bit more than its own data. A chef links to a cuisine, and to show the chef you want the name of that cuisine, not just its IRIIRI The web address that identifies a thing in RDF, like https://example.org/chef/julia-child. It works like a primary key that is unique worldwide.. A shapeShape A description of what a resource should look like: which properties it has, of which type and how many. Comparable to a Zod or JSON Schema. ShapeThing uses shapes to fetch, show, edit, type and generate data. tells ResourceFetcher what to follow: the shape below says to fetch the cuisine with its rdfs:label. Everything about the chef itself is still fetched without being listed in the shape. The cuisines are stored in a different file than the chefs, so the cuisine is fetched as its own document by a nested fetch. Its queries are listed after the ones for the chef.
queries
Viewing and editing
import { ShaclRenderer, type SubmitResult } from "@shapething/shacl-renderer";
import "@shapething/shacl-renderer/style.css";
import { DataFactory, Parser } from "n3";
import shapesTurtle from "./shapes.ttl?raw";
const { namedNode } = DataFactory;
// The shapes for a chef and a recipe, as an array of quads.
const shapes = new Parser().parse(shapesTurtle);
// Shows a chef, the shape decides which properties are shown and how.
export function Chef({ quads }) {
return (
<ShaclRenderer
mode="view"
shapesGraph={shapes}
dataGraph={quads}
focusNode={namedNode("https://example.org/chefs/gordon-ramsay")}
nodeShapes={[namedNode("http://example.org/ChefShape")]}
/>
);
}
// A form for a recipe, with fields and validation from the shape.
export function RecipeForm({ quads, onSave }) {
// dataGraph holds the edited recipe, for example to write back to the store.
const onSubmit = async ({ dataGraph }: SubmitResult) => {
await onSave(dataGraph);
};
return (
<ShaclRenderer
mode="edit"
shapesGraph={shapes}
dataGraph={quads}
focusNode={namedNode("https://example.org/recipes/beef-wellington")}
nodeShapes={[namedNode("http://example.org/RecipeShape")]}
onSubmit={onSubmit}
/>
);
} @prefix ex: <http://example.org/>.
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>.
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
@prefix schema: <http://schema.org/>.
@prefix sh: <http://www.w3.org/ns/shacl#>.
@prefix shui: <http://www.w3.org/ns/shacl-ui/>.
@prefix st: <http://shapething.com/>.
@prefix faker: <https://fakerjs.dev/>.
@prefix unit: <http://qudt.org/vocab/unit/>.
### Copied from packages/shacl-renderer/src/stories/showcases/recipes-and-chefs.ttl, the vocabulary
### (cuisines, categories and units) lives in recipes-and-chefs/vocabulary.ttl.
### Unlike the original, the amount and unit of an ingredient are optional, as its comment intends:
### "Eggs" or "a pinch of salt" have no unit. The faker:generator annotations and the bounds on
### servings and amounts make generated recipes and chefs look like the real ones.
### Chef Shape
ex:ChefShape a sh:NodeShape ;
sh:targetClass ex:Chef ;
sh:property [
sh:path schema:name ;
sh:datatype rdf:langString ;
sh:languageIn ( "en" "nl" ) ;
sh:uniqueLang true ;
sh:minCount 2 ;
sh:order 0 ;
sh:name "Name"@en, "Naam"@nl ;
shui:propertyRole shui:LabelRole ;
] ;
sh:property [
sh:path schema:image ;
sh:nodeKind sh:IRI ;
sh:maxCount 1 ;
sh:order 1 ;
sh:name "Photo"@en, "Foto"@nl ;
shui:viewer shui:ImageViewer ;
shui:propertyRole shui:DepictionRole ;
] ;
sh:property [
sh:path schema:nationality ;
faker:generator faker:location.country ;
sh:datatype rdf:langString ;
sh:languageIn ( "en" "nl" ) ;
sh:uniqueLang true ;
sh:minCount 2 ;
sh:order 2 ;
sh:name "Nationality"@en, "Nationaliteit"@nl ;
] ;
sh:property [
sh:path ex:cuisine ;
sh:rootClass ex:Cuisine ;
sh:nodeKind sh:IRI ;
sh:minCount 1 ;
shui:editor shui:SubClassEditor ;
sh:order 3 ;
shui:propertyRole shui:ClassificationRole ;
sh:name "Signature cuisine"@en, "Signatuurkeuken"@nl ;
] ;
sh:property [
sh:path ex:michelinStars ;
sh:datatype xsd:integer ;
sh:minInclusive 0 ;
sh:maxInclusive 3 ;
sh:maxCount 1 ;
sh:order 4 ;
sh:name "Michelin stars"@en, "Michelinsterren"@nl ;
] ;
sh:property [
sh:path schema:worksFor ;
faker:generator ( faker:company.name ", " faker:location.city ) ;
sh:datatype rdf:langString ;
sh:languageIn ( "en" "nl" ) ;
sh:uniqueLang true ;
sh:order 5 ;
sh:name "Restaurant"@en, "Restaurant"@nl ;
] ;
sh:property [
sh:path schema:description ;
faker:generator ( "Chef and restaurateur, known for " faker:food.ethnicCategory " cooking." ) ;
sh:datatype rdf:langString ;
sh:languageIn ( "en" "nl" ) ;
sh:uniqueLang true ;
sh:singleLine false ;
sh:order 6 ;
sh:name "Biography"@en, "Biografie"@nl ;
shui:propertyRole shui:DescriptionRole ;
] ;
sh:property [
sh:path schema:sameAs ;
sh:nodeKind sh:IRI ;
sh:maxCount 1 ;
sh:order 7 ;
sh:name "Wikipedia"@en, "Wikipedia"@nl ;
] ;
.
ex:IngredientShape a sh:NodeShape ;
sh:property [
sh:path schema:name ;
faker:generator faker:food.ingredient ;
sh:datatype rdf:langString ;
sh:languageIn ( "en" "nl" ) ;
sh:uniqueLang true ;
sh:minCount 2 ;
sh:order 0 ;
sh:name "Ingredient"@en, "Ingrediënt"@nl ;
shui:propertyRole shui:LabelRole ;
sh:group ex:ingredientFieldsGroup ;
] ;
sh:property [
sh:path schema:value ;
sh:minInclusive 1 ;
sh:maxInclusive 2000 ;
sh:datatype xsd:integer ;
sh:maxCount 1 ;
sh:order 1 ;
sh:name "Amount"@en, "Hoeveelheid"@nl ;
sh:group ex:ingredientFieldsGroup ;
] ;
sh:property [
sh:path schema:unitCode ;
sh:class ex:Unit ;
sh:nodeKind sh:IRI ;
sh:maxCount 1 ;
shui:editor shui:EnumSelectEditor ;
sh:order 2 ;
sh:name "Unit"@en, "Eenheid"@nl ;
sh:group ex:ingredientFieldsGroup ;
] ;
.
ex:ingredientFieldsGroup a sh:PropertyGroup, st:HorizontalPropertyGroup ;
.
ex:mainGroup
a sh:PropertyGroup ;
sh:name "Main content"@en, "Hoofdinhoud"@nl ;
sh:order -99 ;
.
ex:metaGroup
a sh:PropertyGroup ;
sh:name "Metadata"@en, "Metadata"@nl ;
sh:order -98 ;
.
### Recipe Shape
ex:RecipeShape a sh:NodeShape ;
sh:targetClass schema:Recipe ;
sh:property [
sh:path schema:name ;
faker:generator faker:food.dish ;
sh:datatype rdf:langString ;
sh:languageIn ( "en" "nl" ) ;
sh:uniqueLang true ;
sh:minCount 2 ;
sh:group ex:mainGroup ;
sh:order -6 ;
sh:name "Recipe name"@en, "Naam van het recept"@nl ;
shui:propertyRole shui:LabelRole ;
] ;
sh:property [
sh:path schema:image ;
sh:nodeKind sh:IRI ;
sh:maxCount 1 ;
sh:group ex:mainGroup ;
sh:order 10 ;
sh:name "Photo"@en, "Foto"@nl ;
shui:viewer shui:ImageViewer ;
] ;
sh:property [
sh:path schema:author ;
sh:class ex:Chef ;
sh:nodeKind sh:IRI ;
sh:minCount 1 ;
sh:maxCount 1 ;
sh:group ex:metaGroup ;
sh:order 2 ;
sh:name "Chef"@en, "Chef-kok"@nl ;
] ;
sh:property [
sh:path ex:cuisine ;
sh:rootClass ex:Cuisine ;
sh:nodeKind sh:IRI ;
sh:minCount 1 ;
shui:editor shui:SubClassEditor ;
sh:order 3 ;
sh:group ex:metaGroup ;
sh:name "Cuisine"@en, "Keuken"@nl ;
] ;
sh:property [
sh:path schema:recipeCategory ;
sh:class ex:RecipeCategory ;
sh:nodeKind sh:IRI ;
sh:in ( ex:Starter ex:MainCourse ex:Dessert ex:SideDish ) ;
sh:maxCount 1 ;
sh:group ex:metaGroup ;
sh:order 4 ;
sh:name "Category"@en, "Categorie"@nl ;
] ;
sh:property [
sh:path schema:prepTime ;
sh:datatype xsd:duration ;
shui:editor st:DurationEditor ;
st:durationPattern "{hh}{mm}" ;
sh:group ex:metaGroup ;
sh:minCount 1 ;
sh:maxCount 1 ;
sh:order 5 ;
sh:name "Prep time"@en, "Bereidingstijd"@nl ;
] ;
sh:property [
sh:path schema:cookTime ;
sh:datatype xsd:duration ;
shui:editor st:DurationEditor ;
st:durationPattern "{hh}{mm}" ;
sh:minCount 1 ;
sh:group ex:metaGroup ;
sh:maxCount 1 ;
sh:order 6 ;
sh:name "Cook time"@en, "Kooktijd"@nl ;
] ;
sh:property [
sh:path schema:recipeYield ;
sh:minInclusive 1 ;
sh:maxInclusive 12 ;
sh:datatype xsd:integer ;
sh:group ex:metaGroup ;
sh:maxCount 1 ;
sh:minCount 1 ;
sh:order 7 ;
sh:name "Servings"@en, "Porties"@nl ;
] ;
sh:property [
sh:path schema:recipeIngredient ;
sh:memberShape [
sh:node ex:IngredientShape ;
shui:editor shui:DetailsEditor ;
shui:viewer shui:DetailsViewer ;
] ;
sh:minListLength 1 ;
sh:group ex:mainGroup ;
sh:order 8 ;
sh:name "" ;
] ;
sh:property [
sh:path schema:recipeInstructions ;
faker:generator ( "Prepare the " faker:food.ingredient ", then cook it slowly with the " faker:food.ingredient "." ) ;
sh:datatype rdf:langString ;
sh:languageIn ( "en" "nl" ) ;
sh:uniqueLang true ;
sh:singleLine false ;
sh:group ex:mainGroup ;
sh:minCount 2 ;
sh:order -2 ;
sh:name "Instructions"@en, "Bereidingswijze"@nl ;
] ;
sh:property [
sh:path schema:description ;
faker:generator faker:food.description ;
sh:datatype rdf:langString ;
sh:languageIn ( "en" "nl" ) ;
sh:uniqueLang true ;
sh:singleLine false ;
sh:minCount 2 ;
sh:group ex:mainGroup ;
sh:order -3 ;
sh:name "Description"@en, "Beschrijving"@nl ;
] ;
. The SHACLSHACL The W3C standard for describing and validating RDF data. A SHACL file contains shapes. renderer is a React component that turns a shapeShape A description of what a resource should look like: which properties it has, of which type and how many. Comparable to a Zod or JSON Schema. ShapeThing uses shapes to fetch, show, edit, type and generate data. into a view or a form.
The shapes below describe a chef and a recipe. ResourceFetcher uses them to fetch exactly what is needed: a recipe with all its ingredients comes back in one query. The SHACL renderer then shows a chef:
The SHACL renderer can also make a form to edit a recipe. Saving writes the recipe back to its own file in the store from Loading data, and the other tabs update with it.
Converting and generating
import { generate, rdfToJs, shaclToType } from "@shapething/shacl-renderer/tools";
import { DataFactory, Store } from "n3";
const { namedNode } = DataFactory;
const RecipeShape = namedNode("http://example.org/RecipeShape");
const ChefShape = namedNode("http://example.org/ChefShape");
// Converting: a TypeScript type per shape, as source code.
const types = shaclToType({ shapesGraph, nodeShapes: [RecipeShape, ChefShape] });
const recipeType = types.get("RecipeShape");
// Converting: RDF to a plain JavaScript object, with English values only.
const recipe = await rdfToJs({
shapesGraph,
dataGraph,
focusNode: namedNode("https://example.org/recipes/beef-wellington"),
nodeShapes: [RecipeShape],
languages: ["en"],
});
// Generating: fake data that follows the shape, written into the data graph.
// Existing chefs in the data graph can be picked as the chef of the recipe.
const generated = new Store(chefs);
await generate({
shapesGraph,
dataGraph: generated,
focusNode: namedNode("https://example.org/recipes/generated"),
nodeShapes: [RecipeShape],
}); A shapeShape A description of what a resource should look like: which properties it has, of which type and how many. Comparable to a Zod or JSON Schema. ShapeThing uses shapes to fetch, show, edit, type and generate data. can become a TypeScript type, and RDFRDF The data format behind linked data. All data is written as small statements of three parts, called triples, like "this recipe – has chef – Julia Child". data can be read into a plain JavaScript object of that type. Pick a resource: on the left the type generated from its shape, on the right the resource as an object, with English property names.
A shape describes what the data looks like, so it can also make data. ShapeThing generates a chef or a recipe from its shape, with fake values that follow the rules of the shape: the right datatypes, a cuisine from the list, an existing chef for a recipe. The faker:generator annotations in the shape make the values look like food. Add one to the store and it shows up in the other tabs.