Campfire is a collection of small utilities to make developing with vanilla JS easy.
It is kept lightweight on purpose, aiming to provide the bare minimum necessary to make development easier.
const [button] = cf.nu("button#submit")
.content("Click me")
.attr("type", "submit")
.on("click", handleClick)
.done();
// Create different types of stores
const name = cf.store({ value: "John" });
const reactiveList = cf.store({ type: "list", value: [1, 2, 3] });
const reactiveMap = cf.store({ type: "map", value: { key: "value" } });
// Use stores with reactive elements
const [div] = cf.nu("div")
.deps({ name, reactiveList })
.render(({ name, reactiveList }) =>
`Hello, ${name}! My favourite numbers are ${reactiveList.join(",")}`
// escaped by default, re-renders whenever either name
// or reactiveList change
)
.done();
// Or do your own thing!
name.on("change", ({ value }) => {
greet(value);
});
// Select elements (always returns an array)
const [button] = cf.select({ s: "#submit-button" });
const allButtons = cf.select({ s: "button", all: true });
// Insert elements
cf.insert([elt], { into: container });
cf.insert([elt], { into: container, at: "start" });
cf.insert([elt], { before: sibling });
It doesn't. Campfire and $framework have entirely different goals. Campfire is a library to make writing vanilla JS applications easier, if you don't want the level of abstraction (or the associated overhead) that comes with $framework. You can build entire applications with it or add it quickly to an existing project. You are afforded complete control over your project.
The learning curve is minuscule and the possibilities are endless.
Campfire has no opinion on how you should do this. However, one option is to use a function that returns functions and stores to let you manipulate the element (and the created element itself).
// a reactive representation of a name badge
const NameBadge = () => {
const name = cf.store({ value: "" });
const [badge] = cf.nu("div")
.content(({ name }) => `Hello! My name is ${name}`)
.deps({ name })
.done();
return [badge, name];
};
With the new children API in v4, you can also use cf-slot elements to create composable components:
const Card = (title) => {
const [card] = cf.nu("div.card")
.html(`
<h2>${title}</h2>
<cf-slot name="content"></cf-slot>
`)
.done();
return card;
};
// Usage
const [content] = cf.nu("p").content("Card content").done();
const [wrapper] = cf.nu("div")
.html(`<cf-slot name="card"></cf-slot>`)
.children({ card: [Card("My Card")] })
.done();
escape
and unescape
are intended as basic HTML sanitizers mainly for setting
element contents, etc. You are encouraged to use a different HTML sanitizer if
you need more functionality.
You can include campfire into your site with just an import statement, either from a location where you are hosting it, or from esm.sh / unkpg.
import cf from "https://esm.sh/campfire.js@4";
or
import { ListStore, nu } from "https://esm.sh/campfire.js@4";
If you use a bundler or want to write in TypeScript, you can install Campfire from npm. This gives you full TypeScript support as well as TSDoc comments.
To install:
npm install --save-dev campfire.js
Then in your code:
import cf from "campfire.js";
Good luck, and thank you for choosing Campfire!
View the full API reference for detailed descriptions of the methods and classes provided by Campfire.
Campfire provides the following methods and classes:
nu()
- element creation helperCreates a new DOM element with a fluent builder API.
const [div] = cf.nu("div")
.content("Hello World")
.attr("id", "greeting")
.done();
const [button] = cf.nu("button#submit.primary")
.content("Submit")
.attr("type", "submit")
.on("click", () => console.log("Clicked!"))
.style("backgroundColor", "blue")
.done();
const [card] = cf.nu(".card.shadow") // Creates div by default
.content("Card content")
.done();
const name = cf.store({ value: "John" });
const role = cf.store({ value: "User" });
const [profile] = cf.nu("div.profile")
.deps({ name, role })
// render function runs again whenever name/role change
.render(({ name, role }, { b, first }) =>
b
.html`<h2>${name}</h2><p>Role: ${role}</p>`
.style("color", role === "Admin" ? "red" : "blue")
// can detect if this is first render or a re-render
.attr("data-initialized", first ? "initializing" : "updated")
)
.done();
const name = cf.store({ value: "John" });
const admin = cf.store({ value: false });
const [greeting] = cf.nu("h1")
.deps({ name, admin })
.render(({ name, admin }, { b }) => b
.content(`Hello, ${name}!`)
.style("color", admin ? "red" : "black")
.attr("title", admin ? "Administrator" : "User");
)
.done();
const name = cf.store({ value: "John" });
const renderGreeting = (name: string) =>
cf.html`<span>Hello, ${name}</span>`
const [greeting] = cf.nu("h1")
.deps({ name })
// b.html() sets innerHTML without escaping
// use b.content() to do it safely
.render(({ name }, { b }) => b.html(renderGreeting(name)))
.done();
// or you can disable escaping with nu(...).raw(true).done()
.gimme()
const [card, title, desc] = cf.nu("div.card")
.html(`
<h2 class="title">Card Title</h2>
<p class="desc">Description</p>
`)
.gimme(".title", ".desc") // Variadic - pass any number of selectors
.done();
const parentData = cf.store({ value: "Parent content" });
const childData = cf.store({ value: "Child content" });
// Parent with slots for child components
const [parent] = cf.nu("section", {
deps: { data: parentData },
render: ({ data }) => `
<h3>${data}</h3>
<cf-slot name="child"></cf-slot>
`,
children: {
// Child components maintain independent reactivity
// and are preserved between re-renders of the parent
child: cf.nu("div")
.deps({ data: childData })
.render(({ data }) => data)
.done(),
},
}).done();
const items = cf.store({ type: "list", value: ["Item 1", "Item 2", "Item 3"] });
// Create multiple listItem components
const listItems = items.value.map((text) =>
cf.nu("li")
.content(text)
.done()
);
// Create container and insert children
const [container] = cf.nu("div")
.html`
<h3>Item List</h3>
<ul><cf-slot name="items"></cf-slot></ul>
`
.children({ listItems })
.done();
const disabled = cf.store({ value: false });
const theme = cf.store({ value: "light" });
const themes = {
dark: { backgroundColor: "#303030", color: "white" },
light: { backgroundColor: "#f5f4f0", color: "#202020" },
};
const [button] = cf.nu("button")
.content("Click me")
.deps({ disabled, theme })
.render(({ disabled, theme }, { b }) => {
// Conditionally set or clear attributes (empty string clears)
b.attr("disabled", disabled ? "disabled" : "");
b.style("backgroundColor", themes[theme].backgroundColor);
b.style("color", themes[theme].color);
return b;
})
// assign click listener
.on("click", () => theme.update(theme.value === "light" ? "dark" : "light"))
.done();
// Create and globally track an element by ID
const [modal] = cf.nu("div.modal")
.content("Modal Content")
.track("app-modal") // Register with global tracking system
.done();
// Later, retrieve the element from anywhere
const elt = cf.tracked("app-modal");
if (elt) {
elt.style.display = "block";
}
// when done
cf.untrack("app-modal");
store()
- reactive data storesCreates reactive data stores to manage state with automatic UI updates.
const counter = cf.store({ value: 0 });
counter.update(5); // Sets value to 5
counter.current(); // Gets current value (5)
counter.on("change", (event) => {
console.log(`Value changed to ${event.value}`);
});
// Optionally trigger the callback immediately with current value
counter.on("change", (event) => {
console.log(`Value changed to ${event.value}`);
}, true); // Pass true to call immediately
const todoList = cf.store({ type: "list", value: ["Buy milk"] });
todoList.push("Walk dog"); // Adds to the end
todoList.remove(0); // Removes first item
todoList.clear(); // Empties the list
const user = cf.store({
type: "map",
value: { name: "John", age: 30 },
});
user.set("location", "New York"); // Add/update a property
user.delete("age"); // Remove a property
user.clear(); // Empty the map
todoList.any((event) => {
console.log(`Event type: ${event.type}`);
});
const counter = cf.store({ value: 10 });
// Using a value directly
counter.update(20);
// Using a transform function
counter.update((currentValue) => currentValue + 5); // Increments by 5
// More complex transformations
const user = cf.store({
type: "map",
value: { name: "John", visits: 0, lastVisit: null },
});
// Update multiple properties based on current value
user.update((current) => ({
...current,
visits: current.visits + 1,
lastVisit: new Date(),
}));
select()
- element selectionSelects elements from the DOM with a unified API.
const [header] = cf.select({ s: "#page-header" });
// or if you need the ref for passing somewhere:
const header = cf.select({ s: "#page-header", single: true });
const [submitButton] = cf.select({
s: 'button[type="submit"]',
from: formElement,
});
const paragraphs = cf.select({
s: "p",
all: true,
});
cf.select({ s: ".cards", all: true }).forEach((card) => {
cf.extend(card, { style: { border: "1px solid black" } });
});
insert()
- element insertionInserts elements into the DOM at specific positions.
cf.insert([elt], { into: container });
cf.insert([elt], { into: container, at: "start" });
cf.insert([elt], { before: referenceElement });
cf.insert([elt1, elt2], { after: referenceElement });
cf.insert(cf.nu().content("New content").done(), { into: document.body });
html()
- auto-escaping template literal and element builder methodCreates HTML strings with automatic escaping of interpolated values.
const username = '<script>alert("XSS")</script>';
const greeting = cf.html`Hello, ${username}!`;
// Result: "Hello, <script>alert("XSS")</script>!"
const trusted = cf.r('"<b>Bold text</b>"');
const message = cf.html`Safe message: ${trusted}`;
// Result: "Safe message: "<b>Bold text</b>""
const [div] = cf.nu("div")
.deps({ user })
// .html() is equivalent to .content().raw(true)
.html(({ user }) => cf.html`<h1>Title</h1><p>${user}</p>`)
.done();
mustache()
and template()
- reusable and composable string templatesA lightweight implementation of the Mustache templating system for string interpolation.
const result = cf.mustache("Hello, {{ name }}!", { name: "John" });
// Result: "Hello, John!"
const result = cf.mustache("Welcome, {{{ userHtml }}}!", {
userHtml: "<b>Admin</b>",
});
// Result: "Welcome, <b>Admin</b>!"
const result = cf.mustache(
"{{#loggedIn}}Welcome back!{{/loggedIn}}{{^loggedIn}}Please log in.{{/loggedIn}}",
{ loggedIn: true },
);
// Result: "Welcome back!"
const result = cf.mustache(
"<ul>{{#items}}<li>{{name}}</li>{{/items}}</ul>",
{ items: [{ name: "Item 1" }, { name: "Item 2" }] },
);
const result = cf.mustache(
"Numbers: {{#numbers}}{{.}}, {{/numbers}}",
{ numbers: [1, 2, 3] },
);
// Result: "Numbers: 1, 2, 3, "
const result = cf.mustache(
"{{#user}}Name: {{name}}, Age: {{age}}{{/user}}",
{ user: { name: "John", age: 30 } },
);
// Result: "Name: John, Age: 30"
const result = cf.mustache(
"{{#user}}{{name}} {{#admin}}(Admin){{/admin}}{{^admin}}(User){{/admin}}{{/user}}",
{ user: { name: "John", admin: true } },
);
// Result: "John (Admin)"
const result = cf.mustache(
"This is not a variable: \\{{ name }}",
{ name: "John" },
);
// Result: "This is not a variable: {{ name }}"
// Compile template once
const greet = cf.template("Hello, {{ name }}!");
// Use multiple times with different data
const aliceGreeting = greet({ name: "Alice" }); // "Hello, Alice!"
const bobGreeting = greet({ name: "Bob" }); // "Hello, Bob!"
extend()
- element modificationModifies existing DOM elements with the same options as nu()
.
New in v4.0.0-rc17: x(), an alias for nu() that uses the same builder API.
const element = document.querySelector("#my-element");
cf.extend(element, {
contents: "New content",
style: { color: "red", fontSize: "16px" },
});
// or
cf.x(element)
.content("New content")
.style({ color: "red", fontSize: "16px" })
.done();
const title = cf.store({ value: "Initial Title" });
cf.extend(header, {
render: ({ title }) => `Page: ${title}`,
deps: { title },
});
// or
cf.x(header)
.deps({ title })
.render(({ title }) => `Page: ${title}`)
.done();
empty()
and rm()
- element cleanupRemove elements or their contents from the DOM.
cf.empty(container);
cf.rm(element);
escape()
and unescape()
- string sanitizationSimple HTML escaping and unescaping utilities. These are the bare minimum for inserting text into the DOM - you should look to a different library for more complex needs.
escape("<script>alert('XSS')</script>"); // "<script>alert('XSS')</script>"
// Unescape previously escaped strings
unescape("<script>alert('XSS')</script>"); // "<script>alert('XSS')</script>"
onload()
- DOM ready handlerExecutes code when the DOM is fully loaded.
cf.onload(() => {
// Initialize application
const [app] = cf.nu("div#app").done();
cf.insert(app, { into: document.body });
});
seq()
- sequence generatorGenerates numerical sequences for iteration.
cf.seq(5); // [0, 1, 2, 3, 4]
cf.seq(2, 7); // [2, 3, 4, 5, 6]
cf.seq(1, 10, 2); // [1, 3, 5, 7, 9]
cf.seq(5).forEach((i) => {
const [item] = cf.nu("li")
.content(`Item ${i + 1}`)
.done();
cf.insert(item, { into: listElement });
});
ids()
- unique ID generatorGenerates unique IDs with an optional prefix. Useful for creating HTML element IDs.
const generateId = cf.ids(); // Default prefix is 'cf-'
const id1 = generateId(); // e.g. "cf-a1b2c3"
// With custom prefix
const generateButtonId = cf.ids("btn");
const buttonId = generateButtonId(); // e.g. "btn-g7h8i9"
const idGenerator = cf.ids("form-field");
cf.seq(3).forEach(() => {
const fieldId = idGenerator();
const [field, label] = cf.nu("div.form-field")
.html`
<label for="${fieldId}">Field</label>
<input id="${fieldId}" type="text">
`
.gimme("label", "input")
.done();
});
const getId = cf.ids("profile");
const LabeledInput = (labelText, type = "text") => {
const fieldId = getId();
return cf.nu("div.form-group")
.html`
<label for="${fieldId}">${labelText}</label>
<input id="${fieldId}" type="${type}">
`
.done();
};
const [emailGroup] = LabeledInput("Username", "email");
track()
, tracked()
, and untrack()
- element trackingProvides a global registry to track and retrieve elements by custom IDs.
// Create and track elements
const [header] = cf.nu("header")
.content("App Header")
.done();
// Track the element with a custom ID
cf.track("main-header", header);
// Later retrieve the element from anywhere in your code
const retrievedHeader = cf.tracked("main-header");
// Remove tracking when it's no longer needed
function removeComponent() {
const component = cf.tracked("my-component");
if (component) {
cf.rm(component);
cf.untrack("my-component");
}
}
callbackify()
- convert Promise-based functions to callback styleConverts a function that returns a Promise into a function that accepts a Node-style callback. Especially useful for using async operations in Store event handlers.
// Store event handlers are expected to be synchronous
// This pattern enables async operations without marking the handler as async
// Define an async operation
const loadEditorAsync = async (postId) => {
const content = await fetchPostContent(postId);
const [element, editor] = await createEditor(content);
return { element, editor };
};
// In a store subscription:
postStore.on("update", (event) => {
// Launch the async operation properly
callbackify(loadEditorAsync)(
(err, result) => {
if (err) {
console.error("Failed to load editor:", err);
return;
}
// Handle the result when the async operation completes
const { element, editor } = result;
cf.insert(element, { into: container });
postStore.set("editor", editor);
},
event.value,
);
});
// Original async function
const getUser = async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
};
// Convert to callback style
const getUserCb = cf.callbackify(getUser);
// Use with a callback
getUserCb((err, data) => {
if (err) {
console.error("Error:", err);
return;
}
console.log("User data:", data);
}, "12345");
const processItems = async (items) => {
// This might throw errors
const results = await Promise.all(items.map(processItem));
return results;
};
// Safe error handling with callbackify
cf.store({ value: [] }).on("update", (event) => {
cf.callbackify(processItems)(
(error, results) => {
if (error) {
errorStore.update(`Processing failed: ${error.message}`);
return;
}
resultStore.update(results);
},
event.value,
);
});
poll()
- execute a function at regular intervalsRepeatedly executes a function at specified intervals with proper cleanup.
// Check for updates every 5 seconds, starting 5 seconds from now
const stopPolling = cf.poll(() => checkMessages(user), 5000);
// Or call immediately:
const stopPolling = cf.poll(
() => checkMessages(user),
5000,
/* callNow */ true,
);
// Later, when you want to stop polling:
stopPolling();
You can use stores to pass messages out of the poll function, aside from just using good old-fashioned closures:
const messages = cf.store({ type: "list", value: [] });
const stopMessagePolling = cf.poll(
() => {
fetch("/api/messages")
.then((response) => response.json())
.then((data) => messages.update(data));
},
10000,
true,
);
// Cancel polling when component is removed
const cleanup = () => {
stopMessagePolling();
messages.dispose();
};