Skip to content

Releases: transclude-dev/transclude

v0.8.2

Choose a tag to compare

@github-actions github-actions released this 06 Aug 22:58

Five things that worked in the build and not in npm run dev, one that worked in
neither, and a snippet that was wrong in the documentation.

Both packages move together: @transclude/core and @transclude/create are
0.8.2.

npm install @transclude/core@latest

0.8.1 was tagged and never published. GitHub queued its workflow and never ran
it, and by the time that was worked out there was a fix worth carrying, so this
release is both. Nothing is missing: everything written for 0.8.1 is here.

If you copied the bindings snippet, change it

The version shipped in 0.8.0 holds env in a module variable. In a transclude
app that does not work, and it fails quietly.

Two bundlers touch the file. The build inlines a copy into
dist/server/entry.js for your loaders, and wrangler bundles worker.js with a
second one. hold sets the variable in one copy and bindings reads the other,
which is still null.

// app/lib/bindings.js
const SLOT = Symbol.for('app.bindings');

export const hold = (env) => {
  globalThis[SLOT] = env;
};

export const bindings = () => globalThis[SLOT] ?? null;

A symbol in the global registry is one slot per isolate however many copies of
the module exist.

This was found on a live site rather than in a test. A form answered, the page
said what it was supposed to say, and the row went to the fallback the module
keeps for dev. Nothing logged anything. If you built on the old snippet, check
that what you think you are writing is arriving.

Fixed

A <script> the browser does not execute is markup now, not code to
compile.
Any type that is not JavaScript makes a script a data block, and
every one of them was read as a client module and compiled, which means
swallowed.

<script type="application/ld+json"></script>
<script type="importmap"></script>

Structured data, import maps, hand-written speculation rules, and anything a
library reads out of the document all disappeared from the page with nothing
said. A plain <script> and type="module" are unchanged.

Interpolating into one is still refused. Being markup does not make raw text
safe, and a ${…} there could close the element or the statement around it.

ctx.after and ctx.revalidateTag did not exist in the dev server. The dev
server builds its own context, and neither was on it. A loader calling one
worked in the build and threw after is not a function while you were writing
it.

revalidateTag is a no-op in dev, since nothing is held between requests there.
after behaves as it does on Node anywhere else.

/feed.xml, /sitemap.xml and the proxy route were 404 in dev. They are
mounted by createApp, which the dev server does not use, so a site with a
feed or a sitemap in its config had them in the build and nowhere else. The
sitemap had been that way for as long as the key has existed. The proxy was
worse: the route the browser calls returned 404 while you wrote the page and
worked once deployed.

/precache.json stays absent from dev, and that is a decision rather than the
same oversight. It names hashed asset filenames only the build knows, and a
service worker holding anything while you edit is a bug.

Four files carry the loader context and nothing linked them.
test/context-shape.test.js now compares src/app.js, bin/dev.js,
src/prerender.js and the type literal in src/typecheck.js.

A note on the version

A patch. Nothing in the built server changed, so a deployed site behaves as it
did on 0.8.0. What changed is the dev server catching up to it, the compiler
keeping data blocks it used to eat, and what the shipped skill teaches about
reaching a binding.

Full changelog: v0.8.0...v0.8.2

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 06 Aug 08:21

Fragments were answered with the whole document on Cloudflare Workers. If you
deploy there, this is the release to take.

Both packages move together: @transclude/core and @transclude/create are
0.8.0.

npm install @transclude/core@latest

Fixed

?fragment= returned the whole page on workerd. Every config default was
applied by loadProject, which reads a disk, so only Node ever applied them. A
worker imports transclude.config.js and hands the app exactly what the author
wrote, so a config that did not name fragmentParam left it undefined. That
reads as "no parameter configured", and the request falls through to the
document.

The visible symptom is a swap writing a second copy of the page into the element
it was meant to replace. It looks like a compiler bug and it was a missing
default.

// this was enough to be affected
export default {
  appDir: 'app',
  stylesheet: 'app/styles/global.css',
};

createApp applies the defaults now, on its first line, so no runtime and no
hand-written entry can miss them. A key you wrote still wins, including a falsy
one, so fragmentParam: null turns the parameter off as it always did.

Nothing changes for Node, Bun or Deno, which were reading the same defaults
already. On workerd, a fragment request that was answered with a document is
answered with the fragment. If you worked around this in a client, take the
workaround out.

A cookieSecret set to an empty string now says so. It said "needs a
secret", which sends you through the wiring while looking at a secret that is
plainly set. Every typeof along the way says string, because '' is one.
On a worker the usual cause is a wrangler secret put that took a blank line.

New

workerFrom is the worker entry, minus the wiring. Parsing the manifest,
wrapping each byte map and building the app on the first request is the same in
every app, so it moved into the package.

// worker.js
import { workerFrom } from '@transclude/core/worker';
import * as bundle from './dist/server/assets.js';
import * as entry from './dist/server/entry.js';
import manifest from './dist/routes.json';
import config from './transclude.config.js';

export default workerFrom({ config, manifest, entry, bundle });

The imports stay in your file, because a bundler needs a literal path to follow.
cookieSecret comes from env.COOKIE_SECRET when there is one, since that is
the only piece of config a worker cannot read at import time. For anything else,
call createApp from @transclude/core/app yourself: the long form still works
and is what this replaced.

Existing entries keep working. There is nothing to change.

Also

Every example runs on the web now, one per subdomain, each built from the same
source you would clone. todomvc.transclude.dev
ships zero bytes of JavaScript, which is easier to believe when you can open it.
The examples page links all nine.

Compared to other tools is new: Astro,
Enhance, htmx, Eleventy, Nue and the client frameworks, each with a paragraph on
where it is the better answer.

Decisions and limits gained a
Before 1.0 section, splitting what is settled from what may still move.

Full documentation at transclude.dev.

A note on the version

Below 1.0, a minor may break something. This one does not. It adds an export
and fixes a default that was only ever missing on one runtime.

Full changelog: v0.7.0...v0.8.0

v0.7.0

Choose a tag to compare

@github-actions github-actions released this 05 Aug 21:40

A loader can start work the reader does not wait for, and the compiler refuses
one more thing the browser answers with silence.

Both packages move together: @transclude/core and @transclude/create are
0.7.0.

npm install @transclude/core@latest

Before you upgrade

A repeated element writing a fixed view-transition-name now fails the
build.
A name has to be unique in the document. Two elements carrying one is
not an error the browser reports: it runs no transition at all and says nothing.

<!-- now a compile error -->
<li each="item of items" style="view-transition-name: card">

Derive it from the loop instead.

<li each="item of items" style="view-transition-name: card-${item.id}">

The build stopping is the change. A project that hits this had a transition that
was never running, so the fix is the one that was always needed. none is still
allowed, being the one value two elements can share.

Only the style attribute is read. A name applied through a class lives in a
stylesheet the compiler never sees.

New

ctx.after(work) is a promise the reader does not wait for. A view counted,
a webhook sent, a log written.

export const prerender = false;

export default async ({ after, url }) => {
  after(recordView(url));
  return { notes: await notes.all() };
};

On workerd this is waitUntil, which is how a worker asks to stay up past the
response it just sent. Without it the isolate may stop and take the work with
it. Node, Bun and Deno keep running either way, so there it changes nothing about
when the work finishes.

It takes the promise, not a function that makes one. A function is refused rather
than wrapped, because wrapping one would resolve to the function and run nothing.

A rejection goes to onError with the request that started it. Nothing awaits
this work, so leaving it would end the process on Node. The response is already
sent and does not change.

Also

ctx.revalidateTag called from a page being prerendered now says so. It was
never on that context, so the build failed with revalidateTag is not a function, which named neither the mistake nor the fix. Both it and ctx.after
refuse by name there, and say to give the page prerender = false.

How a Cloudflare binding reaches a loader is written down, at
transclude.dev/docs/runtimes. There is no
ctx.env, on purpose: env means something different on each of the four
runtimes. worker.js belongs to the app and does receive it, so the answer is
ten lines the app owns and types itself.

Two things the framework already did are documented for the first time.
setValidity on a form-associated element, so it can block a submit and show the
browser's own message. <details name> and hidden="until-found" are on
recipes.

Full documentation at transclude.dev.

A note on the version

Below 1.0, a minor may break something. This one can, in one place: a repeated
element that writes a fixed view-transition-name. The section at the top says
what to do, and a project hitting it was already not getting the transition.

Full changelog: v0.6.0...v0.7.0

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 05 Aug 19:07

The browser can fetch the next page before the reader clicks, and CSS can react
to state the document still cannot read.

Both packages move together: @transclude/core and @transclude/create are
0.6.0.

npm install @transclude/core@latest

Before you upgrade

An element with a boolean in <script state> now has ElementInternals.
That is where custom states live, so it is attached for any element that has one
to reflect. Two things follow.

Reading element.internals as a way to ask "is this a form control" now answers
differently. constructor.formAssociated is the question you meant, and it has
not changed.

Calling host.attachInternals() yourself inside an element's <script> block
now throws, because the framework already did. Read host.internals instead.

Neither applies to an element whose state is only numbers or strings, and
nothing else in this release changes what an existing project does.

New

speculate: true writes speculation rules into every page. Every link here
is a document request, and this lets the browser pay for the next one early. No
JavaScript of the framework's is involved, and a browser that does not support
it ignores the block.

export default {
  speculate: true,
};

The split is the part worth knowing, and the build decides it. A page written to
a file has no loader left to run, so the browser may run it early. Every route
the server still renders may only be fetched. Its loader may read a cookie,
count a view or hand out a token. Endpoints are in neither.

{
  "prerender": [{ "where": { "or": [{ "href_matches": "/about" }] }, "eagerness": "moderate" }],
  "prefetch":  [{ "where": { "or": [{ "href_matches": "/notes" }] }, "eagerness": "moderate" }]
}

eagerness is moderate by default, which waits for a hover. exclude takes
patterns as they are emitted, so a route /docs/:path{.+} is excluded as
/docs/*. With csp: true the block is hashed along with everything else the
page inlines, and needs no policy of its own.

A boolean state field is a custom state. State is deliberately not in the
document, which had left CSS no way to see it at all.

<script state>
  export default {
    hot: false,
  };
</script>

<style>
  :scope:state(hot) output {
    color: #b4232c;
  }
</style>

Booleans only: a custom state is a name and not a value. It lands with the
render rather than with the assignment, so await element.updateComplete before
asserting on it. Nothing is reflected on the server, because a state field
always starts at the default its block declares.

A new project turns off one editor setting. npm create @transclude writes
.vscode/settings.json with html.validate.scripts: false. A .html file here
holds script blocks that are separate modules, and the editor's built-in HTML
support reads them as one. An element declaring both <script properties> and
<script state> was reported as having two default exports, on a file that is
correct.

An existing project adds that file by hand. Nothing is lost by it.
npm run check and the language server in the package's editor/ directory
read these files the way the compiler does.

Also

command and commandfor open a <dialog> with no script at all, including
the CSS that makes it animate. That is the platform's own invoker rather than
anything here. It is on
recipes because writing a click handler
instead is the common mistake.

transclude.dev runs speculation now, so the
documentation demonstrates what it documents.

Full documentation at transclude.dev.

A note on the version

Below 1.0, a minor may break something. This one can, in one narrow place.
An element with a boolean state field that also reaches for attachInternals,
or reads internals to decide something. The section at the top says what to
do.

Full changelog: v0.5.0...v0.6.0

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 05 Aug 17:38

An icon set is a folder of SVG files. Put the folder in app/icons/ and it
becomes a sheet of its own, referenced by library and name.

Both packages move together: @transclude/core and @transclude/create are
0.5.0.

npm install @transclude/core@latest

Before you upgrade

Coming from 0.4.0: a subdirectory of app/icons/ means something new.
0.4.0 read the whole tree flat, so lucide/check.svg was /icons.svg#check.
It is /lucide.svg#check now. A page still pointing at the old URL renders an
empty box rather than an error. Search your markup for icons.svg# if you
nested anything.

Coming from 0.3.0 or earlier: app/icons/ is read at all. iconsDir
defaults to 'icons', so a project already keeping SVG files there starts
compiling them. If one has no viewBox, the build stops and names the file.
Set iconsDir to null if that directory holds something else:

export default {
  iconsDir: null,
};

A project with no app/icons/ sees no change either way.

New

A subdirectory is a library. This is the whole feature, and it is the flow
you already have: download an icon set, put the folder in, use it.

app/
  icons/
    check.svg              /icons.svg#check
    lucide/
      check.svg            /lucide.svg#check
      arrow-right.svg      /lucide.svg#arrow-right

Nothing is renamed to get there. Two libraries may each have a check, because
they are different files in different sheets.

<svg width="16" height="16"><use href="/lucide.svg#check"></use></svg>

Files loose at the top are the icons library, which is why they keep the
/icons.svg they had.

A new project comes with the element. npm create @transclude writes
app/elements/svg-icon.html, which takes library beside name:

<svg-icon name="check"></svg-icon>
<svg-icon library="lucide" name="check" label="Mark as done"></svg-icon>

The framework still defines no elements, and this one is not an exception: it is
scaffolded, so it is your file. Change the sizing, add a class, delete it. It
ships because the accessibility half is the half that fails quietly. An icon
that is hidden and labelled at once announces nothing, and one that is neither
announces its file name. The file gets both spellings right on the first day,
and test/create.test.js holds them.

An existing project has no such file. The icons
page
has it to copy.

A library is one flat directory. lucide/arrows/up.svg is refused rather
than flattened or skipped. Flattening would give two files one id, which is the
collision libraries exist to end, and skipping loses an icon without saying so.
Move the directory up to be a library of its own.

A name no library answers to is a 404. /lucdie.svg is a typo, and an empty
sprite is a bad way to find that out.

Changed

Two files may share a name now. 0.4.0 refused it, because a flat tree gave
them one id. In different libraries they are different icons and nothing
complains. In one library the filesystem already prevents it.

A library claims a URL at the site root. app/icons/lucide/ answers for
/lucide.svg, so a file you write at app/public/lucide.svg asks for the same
URL. The build and the dev server both stop until one is renamed. Public files
you have not named after a library are served exactly as before, including an
SVG: the public handler is asked first.

Full documentation at transclude.dev.

A note on the version

Below 1.0, a minor may break something, and this one does: a nested icon file
changes URL. 0.4.0 was published the same day and this is the correction to
it, so the window where anyone is affected is small. The first section says what
to look for.

Full changelog: v0.4.0...v0.5.0

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 05 Aug 16:25

Icons are a directory of SVG files you manage yourself. The build compiles them
into one sprite, and a page points at it.

Both packages move together: @transclude/core and @transclude/create are
0.4.0.

npm install @transclude/core@latest

Before you upgrade

app/icons/ is read now. iconsDir defaults to 'icons', so a project
already keeping SVG files there starts compiling them into /icons.svg. If one
of them has no viewBox, or two share a file name, the build stops and names the
file. That is the check doing its job, and it is also a build that passed
yesterday.

Set iconsDir to null if that directory holds something else:

export default {
  iconsDir: null,
};

A project with no app/icons/ sees no change.

New

A directory of SVG files becomes one sprite. One file per icon, which is a
whole SVG document you can open, edit and diff.

app/
  icons/
    check.svg
    chevron-down.svg

The build writes /icons.svg, one document of <symbol>s. The file name is the
id, and <use> points at it.

<svg width="16" height="16"><use href="/icons.svg#check"></use></svg>

One request serves every icon on the site, and the browser caches it. A page
showing twenty icons still makes one, and the second page costs nothing. The
same bytes are in the bundle a worker imports, so this works on Node, Bun, Deno
and workerd.

What the file says about paint is kept. fill="none" stroke="currentColor"
is how most icon sets say what they are, and dropping it turns every icon into a
black blob. viewBox is carried over too. width and height are dropped,
because they fight whatever CSS sizes the icon.

The build refuses rather than warns, because each of these renders and reads
as a stylesheet problem. A file with no viewBox scales wrong. Two files
claiming one name leave the second unreachable. A hand-written
app/public/icons.svg asks for the same URL as the sprite, so both the build and
the dev server stop until one is renamed.

The dev server builds the sprite per request, so adding an icon shows up on
reload. A refusal reads there as the same message that would stop the build.

No icon element ships with this. Elements come from your elements/
directory. Most apps want to write a name instead of a URL, which is six lines,
and the icons page has the one to copy:

<svg-icon name="check"></svg-icon>
<svg-icon name="check" label="Mark as done"></svg-icon>

The first is decorative and hidden from a screen reader, which is right when the
icon sits beside its own label. The second is announced, which is what a control
holding nothing but an icon needs.

Also

The skill shipped in the package knows about icons. An agent working in a project
gets the directory, the config key and the element without reading the site.

Two refusals are written down on
decisions and limits: <transclude>
does not import an SVG file, and no icon element ships with the framework.

Full documentation at transclude.dev.

A note on the version

Below 1.0, a minor may break something. This one adds a directory a project may
already have for its own reasons, which is the single place it can surprise you.
The section at the top says what to do about it.

Full changelog: v0.3.0...v0.4.0

v0.3.0

Choose a tag to compare

@joe-dakroub joe-dakroub released this 05 Aug 00:55

A project needs one fewer config file, your editor checks the JavaScript it was
already reading, and the types the compiler writes compile for the first time.

Both packages move together: @transclude/core and @transclude/create are
0.3.0.

npm install @transclude/core@latest

Before you upgrade

npm run check now reads transclude-env.d.ts for real. It never did. Every
context type in that file named __Cookies and the file declared it nowhere, in
every project, from the first check anyone ran. A .d.ts is the one kind of file
skipLibCheck skips, and both things that could have reported it passed that
option: bin/check.js passed it to the program written to catch exactly this,
and a jsconfig.json implies it. So the check ran and read nothing.

The compiler emits those declarations now, so a project that was fine stays fine.
A project using a type the emitter cannot express will see an error where there
was silence. That is the fix working.

New

A page can write attributes onto <body>.

<body class="docs" data-theme="${theme}">

Same rule <html> already had: the chain merges by name, and the innermost level
wins per attribute.

Changed

A project does not need a vite.config.js. Both templates ship without one.
The bins hand Vite the compiler themselves, so the file only existed to repeat
what they already do.

Keeping yours is fine and changes nothing: a second registration of the plugin is
ignored. Deleting it is worth doing, because Vite merges the two lists rather than
deduping them, and the second copy scanned your app again on every build and added
a second dev watcher that reloaded the browser twice for one edit.

Write one when you want another Vite plugin, and put only that in it:

import { defineConfig } from 'vite';
import tailwind from '@tailwindcss/vite';

export default defineConfig({
  plugins: [tailwind()],
});

A new project's jsconfig.json turns on checkJs. Your editor reported
nothing about the app's .js files while transclude-check ran the same files
with checking on. Two answers to one question, and the quiet one was the one you
saw. It also excludes app/public, which holds files you did not write, and sets
maxNodeModuleJsDepth to 0 so a dependency's own JavaScript is not checked.

An existing project keeps its own jsconfig.json. Adopting the new one is three
keys.

Fixed

A worker entry no longer drags the build output into your editor.
worker.js imports dist/server/entry.js, which put twenty errors about
generated code in front of anyone with checkJs on. The build writes a
@ts-nocheck banner on that file.

dev works in a project with no Vite config. It passed no plugins at all and
took the whole compiler from the app's own file, so removing that file answered
every page with a 500 while the build carried on working.

Also

The compiler had one rule written down three times: the expression that parses
each="item, i of items" lived in three files that each do something different
with the answer. The list of void elements, the compile-time escaper and the
substitution that turns /people/:name into a URL were each written twice. They
have one home now.

Merging them is how three gaps in the tests turned up. Deleting br from the
void list broke nothing, and neither did dropping any of the four escape
replacements. Both are covered now.

Full documentation at transclude.dev.

A note on the version

Below 1.0, a minor may break something. This one should not: an existing
project keeps its config files and its behavior. The one place it can surprise
you is the check above, which now reports a file that was never being read.

Full changelog: v0.2.0...v0.3.0

v0.2.0

Choose a tag to compare

@joe-dakroub joe-dakroub released this 04 Aug 20:11

Two things that were rendering the wrong output are compile errors now, and a
config that names only what it changes finally works.

Both packages move together: @transclude/core and @transclude/create are
0.2.0.

npm install @transclude/core@latest

Before you upgrade

Two new compile errors. If your build passes, nothing here changes what it
produces.
If it fails, it was producing something you did not ask for.

An interpolated attribute name is refused.

<div ${name}="x"></div>

An attribute name is written out as it stands, so this reached the page as the
five characters ${name} rather than a value. No error, no warning, a page that
looked fine until somebody read the output. It was the only interpolation
mistake here that did not fail. To choose between two spellings, put the
condition in the value: class="${done ? 'a' : 'b'}".

A <script server> block cannot bind a name the generated module already
defines.

import { elements } from '../data/elements.js';

elements is one of the names a compiled page exports, so this broke the build
inside the bundler, pointing at a virtual module rather than at anyone's file.
The check was already there for exports; an import binds a name and exports
nothing, so it walked straight past. Rename or alias it. The reserved list is
css, load, render, renderHead, renderTitle, renderHtmlAttrs,
layouts, client, elements, headScript, hasTitle and includes.

Fixed

A config gets the documented defaults. appDir, routesDir, elementsDir,
publicDir, outDir, typesFile, stylesheet, lang, fragmentParam,
trailingSlash, strict, csrf and csp were written in the documentation as
defaults and applied nowhere. A config leaving outDir out reached
path.join(root, undefined) and threw ERR_INVALID_ARG_TYPE, naming neither
the key nor the file. Every starter template sets all of them, which is why this
survived.

A top-level <script src> in a page reaches the page. It was read as a
client block, which made it an empty one, and the tag was dropped with its
src. The page rendered and the script it asked for was simply absent. A src
means there is no code to compile, so it is markup. A nested one always was.

A loader that can answer with a Response still types its own template.
Returning Response.redirect(...) from a layout is the documented way to write
a guard, and doing it made every name in that layout's markup an error about a
union the template can never be handed. ctx.action has excluded Response
since it was written; a loader does now too.

Also

Seven new example apps, each an ordinary project you can copy out of the
repository: TodoMVC with nothing but forms, a prerendered blog with a sitemap
and a feed, search over a fragment, the same search driven by htmx, transclusion
from three sources, a guarded section with a signed-cookie session, and a board
that updates itself over server-sent events.

New documentation for recipes, examples, contributing, and the parts that had
none: what an attribute value does, what the directives are, revalidate and
tags, and an endpoint that answers with a stream.

Full documentation at transclude.dev.

A note on the version

Below 1.0, a minor may break something. These two do, in the sense that a
build which passed can now fail. Both were turning a mistake into wrong output
rather than into a message, so the change is the point rather than a cost.

Full changelog: v0.1.1...v0.2.0