<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Lluís Ulzurrun de Asanza i Sàez</title>
	<atom:link href="https://llu.is/feed/" rel="self" type="application/rss+xml" />
	<link>https://llu.is</link>
	<description>aka Sumolari</description>
	<lastBuildDate>Mon, 13 Apr 2026 10:21:36 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</generator>
	<item>
		<title>Pulumi vs Terraform: honest retrospective after a full migration</title>
		<link>https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/</link>
					<comments>https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/#respond</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Mon, 13 Apr 2026 10:19:37 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[pulumi]]></category>
		<category><![CDATA[terraform]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15737</guid>

					<description><![CDATA[<p>This post is part of my series on migrating my Homelab from Terraform to Pulumi. In this article, I will sum up the experience, highlight the highs and lows of Pulumi, and share a few things I wish I had known beforehand. Other parts in this series: Migration duration This migration took me months to [&#8230;]</p>
The post <a href="https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/">Pulumi vs Terraform: honest retrospective after a full migration</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p>
    This post is part of my
    <a href="https://llu.is/category/projects/homelab/" title="">series</a> on
    <a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">migrating my Homelab from Terraform to Pulumi</a>. In this article, I will sum up the experience, highlight the highs and
    lows of <a href="https://www.pulumi.com/">Pulumi</a>, and share a few things
    I wish I had known beforehand.
</p>



<span id="more-15737"></span>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Other parts in this series:</strong></p>



<ul class="wp-block-list">
<li><a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">Why I am migrating my Homelab IaC from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/">Migrating Proxmox LXC containers from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/">How to manage Pulumi Secrets with 1Password</a></li>



<li><a href="https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/">Rookie mistakes I made with Pulumi dependency tracking</a></li>



<li><a href="https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/">Pulumi vs Terraform: honest retrospective after a full migration</a> (this one)</li>
</ul>
</div></div>



<h3 class="wp-block-heading">Migration duration</h3>



<p>
    This migration took me months to complete. I did not spend all my spare time
    on it, but it still dragged on because it was tedious and boring, and at
    some points even small progress required a significant investment of time.
</p>



<p>
    Migrating network share configurations and putting together the foundations
    for the migration was probably the most painful part.
</p>



<p>
    Preserving existing resources required careful manual imports and sometimes
    risky manual edits to the Pulumi state. It was not a clean import: LXC
    containers needed small tweaks and refreshes to be properly imported rather
    than recreated from scratch.
</p>



<p>
    Once the foundations were in place, the rest moved fairly quickly, though
    not without its own quirks. The first 20% of the migration consumed roughly
    80% of the total time. The remaining 80% took <em>another 80%</em>.
</p>



<h3 class="wp-block-heading">Missing foundations</h3>



<p>
    Pulumi falls short when it comes to file and template management. There is
    <strong>no built-in way to create files from templates</strong> or to load
    templates and substitute variables the way Terraform does. I ended up
    writing a custom template system based on
    <a href="https://handlebarsjs.com/">Handlebars</a> so I could keep templates
    in isolated files with proper syntax highlighting and linter support.
</p>



<p>
    Some Pulumi primitives also lack basic functionality.
    <code>RemoteFile</code> requires you to manually create parent directories
    if they are missing, and it does not support setting file permissions on the
    remote. Both limitations are understandable given how differently operating
    systems handle these things, but they are still annoying. I ended up writing
    custom classes that wrap <code>RemoteFile</code> just to automate all of
    this.
</p>



<p>
    <code>RemoteCommand</code> only accepts in-memory strings, not script files.
    So if you want to run a longer script, you either load it into memory and
    pass it directly, or you copy the script to the remote host, set its
    permissions, execute it with <code>RemoteCommand</code>, and then clean up
    afterwards. Annoying. I wrote a custom class to handle that workflow too.
</p>



<p>
    By the end I had a set of custom classes for the most common operations:
    creating remote files from Handlebars templates, deploying configuration
    files to paths that mirror the local <code>assets</code> folder structure,
    and creating executable files from either templates or static sources.
    Everything became much simpler, faster, and more pleasant to work with once
    these abstractions were in place.
</p>



<h3 class="wp-block-heading">But I&#8217;m overall happy</h3>



<p>
    Despite all the annoyances and custom code I had to write, I still prefer
    this over Terraform. Being able to import shared constants or write unit
    tests is great.
</p>



<p>
    I wrote my stack in TypeScript, which means I can also statically derive
    information that would simply be impossible in Terraform (like the IP
    addresses of each container or the domain names linked to them) even from
    files far from where those values are defined, just by leveraging TypeScript
    generics and compiler inference.
</p>



<p>
    Being able to structure files in whatever folder layout I prefer is also
    really nice, and my service definitions became much cleaner and easier to
    read thanks to splitting the code in a more composable way than Terraform
    ever allowed.
</p>



<p>
    Now that I have invested in these foundations, I will keep using Pulumi and
    build more things on top of it. That said, had I known the time and effort
    it would take to reach this point, I would have delayed the migration.
</p>



<p>
    There are real benefits to Pulumi over Terraform, but at the time I simply
    wanted to add another LXC container to my homelab without repeating too much
    boilerplate and prop drilling. All this effort was not worth it just for
    those small improvements.
</p>



<p>
    It is worth it, though, for the broader improvements to the entire homelab
    stack: the better development experience that comes from type-checking and
    inference, the powerful code reuse, the simpler modules and utilities.
</p>



<p>
    I also used this migration as an excuse to fix things that had been wrong in
    the original server configuration: setting up SMB and CIFS properly for
    network shares, unifying all ZSH configuration for Debian, migrating from
    <a href="https://ohmyz.sh/">Oh My ZSH</a> to
    <a href="https://ohmyposh.dev/">Oh My Posh</a>, and even automating the
    installation of services that were previously set up by hand.
</p>



<p>So, if you are starting from scratch, absolutely give Pulumi a try. If you have an existing stack and are considering a migration, double whatever time estimate you have, and if it still makes sense after that, go for it. </p>The post <a href="https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/">Pulumi vs Terraform: honest retrospective after a full migration</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Rookie mistakes I made with Pulumi dependency tracking</title>
		<link>https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/</link>
					<comments>https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/#respond</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Mon, 13 Apr 2026 09:27:42 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[pulumi]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15674</guid>

					<description><![CDATA[<p>Avoid common Pulumi dependency pitfalls: learn why chaining apply, overusing Input, and skipping the parent property break previews; and how to fix them.</p>
The post <a href="https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/">Rookie mistakes I made with Pulumi dependency tracking</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p>
    This post is part of my
    <a href="https://llu.is/category/projects/homelab/">series</a> on
    <a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">migrating my Homelab from Terraform to Pulumi</a>. In this article, I&#8217;ll walk through a few rookie mistakes I made when
    modelling dependencies in <a href="https://www.pulumi.com/">Pulumi</a>, why
    they caused problems, and how to avoid them.
</p>



<span id="more-15674"></span>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Other parts in this series:</strong></p>



<ul class="wp-block-list">
<li><a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">Why I am migrating my Homelab IaC from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/">Migrating Proxmox LXC containers from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/">How to manage Pulumi Secrets with 1Password</a></li>



<li><a href="https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/">Rookie mistakes I made with Pulumi dependency tracking</a> (this one)</li>



<li><a href="https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/">Pulumi vs Terraform: honest retrospective after a full migration</a></li>
</ul>
</div></div>



<h3 class="wp-block-heading">
    Chaining <code>apply</code> to create resources
</h3>



<p>
    I discovered <code>.apply(callback)</code> very early and started using it
    everywhere, long before I really understood what it does. Most Pulumi
    resources expose lazy values wrapped in some sort of <code>Input</code>. A
    very common beginner mistake is to try to <em>unwrap</em> those values by
    creating other resources inside <code>apply</code> chains like this:
</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> pulumi <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/pulumi"</span>;
<span class="hljs-keyword">import</span> { RandomPassword } <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/random"</span>;
<span class="hljs-keyword">import</span> { remote } <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/command"</span>;

<span class="hljs-keyword">const</span> password = <span class="hljs-keyword">new</span> RandomPassword(<span class="hljs-string">'password'</span>);

<span class="hljs-keyword">const</span> connection = {}; <span class="hljs-comment">// Omitted for simplicity</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> changePassword = pulumi
  .output(password.result)
  .apply(<span class="hljs-function">(<span class="hljs-params"><span class="hljs-params">password</span></span>) =&gt;</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> remote.Command(<span class="hljs-string">'change-password'</span>, {
      connection,
      create: <span class="hljs-string">`echo 'root:<span class="hljs-subst">${password}</span>' | chpasswd`</span>
    });
});</code></span></pre>


<p>
    Before Pulumi can apply a plan, it has to compute it. Pulumi does this by
    running in preview (dry-run) mode, which does not create, update, or delete
    any resources. Instead, it computes the resources that <em>would</em> exist
    and compares them against the current state. During preview, Pulumi will not
    run <code>apply</code> callbacks because those callbacks depend on provider
    side effects to resolve the lazy values.
</p>



<p>
    In the example above, during preview Pulumi correctly detects that it needs
    to create a new <code>RandomPassword</code>, but it never <em>sees</em> the
    <code>remote.Command</code> meant to change the password because the
    <code>apply</code> callback is never executed.
</p>



<p>
    This compounds quickly, especially when you use <code>apply</code> to manage
    dependencies for remote files or scripts. You might need to run
    <code>mkdir -p</code> to ensure the parent folder exists, then copy the
    file, then make it executable with <code>chmod</code>, and finally execute
    it. I ended up with deep chains of nested <code>apply</code> calls just to
    deploy scripts for restarting services or reloading configuration. That
    easily becomes four or five levels of nesting once you add container
    creation into the mix.
</p>



<p>
    The fix is straightforward: do not create resources inside
    <code>apply</code> callbacks. Instead, declare resources at the top level
    and use <code>apply</code> only to compute the inputs that those resources
    need.
</p>



<p>Here is the same example rewritten correctly:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> pulumi <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/pulumi"</span>;
<span class="hljs-keyword">import</span> { RandomPassword } <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/random"</span>;
<span class="hljs-keyword">import</span> { remote } <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/command"</span>;

<span class="hljs-keyword">const</span> password = <span class="hljs-keyword">new</span> RandomPassword(<span class="hljs-string">'password'</span>);

<span class="hljs-keyword">const</span> connection = {}; <span class="hljs-comment">// Omitted for simplicity</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> changePassword = <span class="hljs-keyword">new</span> remote
  .Command(<span class="hljs-string">'change-password'</span>, {
    connection,
    create: pulumi
      .output(password.result)
      .apply(<span class="hljs-function">(<span class="hljs-params"><span class="hljs-params">password</span></span>) =&gt;</span>
        <span class="hljs-string">`echo 'root:<span class="hljs-subst">${password}</span>' | chpasswd`</span>
      )
  });</code></span></pre>


<p>
    Now Pulumi can see both resources clearly, and it knows that
    <code>remote.Command</code> depends on the output of
    <code>RandomPassword</code>. It will create the
    <code>RandomPassword</code> first and then the <code>remote.Command</code>.
</p>



<p>
    This pattern matters not only for performance (Pulumi can parallelize work
    more effectively when dependencies are explicit and granular) but also for
    producing accurate previews.
</p>



<h4 class="wp-block-heading">My recommendations</h4>



<p>
    Never create resources inside <code>apply</code> callbacks. Use
    <code>apply</code> only to derive input values for resources that are
    declared at the top level.
</p>



<h3 class="wp-block-heading">Overusing <code>pulumi.Input</code></h3>



<p>
    Another rookie mistake I made was typing almost everything as a lazy value
    using <code>pulumi.Input</code>. This caused two kinds of problems:
</p>



<ul class="wp-block-list">
<li>
        It prevents you from using the value in places that need eager
        evaluation (for instance, in resource names, which must be known at
        preview time to detect duplicates and changes).
    </li>



<li>
        It breaks class prototype chains in a way that is only detected at
        runtime. This is fine if you pass around plain objects, but it can be
        quite tricky if you pass something more complex, like a
        <a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/" title="How to manage Pulumi Secrets with 1Password">1Password client</a>.
    </li>
</ul>



<p>
    For each value, it&#8217;s worth asking whether it is truly lazy at its source.
    One of my early mistakes was to build helpers around
    <code>remote.Command</code> and <code>remote.CopyToRemote</code> that
    treated the remote file path as a lazy value, even though the path was
    completely static and known before any code ran. That prevented me from
    using the path in resource names, forced me into extra
    <code>apply</code> chains, and made the preview diffs less useful.
</p>



<h4 class="wp-block-heading">My recommendations</h4>



<p>
    Use <code>pulumi.Input</code> only for values that genuinely depend on
    provider side effects or other lazy outputs. Keep static values as plain
    types so you can safely use them in resource names and other places that
    must be known during preview.
</p>



<h3 class="wp-block-heading">Not leaning on the <code>parent</code> property</h3>



<p>
    Sometimes we need to create explicit dependencies between resources because
    Pulumi cannot reliably infer them from the resource definitions alone.
</p>



<p>
    For example, when copying a file to a remote path, the copy will fail if the
    target directory does not already exist. Creating that directory is just a
    simple <code>mkdir -p</code> on the remote host, but Pulumi has no way to
    know that the directory must be created before the file is copied.
</p>



<p>
    We can solve this with <code>dependsOn</code>, <code>triggers</code>, or (
    usually the better option) the <code>parent</code> property. Each resource
    can have a single <code>parent</code>. Pulumi will then create the child
    after the parent, delete the child before the parent, and recreate the child
    when the parent is replaced. That behaviour is extremely helpful when you
    want to ensure, for example, that all files are recopied whenever a
    container is replaced.
</p>



<p>Consider the following code:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">import</span> { remote } <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/command"</span>;
<span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> pulumi <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/pulumi"</span>;
<span class="hljs-keyword">import</span> { dirname } <span class="hljs-keyword">from</span> <span class="hljs-string">"node:path"</span>;

<span class="hljs-keyword">const</span> remotePath = <span class="hljs-string">"/tmp/my-folder/my-file"</span>;
<span class="hljs-keyword">const</span> connection = {}; <span class="hljs-comment">// Omitted for simplicity</span>

<span class="hljs-keyword">const</span> createContainerFolder = <span class="hljs-keyword">new</span> remote.Command(<span class="hljs-string">'create-container-folder'</span>, {
	connection,
	create: <span class="hljs-string">`mkdir -p <span class="hljs-subst">${dirname(remotePath)}</span>`</span>,
})

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> remoteFile = <span class="hljs-keyword">new</span> remote.CopyToRemote(
	<span class="hljs-string">`my-file`</span>,
	{
		connection,
		source: <span class="hljs-keyword">new</span> pulumi.asset.StringAsset(<span class="hljs-string">"Hello, world!"</span>),
		remotePath,
	},
	{ parent: createContainerFolder },
);</code></span></pre>


<p>
    This integrates nicely with Pulumi: the remote file is visible in preview
    mode, is copied only after the container folder is created, and is removed
    before the command that creates the folder is deleted. The preview also
    shows the remote file nested under that resource, which makes the dependency
    obvious when you inspect the plan.
</p>



<p>
    However, this approach has some limitations. Each resource can have at most
    a single parent, but there are many situations where a resource depends on
    multiple others. There is also a subtler issue: changing a resource&#8217;s parent
    modifies its URN, which means Pulumi cannot update it in-place — it must
    destroy and recreate it instead. That is acceptable for some resources, but
    I ran into many situations where I did not want to risk losing important
    data by destroying the original.
</p>



<h4 class="wp-block-heading">My recommendations</h4>



<p>
    Prefer <code>parent</code> over <code>dependsOn</code> or
    <code>triggers</code> when you want to express a clear, structural
    dependency between resources and have that relationship reflected in
    previews and replacements. Just be aware that changing a resource&#8217;s parent
    will trigger a destroy-and-recreate cycle rather than an in-place update.
</p>The post <a href="https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/">Rookie mistakes I made with Pulumi dependency tracking</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>How to manage Pulumi Secrets with 1Password</title>
		<link>https://llu.is/how-to-manage-pulumi-secrets-with-1password/</link>
					<comments>https://llu.is/how-to-manage-pulumi-secrets-with-1password/#respond</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Mon, 16 Mar 2026 11:19:39 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[1Password]]></category>
		<category><![CDATA[pulumi]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15668</guid>

					<description><![CDATA[<p>How I manage homelab secrets and SSH access with Pulumi and 1Password, avoiding 1Password rate limits and speeding up plans with caching.</p>
The post <a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/">How to manage Pulumi Secrets with 1Password</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p>This post is part of my <a href="https://llu.is/category/projects/homelab/">series</a> on <a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">migrating my homelab IaC from Terraform to Pulumi</a>. In this article, I explain how I manage secrets in my <a href="https://www.pulumi.com/">Pulumi</a> setup using 1Password and what I learned along the way.</p>



<span id="more-15668"></span>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Other parts in this series:</strong></p>



<ul class="wp-block-list">
<li><a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">Why I am migrating my Homelab IaC from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/">Migrating Proxmox LXC containers from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/">How to manage Pulumi Secrets with 1Password</a> (this one)</li>



<li><a href="https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/">Rookie mistakes I made with Pulumi dependency tracking</a></li>



<li><a href="https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/">Pulumi vs Terraform: honest retrospective after a full migration</a></li>
</ul>
</div></div>



<h3 class="wp-block-heading">Why 1Password</h3>



<p>One of my goals for the Pulumi migration was to automate the setup of SSH connections to my containers. I use 1Password to handle all my passwords, and SSH felt like a convenient way to avoid manually copying the private SSH keys that Pulumi creates into my <code>.ssh</code> folder.</p>



<p>I also had plenty of secrets defined in a separate secrets file that I wanted to move into 1Password, so that there would be a single source of truth for all API keys, usernames, passwords, and tokens in both my homelab and my browser sessions.</p>



<p>However, I found the Pulumi 1Password provider quite limiting for this setup:</p>



<ul class="wp-block-list">
<li>It only supports reading items, and I want to create new items when I add new containers to my homelab.</li>



<li>Access is rate limited by the service account restrictions. There is a limit of 1000 read operations per hour and 100 write operations per hour for 1Password and 1Password Families accounts per service account token. Those limits also apply on a daily basis to the 1Password account. My homelab vault already has around 50 secrets, which effectively limits me to about 20 executions per day. That is not enough on days when I&#8217;m actively experimenting.</li>
</ul>



<p>So I decided to build my own 1Password client. There are some important details to take into account if you follow this approach. I ran into a number of issues and eventually managed to sort them out.</p>



<h3 class="wp-block-heading">Avoiding rate limits</h3>



<p>Service account rate limits are too restrictive for my use case, so I am effectively forced to use the desktop app authentication mechanism to avoid them.</p>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>It is possible to set up a <a href="https://developer.1password.com/docs/connect/get-started/">Connect Server</a> that does not face the same rate limits, but it requires spinning up two Docker containers, and I found that too overcomplicated for my simple needs.</p>
</div></div>



<p>When I started my Pulumi migration, the latest version of the <a href="https://github.com/1Password/onepassword-sdk-js">1Password JS SDK</a> available was v0.3.1, which only supported service accounts as an authentication mechanism. I decided to wrap the <a href="https://developer.1password.com/docs/cli/">1Password CLI</a> in a TypeScript class so I could use the desktop app as the authentication mechanism and avoid the service account rate limits.</p>



<div class="wp-block-group is-style-tip"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>Later on, version v0.4.0 of the <a href="https://github.com/1Password/onepassword-sdk-js">1Password JS SDK</a> added support for using the desktop app as an authentication mechanism, but by then I already had all those utilities in place.</p>



<p>If I were to start from scratch again, I would stick to the JS SDK using the desktop app as the authentication mechanism instead.</p>
</div></div>



<h3 class="wp-block-heading">CLI client issues</h3>



<p>Using the CLI directly introduced a few practical issues.</p>



<p>First, dollar sign (<code>$</code>) characters were incorrectly double-escaped. This led to my client trying to overwrite, on each execution, existing passwords that contained dollar signs.</p>



<p>The plans still worked correctly, but this had a noticeable performance impact. I could not figure out how to properly fix this, so I decided to remove the dollar sign from the set of special characters allowed to be used by the <a href="https://www.pulumi.com/registry/packages/random/api-docs/randompassword/">Pulumi <code>random.randomPassword</code> provider</a>.</p>



<p>Second, sequential access was too slow, but simply making access parallel caused 1Password to ask multiple times, concurrently, for permissions to access the vault.</p>



<p>To fix this, I forced the first access to a secret to be sequential, with the rest of the accesses running in parallel. This way, after I allow access once, the remaining accesses do not need to ask for permissions again.</p>



<h3 class="wp-block-heading">SSH Keys</h3>



<p>One of the things I wanted to achieve was a better integration with my SSH client, so that whenever I apply a Pulumi plan I get the SSH keys to connect to the container in 1Password, and I can SSH into it directly from my terminal without manually copying private keys all over the place.</p>



<p>However, <a href="https://developer.1password.com/docs/cli/ssh-keys/#generate-an-ssh-key">there is no way to import SSH keys to 1Password with the CLI</a>, so I had to delegate the actual SSH key generation to 1Password and then copy it over to the remote container.</p>



<h3 class="wp-block-heading">Localized labels</h3>



<p>My development machine is set up in Spanish, so when I create a login item in 1Password the <code>username</code> field is labeled <code>nombre de usuario</code> and the <code>password</code> field is labeled <code>contraseña</code>. This also affects the <code>credential</code> field on API key items, which is labeled <code>credencial</code>.</p>



<p>I normally don&#8217;t pay much attention to those labels, but they are important when requesting items from 1Password, because those field labels are what the CLI client will return. To support multiple languages I had to define a list of alternative labels for each kind of field I wanted to request, and then make my client return the first matching field.</p>



<h3 class="wp-block-heading">Caching items</h3>



<p>My custom 1Password wrapper was the slowest part of my Pulumi codebase, taking about 80% of the runtime (100 seconds out of 120-second executions). I managed to reduce this to a negligible duration with caching and parallelization:</p>



<ul class="wp-block-list">
<li><strong>Keep a cache for all items that you read.</strong> You probably don&#8217;t need to worry about stale items because this will run in less than a minute, and if you update any item in this period you can just re-run the plan later. Don&#8217;t forget to invalidate the cache entry if you update the item as part of the plan.</li>



<li><strong>Parallelize as much work as possible.</strong> Describe a strong dependency tree for your resources so that you only try to read 1Password items that have already been created by a parent resource. That way you will be able to run most 1Password interactions in parallel.</li>



<li>Minimize cold starts and permission requests by <strong>gating all concurrent requests behind a sequential gate</strong>. This way you will get asked for permission only once, and after this initial request everything will run in parallel. Otherwise, every single request will be asking for permissions and hitting cold internal caches as they all run in parallel.</li>
</ul>The post <a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/">How to manage Pulumi Secrets with 1Password</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/how-to-manage-pulumi-secrets-with-1password/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Migrating Proxmox LXC containers from Terraform to Pulumi</title>
		<link>https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/</link>
					<comments>https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/#respond</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Sat, 14 Feb 2026 13:58:30 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[containers]]></category>
		<category><![CDATA[lxc]]></category>
		<category><![CDATA[proxmox]]></category>
		<category><![CDATA[pulumi]]></category>
		<category><![CDATA[terraform]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15672</guid>

					<description><![CDATA[<p>A practical guide to moving Proxmox LXC containers from Terraform to Pulumi, covering imports, state edits, and common pitfalls.</p>
The post <a href="https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/">Migrating Proxmox LXC containers from Terraform to Pulumi</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p>This post is part of my <a href="https://llu.is/category/projects/homelab/" title="">series</a> on <a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">migrating my Homelab from Terraform to Pulumi</a>. In this article I walk through how I&#8217;m migrating my LXC containers and how I imported them from <a href="https://developer.hashicorp.com/terraform">Terraform</a> without any data loss.</p>



<span id="more-15672"></span>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Other parts in this series:</strong></p>



<ul class="wp-block-list">
<li><a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">Why I am migrating my Homelab IaC from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/">Migrating Proxmox LXC containers from Terraform to Pulumi</a> (this one)</li>



<li><a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/">How to manage Pulumi Secrets with 1Password</a></li>



<li><a href="https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/">Rookie mistakes I made with Pulumi dependency tracking</a></li>



<li><a href="https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/">Pulumi vs Terraform: honest retrospective after a full migration</a></li>
</ul>
</div></div>



<h3 class="wp-block-heading">My LCX containers</h3>



<p>I run several containers in my homelab, mainly <strong>NixOS</strong> and <strong>Debian</strong> LXC containers. Each container has a small volume where the operating system, applications, and configuration are stored. Everything there is created automatically through Infrastructure as Code.</p>



<p>Some containers also have additional storage to persist application data. For example, I have a Debian LXC container running <a href="https://signoz.io/" title="">SigNoz</a>, and I keep its ClickHouse database and logs on a separate volume.</p>



<p>Others share data across containers. To accomplish this, I run a dedicated container providing an SMB server, while the rest connect to it as regular SMB clients.</p>



<p>Some containers can be safely recreated from scratch, but a few of them contain data I want to keep. That made migrating existing containers an important requirement (rather than deleting and recreating them).</p>



<h3 class="wp-block-heading">Importing existing containers</h3>



<div class="wp-block-group is-style-warning"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>To import Proxmox resources with Pulumi, you first need to define three environment variables:</p>



<ul class="wp-block-list">
<li><code>PROXMOX_VE_ENDPOINT</code></li>



<li><code>PROXMOX_VE_PASSWORD</code></li>



<li><code>PROXMOX_VE_USERNAME</code></li>
</ul>



<p>If you connect through an insecure method (self-signed certificate or non-HTTPS), you may also need:</p>



<ul class="wp-block-list">
<li> <code>PROXMOX_VE_INSECURE</code></li>
</ul>
</div></div>



<p>If a container is simple and has no complex child resources, you can import it directly:</p>


<pre class="wp-block-code"><span><code class="hljs language-shell">pulumi import proxmoxve:CT/container:Container &lt;NAME&gt; proxmox/&lt;PROXMOX_ID&gt;</code></span></pre>


<p>However, more complex containers may require additional resources like copying local files or running remote scripts. In these cases I found it easier to let Pulumi create a temporary new container (with a different IP address), and then manually update the Pulumi state to point it to the original container. Afterward, you can delete the temporary container in Proxmox to keep things clear.</p>



<h4 class="wp-block-heading">Manually editing Pulumi state</h4>



<p>Editing the Pulumi state manually is dangerous, but it can significantly simplify migrations.</p>



<p>Export the current stack state:</p>


<pre class="wp-block-code"><span><code class="hljs language-shell">pulumi stack export &gt; state.json</code></span></pre>


<p>By default, secrets are encrypted. To export plaintext secrets pass the <code>--show-secrets</code> option:</p>


<pre class="wp-block-code"><span><code class="hljs language-shell">pulumi stack export --show-secrets &gt; state.json</code></span></pre>


<p>You can re-import the state with (it doesn&#8217;t matter if your state has cyphered or plaintext secrets):</p>


<pre class="wp-block-code"><span><code class="hljs language-arduino">pulumi <span class="hljs-built_in">stack</span> <span class="hljs-keyword">import</span> --file state.json</code></span></pre>


<p>Pulumi can also refresh the state from real resources (only for supported resources):</p>


<pre class="wp-block-code"><span><code class="hljs">pulumi refresh</code></span></pre>


<h4 class="wp-block-heading">Fixing password and SSH keys differences</h4>



<p>If you use <code>RandomPassword</code> to generate the root password, you must import that password <strong>before</strong> creating the new container and modifying the state. Otherwise, updating the password resource later is harder due to how Pulumi handles secrets.</p>



<p>You can import the <code>RandomPassword</code> resource like this:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript">pulumi <span class="hljs-keyword">import</span> random:index/randomPassword:RandomPassword &lt;NAME&gt; <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">CLEARTEXT_PASSWORD</span>&gt;</span></span></code></span></pre>


<p>An alternative is to define the password as a Pulumi secret, but I prefer not forcing imported containers to keep their Terraform-generated password forever. If I recreate the infrastructure from scratch, I want fresh passwords.</p>



<h4 class="wp-block-heading">The advanced importing process</h4>



<p>Combining all the steps, the import process for non-trivial containers looks like this:</p>



<ol class="wp-block-list">
<li>Update your Pulumi code so it <strong>creates a new container</strong> without conflicts (e.g., temporarily change the container&#8217;s IP).</li>



<li>Run Pulumi and let it create the new container and all its child resources.</li>



<li>Export the state: <code>pulumi stack export &gt; state.json</code></li>



<li>Edit the state file: locate the new container resource and replace its ID with the ID of the original container.
<ul class="wp-block-list">
<li>The ID appears multiple times (at least three times within the container resource and possibly more if other resources reference it).</li>



<li>Update <strong>every occurrence</strong> or Pulumi may detect differences and attempt to recreate or modify the container.</li>
</ul>
</li>



<li>Import the updated state: <code>pulumi stack import --file state.json</code></li>



<li>Refresh the state: <code>pulumi refresh</code> </li>



<li>Update your Pulumi code back to the intended configuration (undoing step 1).</li>



<li>Run <code>pulumi preview</code> to confirm Pulumi detects no differences.</li>
</ol>



<h4 class="wp-block-heading">Issues I run into</h4>



<p>Some problems are expected, especially around remote connections.</p>



<p>At one point I manually edited the state but didn&#8217;t update every occurrence of a value. Pulumi then tried to run a command in the container but couldn&#8217;t connect, and I received a vague <code>unexpected EOF</code> error. I eventually fixed it by deleting the affected <code>remote::Command</code> resource from the state and letting Pulumi recreate it. Since my scripts are idempotent, rerunning them wasn’t an issue.</p>



<div class="wp-block-group is-style-tip"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>Make your install scripts idempotent. They might end up running more often than you expect.</p>
</div></div>



<p>Another major issue involved <strong>remote files</strong>. Pulumi needs to know the file contents to compute hashes and determine identifiers, even during previews. Some of my remote files are templates that depend on Pulumi outputs, including <code>RandomPassword</code>, which isn’t available during previews. I created a custom resource to delay asset creation until outputs resolved, but this led to errors like:</p>


<pre class="wp-block-code"><span><code class="hljs language-swift">property asset value {&lt;<span class="hljs-literal">nil</span>&gt;} has a problem: either asset or archive must be <span class="hljs-keyword">set</span></code></span></pre>


<h3 class="wp-block-heading">A final thought</h3>



<p>This migration is taking longer than I expected. I like Pulumi&#8217;s programming model overall but there are some rough edges. The migration would be significantly easier if:</p>



<ul class="wp-block-list">
<li><strong>Pulumi</strong> supported creating remote files from local templates plus input variables, similar to Terraform&#8217;s <code>templatefile</code>.</li>



<li><strong>Proxmox</strong> allowed creating <strong>detached volumes</strong>, so containers could be recreated without touching storage.</li>
</ul>



<p>Maybe these features exist and I just haven&#8217;t found them yet. If I discover better solutions, I’ll write another post about it.</p>The post <a href="https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/">Migrating Proxmox LXC containers from Terraform to Pulumi</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>New React 18 and 19 features for promise handling</title>
		<link>https://llu.is/new-react-18-and-19-features-for-promise-handling/</link>
					<comments>https://llu.is/new-react-18-and-19-features-for-promise-handling/#respond</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Wed, 04 Feb 2026 17:16:01 +0000</pubDate>
				<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[React]]></category>
		<category><![CDATA[async]]></category>
		<category><![CDATA[hooks]]></category>
		<category><![CDATA[promise]]></category>
		<category><![CDATA[react 18]]></category>
		<category><![CDATA[react 19]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15678</guid>

					<description><![CDATA[<p>A walk-through of React 18/19 async toolkit: use, Suspense, useDeferredValue, useTransition, and useOptimistic for promises and loading UIs</p>
The post <a href="https://llu.is/new-react-18-and-19-features-for-promise-handling/">New React 18 and 19 features for promise handling</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p><code>use</code>, <code>useOptimistic</code> (React 19), <code>useTransition</code>, <code>useDeferredValue</code>, <code>Transition</code>, <code>Actions</code> (React 18), and <code>&lt;Suspense&gt;</code> form a powerful toolkit for managing asynchrony in React. This post walks through how these APIs work in practice and how they fit together, especially useful if you skipped React 18 or 19.</p>



<span id="more-15678"></span>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>The official React docs explain each API well in isolation; the trick is seeing how the pieces connect.</p>
</div></div>



<div class="wp-block-group is-style-tip"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>If you only want a fast reference, <a href="#comparison-recap" title="Jump right to the end">jump to the comparison section at the end</a>.</p>
</div></div>



<h3 class="wp-block-heading">Basic asynchronous work</h3>



<p>We start with a simple digital clock that updates every second, a baseline to confirm the UI stays interactive as we add asynchronous work.</p>



<div class="wp-block-group"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<div data-livecodes-id="insbue6thne"></div>
</div></div>



<p>Next we add an artificial &#8220;heavy&#8221; async task: each has an ID and start/complete timestamps, and we delay it a few seconds to show different loading patterns. Clicking &#8220;<em>Do heavy work!</em>&#8221; starts a task; the result appears when it finishes.</p>



<div class="wp-block-group is-style-warning"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>There is no cancellation in this demo.</strong> Repeated clicks run each task independently and update the UI when it resolves. We control timing here, so <strong>race conditions</strong> don&#8217;t show up. In real apps you&#8217;d need to handle out-of-order results.</p>
</div></div>



<p>The code is a bit long, but most of it is just scaffolding to make the demo clear and interactive.</p>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>The most important piece is the <code>cachedWorks</code> map.</p>



<p>We could remove it and create a new promise on every <code>doHeavyWork</code> call with the same ID but then we&#8217;d hit a problem. React cannot keep updating state with a new promise each render: each render would produce a different promise instance and trigger infinite re-renders.</p>



<p>In real apps you&#8217;d usually use a data library that returns stable promises for a given set of parameters. Here, <code>cachedWorks</code> does the same: it keeps promise identity stable.</p>



<p>Feel free to remove the cache and try the examples. Some results may surprise you.</p>
</div></div>



<div class="wp-block-group"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<div data-livecodes-id="3y2g79gmehq"></div>
</div></div>



<h3 class="wp-block-heading">Refactoring with <code>Suspense</code> and <code>use</code></h3>



<p>We can improve the UI and simplify the code with React&#8217;s <em>newer</em> APIs. A first step is to replace the ad-hoc promise handling in <code>HeavyWorkSummary</code> with the <code>use</code> hook and <code>Suspense</code>.</p>



<p>The <code>use</code> hook lets you read a promise as if it were a synchronous value. For pending promises it throws that same promise to the caller. React treats this &#8220;throwing a pending promise until the component has finished loading&#8221; as <code>suspending</code>.</p>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Rejected promises and other errors</strong> are handled by <a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary">Error Boundaries</a>; this post does not cover them.</p>
</div></div>



<p>If that thrown value isn&#8217;t caught, rendering breaks. React&#8217;s intended pattern is to wrap any component that uses <code>use</code> inside a <code>Suspense</code> boundary. <code>Suspense</code> renders its <code>fallback</code> until its children stop <code>suspending</code>, then it renders the children.</p>



<p>We wrap <code>HeavyWorkSummary</code> in <code>Suspense</code>, replace the ad-hoc promise handling with <code>use</code>, and move the loading UI into the boundary&#8217;s <code>fallback</code>.</p>



<div class="wp-block-group"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<div data-livecodes-id="s9tebbxwyg3"></div>
</div></div>



<div class="wp-block-group is-style-tip"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>As a bonus, the <strong>race condition disappears</strong>: <code>use</code> does not return stale values when the source promise changes.</p>
</div></div>



<h3 class="wp-block-heading">Keeping stale data with <code>useDeferredValue</code> and background rendering</h3>



<p>The UI works but is not ideal: as soon as we click the button we lose the last completed result. Often we want to keep showing that (with a hint that it is outdated) until new data is ready. Imagine a list that loads more data on demand: showing stale data while loading is usually better than showing nothing until the new data is available.</p>



<p>That&#8217;s what <code>useDeferredValue</code> is for.</p>



<p>On first call, <code>useDeferredValue</code> returns whatever you pass in. When the component re-renders because that parameter changed, <code>useDeferredValue</code> still returns the previous value, but it is tightly integrated with <code>Suspense</code> and will also trigger a <strong>background</strong> render, and this time it will return the <strong>new</strong> value.</p>



<p><strong>Background rendering</strong> only makes sense with <code>Suspense</code>. If that background render causes a child to <code>suspend</code>, React does not commit the new DOM tree (which would show the <code>fallback</code>). Instead it keeps the previous one. So the DOM under the <code>Suspense</code> boundary does not change until the suspending children have finished loading. The user keeps seeing the old content until the new content is ready.</p>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>Recall: a component is <code>suspending</code> when it throws a pending promise instead of returning UI. So a background render runs the render code but if something suspends React does not update the DOM tree but keeps the previous UI until the new state is fully loaded.</p>
</div></div>



<p>To see this in action, we add <code>useDeferredValue</code> to the demo and walk through the behaviour.</p>



<h4 class="wp-block-heading"><code>useDeferredValue</code> demo</h4>



<p>In <code>HeavyWorkController</code> we add <code>deferredHeavyWorkId = useDeferredValue(heavyWorkId)</code> and use <code>deferredHeavyWorkId</code> instead of <code>heavyWorkId</code> for the content that can suspend.</p>



<div class="wp-block-group"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<div data-livecodes-id="uzau5fywejc"></div>
</div></div>



<p>Here is the same flow as a sequence of renders and user actions:</p>



<ol class="wp-block-list">
<li>First render: ID <code>0</code>, deferred ID <code>0</code>. No loading state for ID <code>0</code>, so no <code>Suspense</code> rendered yet.
<ul class="wp-block-list">
<li>User clicks the &#8220;Do heavy work!&#8221; button</li>
</ul>
</li>



<li>Second render: ID <code>1</code>, deferred ID still <code>0</code>. Children use deferred ID, so same result and no DOM update.
<ul class="wp-block-list">
<li><code>useDeferredValue</code> will trigger now a background render, no user action required</li>
</ul>
</li>



<li>Third render (background): deferred ID <code>1</code>. React renders the <code>Suspense</code> boundary; <code>HeavyWorkSummary</code> suspends for ID <code>1</code>. First time for this boundary, so no previous DOM element exists. React updates the DOM, showing the <code>fallback</code> (<code>HeavyWorkLoadingIndicator</code>). Second DOM update.
<ul class="wp-block-list">
<li>Let&#8217;s assume in this example the user waits until it finishes loading, we will see what happens if they click in a later example</li>
</ul>
</li>



<li>Promise for ID <code>1</code> settles. <code>HeavyWorkSummary</code> stops <code>suspending</code> so <code>Suspense</code> shows it instead of <code>fallback</code>. Background render done, React updates the DOM. Fourth render, third DOM update.
<ul class="wp-block-list">
<li>Now imagine user clicks the &#8220;Do heavy work!&#8221; button again</li>
</ul>
</li>



<li>Fifth render: ID <code>2</code>, deferred ID still <code>1</code>. Same UI, no DOM update.
<ul class="wp-block-list">
<li><code>useDeferredValue</code> triggers a background Render</li>
</ul>
</li>



<li>Sixth render (background): deferred ID <code>2</code>. <code>HeavyWorkSummary</code> for ID <code>2</code> suspends (no settled promise yet). <code>Suspense</code> renders its <code>fallback</code> because <code>HeavyWorkSummary</code> is <code>suspending</code> so React does <strong>not</strong> update the DOM but keeps the previous boundary content (result for ID <code>1</code>).
<ul class="wp-block-list">
<li>Let&#8217;s assume user waits for a bit, we will consider what happens if user clicks in this moment in a different walk through afterwards</li>
</ul>
</li>



<li>Promise for ID <code>2</code> settles. <code>HeavyWorkSummary</code> stops <code>suspending</code>; React updates the DOM with the new result. Seventh render, fourth DOM update. Summary of DOM updates so far:
<ul class="wp-block-list">
<li>Initial render with heavy work id 0</li>



<li>Loading status for heavy work id 1</li>



<li>Resulting status for heavy work id 1</li>



<li>Resulting status for heavy work id 2</li>
</ul>
</li>
</ol>



<p>A few edge cases are worth spelling out.</p>



<h4 class="wp-block-heading">User clicks again before the first load finishes</h4>



<ol class="wp-block-list">
<li>First render: ID <code>0</code>, deferred ID <code>0</code>, no <code>Suspense</code> rendered yet.
<ul class="wp-block-list">
<li>User clicks &#8220;Do heavy work!&#8221;</li>
</ul>
</li>



<li>Second render: ID <code>1</code>, deferred ID <code>0</code>. Same result, no DOM update.
<ul class="wp-block-list">
<li><code>useDeferredValue</code> triggers a background render.</li>
</ul>
</li>



<li>Third render (background): deferred ID <code>1</code>. <code>HeavyWorkSummary</code> <code>suspends</code> for ID <code>1</code>; first time for this <code>Suspense</code> boundary, so React shows <code>HeavyWorkLoadingIndicator</code>. Second DOM update.
<ul class="wp-block-list">
<li>User clicks &#8220;Do heavy work!&#8221; again before load finishes.</li>
</ul>
</li>



<li>Fourth render: ID <code>2</code>, deferred ID <code>1</code>. Same as second render so no DOM changes.
<ul class="wp-block-list">
<li><code>useDeferredValue</code> triggers a background render.</li>
</ul>
</li>



<li>Fifth render (background): deferred ID <code>2</code>. <code>HeavyWorkSummary</code> for ID <code>2</code> gets a new promise and <code>suspends</code>; <code>use</code> ignores the previous promise. Boundary still shows <code>fallback</code>, so React does not update the DOM.
<ul class="wp-block-list">
<li>If the user waits for load to finish we get the new result; if they click again we repeat from step 4 until they wait for a promise to settle.</li>
</ul>
</li>



<li>Promise for ID <code>2</code> settles. <code>HeavyWorkSummary</code> stops suspending; React updates the DOM for ID <code>2</code>. Sixth render, third DOM update.</li>
</ol>



<h4 class="wp-block-heading">User clicks again while some work is loaded but the latest is still loading</h4>



<p>Same idea as above. Foreground render: ID <code>X+1</code>, deferred ID <code>X</code>: same output so no DOM update. <code>useDeferredValue</code> then triggers a background render with deferred ID <code>X+1</code>. <code>HeavyWorkSummary</code> for <code>X+1</code> gets a new promise and <code>suspends</code>; <code>use</code> drops the previous promise. The boundary keeps showing the old content until the new promise settles, so React does not update the DOM.</p>



<h4 class="wp-block-heading">Loading hints</h4>



<p>Our UI is far from ideal: users have no signal that work is in progress until it finishes. We should show a loading indicator while still keeping the stale data on screen. <strong>When the deferred ID and the current ID differ, we are in the foreground render</strong> just before the background one, so we can render <code>HeavyWorkLoadingIndicator</code> with the new ID and the user will see it immediately.</p>



<div class="wp-block-group"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<div data-livecodes-id="43fxa8xfxhu"></div>
</div></div>



<p>If the user clicks several times before load completes, the loading indicator updates to the latest requested ID. Pretty neat!</p>



<h3 class="wp-block-heading">Manual background rendering with <code>useTransition</code></h3>



<p><code>useDeferredValue</code> keeps stale data on screen automatically. When you need to control <em>when</em> a background render starts, use <code>useTransition</code>. It returns <code>isPending</code> (<code>true</code> while a <code>Transition</code> is in progress) and <code>startTransition</code>, which you call with a function (React calls this function <code>Action</code>) which may be async.</p>



<p>When you call <code>startTransition</code>, React first re-renders with <code>isPending === true</code>, starting the <code>Transition</code>, and runs the <code>Action</code>. It does not update the DOM again until the <code>Action</code> has finished and no children are <code>suspending</code>. Then it re-renders with <code>isPending === false</code> and the new state, finishing the <code>Transition</code>.</p>



<div class="wp-block-group is-style-warning"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>Due to a limitation in React 19, state updates that happen after <code>await</code> inside an <code>Action</code> must be wrapped in their own <code>startTransition</code> call.</p>
</div></div>



<p>The example below swaps <code>useDeferredValue</code> for manual <code>startTransition</code>.</p>



<div class="wp-block-group"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<div data-livecodes-id="yui796x4tnk"></div>
</div></div>



<p>The loading indicator now shows the wrong ID, because React does not re-render until the <code>Transition</code> finishes. <code>useOptimistic</code> fixes that.</p>



<h3 class="wp-block-heading">Immediate UI updates with <code>useOptimistic</code></h3>



<p>We can start transitions manually and keep the previous UI until async work finishes, and we can show a loading state when there is no prior data. But once a <code>Transition</code> has started, the UI does not update again until it ends so there is no progress or intermediate feedback.</p>



<p><code>useOptimistic</code> lets you apply state updates immediately from inside an <code>Action</code>, so the UI can reflect in-progress work (e.g. a progress bar for a multi-step operation). The name suggests &#8220;<em>optimistic UI</em>&#8220;, but it is really about immediate updates from async code.</p>



<p>In our demo we fix the loading indicator by adding optimistic state for the pending heavy-work ID: we update it inside the <code>Action</code> so the user sees the right ID right away, and it is updated again when the transition completes.</p>



<div class="wp-block-group"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<div data-livecodes-id="8bn3q9h5ndw"></div>
</div></div>



<p>Here we could have used other patterns, but in real apps <code>useOptimistic</code> is useful for multi-step feedback or any UI that should update immediately even when the underlying work is async and ongoing.</p>



<script src="https://cdn.jsdelivr.net/npm/livecodes@0.12.0/livecodes.umd.js"></script>
<script>
  const createPlayground = (id) => {
    const options = {
      "appUrl": "https://v47.livecodes.io/",
      "config": {
        "tools": {
          "enabled": "all",
          "status": "closed"
        }
      },
      "import": `id/${id}`
    };
    livecodes.createPlayground(`[data-livecodes-id="${id}"]`, options);
  };

  document.querySelectorAll('#post-15678 div[data-livecodes-id]').forEach((n) => {
    createPlayground(n.attributes['data-livecodes-id'].value);
  });
</script>



<h3 class="wp-block-heading" id="comparison-recap">Comparison and recap</h3>



<p>For a quick reference, the APIs are summarised below. The demos above show each one in context; the <a href="https://react.dev">React docs</a> explain each API in detail, though the big picture is easier to see when they are used together.</p>



<ul class="wp-block-list">
<li><code><a href="https://react.dev/reference/react/use">use</a></code> (introduced in React 19)</li>



<li><code><a href="https://react.dev/reference/react/useOptimistic">useOptimistic</a></code> (introduced in React 19)</li>



<li><code><a href="https://react.dev/reference/react/useTransition" title="">useTransition</a></code>, <code>Transition</code>, <code>Actions</code> (introduced in React 18)</li>



<li><code><a href="https://react.dev/reference/react/useDeferredValue">useDeferredValue</a></code> (introduced in React 18)</li>



<li><code>&lt;<a href="https://react.dev/reference/react/Suspense" title="">Suspense</a>&gt;</code> </li>
</ul>



<h4 class="wp-block-heading"><code>&lt;Suspense&gt;</code> </h4>



<p>Shows its <code>fallback</code> while any child is <code>suspending</code> (throwing a pending promise).</p>



<h4 class="wp-block-heading"><code>suspending</code></h4>



<p>A component is <code>suspending</code> when it throws a pending promise instead of returning UI.</p>



<h4 class="wp-block-heading"><code>useDeferredValue</code></h4>



<p>Works with <code>&lt;Suspense&gt;</code>: when the value changes it triggers a background render and only updates the DOM when children stop <code>suspending</code>. Use it to keep stale data on screen while new data loads.</p>



<h4 class="wp-block-heading"><code>useTransition</code></h4>



<p>Lets you start a <code>Transition</code> manually. State updates inside the transition do not cause a DOM update until the <code>Transition</code> finishes, so the previous UI stays visible.</p>



<h4 class="wp-block-heading" id="transition-summary"><code>Transition</code></h4>



<p>Started via the <code>startTransition</code> function returned by <code>useTransition</code>. Batches state updates: the component that started the <code>Transition</code> does not re-render until the transition finishes (all awaited promises and <code>suspending</code> children are done).</p>



<p><strong><code>Action</code></strong>: the function you pass to <code>startTransition</code>. It may be sync or async.</p>



<p><code>Action</code></p>



<p>See <a href="#transition-summary"><code>Transition</code></a></p>



<h4 class="wp-block-heading"><code>use</code></h4>



<p>Reads a promise or context; if the promise is pending, the component <code>suspends</code>. Lets you use promises directly under <code>Suspense</code> boundaries.</p>The post <a href="https://llu.is/new-react-18-and-19-features-for-promise-handling/">New React 18 and 19 features for promise handling</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/new-react-18-and-19-features-for-promise-handling/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Migrating OVH DNS records from Terraform to Pulumi</title>
		<link>https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/</link>
					<comments>https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/#respond</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Thu, 08 Jan 2026 18:49:10 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[dns]]></category>
		<category><![CDATA[ovh]]></category>
		<category><![CDATA[pulumi]]></category>
		<category><![CDATA[terraform]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15651</guid>

					<description><![CDATA[<p>Learn how to manage OVH DNS records in Pulumi and import existing Terraform resources using bulk or one-by-one imports</p>
The post <a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p>This post is part of my <a href="https://llu.is/category/projects/homelab/" title="">series</a> on <a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">migrating my Homelab from Terraform to Pulumi</a>. Here, I’ll walk through how I manage DNS records in <a href="https://www.pulumi.com/">Pulumi</a> and how I imported them from <a href="https://developer.hashicorp.com/terraform">Terraform</a> so the migration can be fully automated.</p>



<span id="more-15651"></span>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Other parts in this series:</strong></p>



<ul class="wp-block-list">
<li><a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">Why I am migrating my Homelab IaC from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a> (this one)</li>



<li><a href="https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/">Migrating Proxmox LXC containers from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/">How to manage Pulumi Secrets with 1Password</a></li>



<li><a href="https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/">Rookie mistakes I made with Pulumi dependency tracking</a></li>



<li><a href="https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/">Pulumi vs Terraform: honest retrospective after a full migration</a></li>
</ul>
</div></div>



<h3 class="wp-block-heading">My Homelab DNS requirements</h3>



<p>My Homelab setup uses several kinds of DNS records:</p>



<ul class="wp-block-list">
<li><strong>Many CNAME records</strong> that map short, readable names to an Nginx reverse proxy. The proxy then forwards each incoming request to the correct private IP and port.</li>



<li><strong>A few A records</strong> that point directly to private IPs. These aren&#8217;t accessible outside my network, and that&#8217;s intentional as those services are meant to stay private.</li>



<li><strong>Some dynamic host records</strong> used to expose a few services publicly. These combine dynamic host login credentials (configured later in my public-facing services) and the DNS records that clients will query.</li>
</ul>



<p>Setting this up with the <a href="https://www.pulumi.com/registry/packages/ovh/">Pulumi OVH provider</a> is straightforward. I created a small wrapper class around the provider to reduce some boilerplate. The most interesting part is the helper function for creating a dynamic host record:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript shcb-wrap-lines"><span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> ovh <span class="hljs-keyword">from</span> <span class="hljs-string">"@ovhcloud/pulumi-ovh"</span>;
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> * <span class="hljs-keyword">as</span> pulumi <span class="hljs-keyword">from</span> <span class="hljs-string">"@pulumi/pulumi"</span>;

<span class="hljs-keyword">const</span> rootDomainName = <span class="hljs-string">'my-domain-name.ovh'</span>

<span class="hljs-keyword">const</span> createDynhostRecord({
    publicDomainName,
    loginSuffix,
    password,
}: {
    publicDomainName: <span class="hljs-built_in">string</span>;
    loginSuffix: pulumi.Input&lt;<span class="hljs-built_in">string</span>&gt;;
    password: pulumi.Input&lt;<span class="hljs-built_in">string</span>&gt;;
}) =&gt; {
    <span class="hljs-keyword">const</span> dynhostLogin = <span class="hljs-keyword">new</span> ovh.domain.DynhostLogin(
        publicDomainName,
        {
            zoneName: rootDomainName,
            loginSuffix,
            password,
            subDomain: publicDomainName.slice(<span class="hljs-number">0</span>, -rootDomainName.length - <span class="hljs-number">1</span>),
        },
    );

    <span class="hljs-keyword">new</span> ovh.DomainZoneDynhostRecord(publicDomainName, {
        zoneName: dynhostLogin.zoneName,
        subDomain: dynhostLogin.subDomain,
        ip: <span class="hljs-string">"1.1.1.1"</span>,
    });
}</code></span></pre>


<p>I pass the login suffix and password as inputs from the caller. Both values come from 1Password (I’ll explain how in a separate post).</p>



<p>This setup ensures all required DNS records exist. However, I still need to <strong>import the existing records</strong>; otherwise, Pulumi will fail during <code>apply</code>, since the resources already exist even though they’re not part of the Pulumi state yet.</p>



<h3 class="wp-block-heading">Importing existing OVH DNS records in bulk</h3>



<p>Importing them is a bit tricky. Pulumi allows importing resources one by one or in bulk via an import file. Instead of creating this file by hand, you can generate it automatically with:</p>


<pre class="wp-block-code"><span><code class="hljs language-bash">pulumi preview --import-file ./import.json</code></span></pre>


<p>The resulting <code>import.json</code> will look like this (with many more entries):</p>


<pre class="wp-block-code"><span><code class="hljs language-json shcb-wrap-lines">{
    <span class="hljs-attr">"resources"</span>: &#91;
        {
            <span class="hljs-attr">"type"</span>: <span class="hljs-string">"ovh:Domain/zoneRecord:ZoneRecord"</span>,
            <span class="hljs-attr">"name"</span>: <span class="hljs-string">"my-subdomain.my-domain.ovh"</span>,
            <span class="hljs-attr">"id"</span>: <span class="hljs-string">"&lt;PLACEHOLDER&gt;"</span>,
            <span class="hljs-attr">"version"</span>: <span class="hljs-string">"2.10.0"</span>,
            <span class="hljs-attr">"pluginDownloadUrl"</span>: <span class="hljs-string">"github://api.github.com/ovh/pulumi-ovh"</span>
        }
    ]
}</code></span></pre>


<p></p>



<p>Notice the <code>&lt;PLACEHOLDER&gt;</code> value for <code>id</code>. You must replace this with the actual ID used by the OVH provider.</p>



<p>Each provider computes resource IDs differently. You can discover the ID format by creating a dummy resource and inspecting the Pulumi state. For OVH DNS records, you’ll see that the ID is a simple numeric value.</p>



<p>This matches the ID used by the Terraform OVH provider. A Terraform state with OVH DNS records looks like:</p>


<pre class="wp-block-code"><span><code class="hljs language-json shcb-wrap-lines">{
  <span class="hljs-comment">// Other fields omitted for brevity</span>
  <span class="hljs-attr">"resources"</span>: &#91;{
    <span class="hljs-comment">// Other fields omitted for brevity</span>
    <span class="hljs-attr">"type"</span>: <span class="hljs-string">"ovh_domain_zone_record"</span>,
    <span class="hljs-attr">"provider"</span>: <span class="hljs-string">"provider&#91;\"registry.terraform.io/ovh/ovh\"]"</span>,
    <span class="hljs-attr">"instances"</span>: &#91;{
      <span class="hljs-comment">// Other fields omitted for brevity</span>
      <span class="hljs-attr">"attributes"</span>: {
        <span class="hljs-comment">// Other fields omitted for brevity</span>
        <span class="hljs-attr">"id"</span>: <span class="hljs-string">"0123456789"</span> <span class="hljs-comment">// NUMERIC_ID</span>
      } 
    }]
  }]
}</code></span></pre>


<p>However, <strong>Pulumi OVH provider uses a different format for imports</strong>. Instead of just <code>NUMERIC_ID</code>, the import ID must be:</p>


<pre class="wp-block-code"><span><code class="hljs language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">NUMERIC_ID</span>&gt;</span>.<span class="hljs-tag">&lt;<span class="hljs-name">DNS_ZONE</span>&gt;</span></code></span></pre>


<p>So in this example, the ID you should use is:</p>


<pre class="wp-block-code"><span><code class="hljs language-css">0123456789<span class="hljs-selector-class">.my-domain</span><span class="hljs-selector-class">.ovh</span></code></span></pre>


<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Can I get the ID without a Terraform state?</strong></p>



<p>Yes. A slightly cumbersome but easy method is:</p>



<ol class="wp-block-list">
<li>Open the DNS record in the OVH web admin panel.</li>



<li>Check the browser&#8217;s dev tools.</li>



<li>You&#8217;ll see a network request to <code>https://manager.eu.ovhcloud.com/engine/apiv6/domain/zone/<strong>&lt;DNS_ZONE&gt;</strong>/record/<strong>&lt;NUMERIC_ID&gt;</strong></code></li>
</ol>



<p>You can also fetch these in bulk using the <a href="https://eu.api.ovh.com/console/?section=%2Fdomain&amp;branch=v1#get-/domain/zone/-zoneName-/record">OVH API</a>.</p>
</div></div>



<p>Once you&#8217;ve replaced all placeholders, import everything in one go:</p>


<pre class="wp-block-code"><span><code class="hljs language-swift">pulumi <span class="hljs-keyword">import</span> -f ./<span class="hljs-keyword">import</span>.json</code></span></pre>


<h3 class="wp-block-heading">Importing existing OVH DNS records one by one</h3>



<p>You can also import individual records. You&#8217;ll need three things:</p>



<ol class="wp-block-list">
<li><strong>The resource type</strong>. For OVH DNS records it is <code>ovh:Domain/zoneRecord:ZoneRecord</code>. (I pulled this from the generated JSON import file; I’m not sure of another direct way).</li>



<li><strong>The resource name</strong>. The name you assigned to the Pulumi resource on creation.</li>



<li><strong>The import ID</strong>, in the import format. Note that for OVH DNS records this is <code>&lt;NUMERIC_ID&gt;.&lt;DNS_ZONE&gt;</code>.</li>
</ol>



<p>For the earlier example ( <code>my-subdomain.my-domain.ovh</code>, ID  <code>0123456789</code>), the import command is:</p>


<pre class="wp-block-code"><span><code class="hljs language-bash shcb-wrap-lines">pulumi import ovh:Domain/zoneRecord:ZoneRecord my-subdomain.my-domain.ovh 0123456789.ulz.ovh</code></span></pre>The post <a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Why I am migrating my Homelab IaC from Terraform to Pulumi</title>
		<link>https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/</link>
					<comments>https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/#respond</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Wed, 07 Jan 2026 18:21:23 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[pulumi]]></category>
		<category><![CDATA[terraform]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15636</guid>

					<description><![CDATA[<p>Why I’m shifting my Homelab from Terraform to Pulumi, reducing headaches from modules, constants, and boilerplate along the way.</p>
The post <a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">Why I am migrating my Homelab IaC from Terraform to Pulumi</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p>Last year, I repurposed an old PC as a home server. Before that, I was already running a few Docker containers on a Synology NAS, but I wanted something more cost-effective—especially when it came to storage capacity.</p>



<p>One of my goals was to define the entire setup as code. I wanted to avoid clicking around in the UI and waiting for pages to load every time I needed to change a setting. So I installed <a href="https://www.proxmox.com/" title="">Proxmox</a> (manually), set up API credentials, and jumped on the <a href="https://developer.hashicorp.com/terraform">Terraform</a> bandwagon. With it, I defined all my containers, firewall rules, and the public and private domain names needed to access my services.</p>



<span id="more-15636"></span>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Other parts in this series:</strong></p>



<ul class="wp-block-list">
<li><a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">Why I am migrating my Homelab IaC from Terraform to Pulumi</a> (this one)</li>



<li><a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/migrating-proxmox-lxc-containers-from-terraform-to-pulumi/">Migrating Proxmox LXC containers from Terraform to Pulumi</a></li>



<li><a href="https://llu.is/how-to-manage-pulumi-secrets-with-1password/">How to manage Pulumi Secrets with 1Password</a></li>



<li><a href="https://llu.is/rookie-mistakes-i-made-with-pulumi-dependency-tracking/">Rookie mistakes I made with Pulumi dependency tracking</a></li>



<li><a href="https://llu.is/pulumi-vs-terraform-honest-retrospective-after-a-full-migration/">Pulumi vs Terraform: honest retrospective after a full migration</a> </li>
</ul>
</div></div>



<h3 class="wp-block-heading">The maintenance issues</h3>



<p>This worked well for a while, but as the infrastructure grew, it became harder to maintain and several pain points appeared:</p>



<ul class="wp-block-list">
<li><strong>Splitting resources across files is awkward.</strong> Everything has to live at the root module level, or you’re forced to create full modules if you want to use folders. I went with the latter, which left me with a ton of boilerplate <code>variables.tf</code> files and repeated <code>providers.tf</code> definitions.</li>



<li><strong>Sharing constants is painful.</strong> They need to be defined in the root module and manually passed down to every child module.</li>



<li><strong>There’s no easy way to share data types.</strong> I’m not a Go developer, so writing custom Terraform plugins or providers isn’t feasible. But I really wish I could enforce a consistent input structure across all my LXC container modules.</li>
</ul>



<p>The setup itself isn’t overly complex, though. I have a cheap <code>.ovh</code> domain, and I use Terraform to point several DNS records to private IPs in my network. Then I use the <code><code><a href="https://registry.terraform.io/providers/bpg/proxmox/latest/docs">bpg/proxmox</a></code></code> provider to declare firewall rules and LXC containers. Most of those containers run NixOS with custom <code>configuration.nix</code> files generated from templates that inherit from a base template. With <code><code><a href="https://registry.terraform.io/providers/Sander0542/nginxproxymanager/latest">Sander0542/nginxproxymanager</a></code></code>, I configure an internal proxy that forwards HTTP(S) traffic to the correct container and port.</p>



<p>There are a few parts I’m saving for future posts—like how everything is monitored with <a href="https://opentelemetry.io/">Open Telemetry</a> and <a href="https://signoz.io/">SigNoz</a>, or how one of the containers runs Samba so the others can share files easily.</p>



<figure data-wp-context="{&quot;imageId&quot;:&quot;69dcc54a8be35&quot;}" data-wp-interactive="core/image" data-wp-key="69dcc54a8be35" class="wp-block-image size-medium wp-lightbox-container"><img decoding="async" width="300" height="156" data-wp-class--hide="state.isContentHidden" data-wp-class--show="state.isContentVisible" data-wp-init="callbacks.setButtonStyles" data-wp-on--click="actions.showLightbox" data-wp-on--load="callbacks.setButtonStyles" data-wp-on-window--resize="callbacks.setButtonStyles" src="https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-300x156.jpg" alt="Screenshot of Proxmox UI showing details of a Debian LXC container running SigNoz" class="wp-image-15640" srcset="https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-300x156.jpg 300w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-778x405.jpg 778w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-768x400.jpg 768w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-1536x800.jpg 1536w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-2048x1067.jpg 2048w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-1278x666.jpg 1278w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-852x444.jpg 852w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-426x222.jpg 426w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-120x63.jpg 120w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-80x42.jpg 80w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-40x21.jpg 40w, https://llu.is/wp-content/uploads/2026/01/Captura-de-pantalla-2026-01-07-a-las-19.05.58-420x219.jpg 420w" sizes="(max-width: 300px) 100vw, 300px" /><button
			class="lightbox-trigger"
			type="button"
			aria-haspopup="dialog"
			aria-label="Enlarge"
			data-wp-init="callbacks.initTriggerButton"
			data-wp-on--click="actions.showLightbox"
			data-wp-style--right="state.imageButtonRight"
			data-wp-style--top="state.imageButtonTop"
		>
			<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 12 12">
				<path fill="#fff" d="M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z" />
			</svg>
		</button><figcaption class="wp-element-caption">LXC Debian container running SigNoz in my Homelab</figcaption></figure>



<h3 class="wp-block-heading">The migration</h3>



<p>Eventually, I decided I needed to simplify the code behind this setup. I considered moving to <a href="https://developer.hashicorp.com/terraform/cdktf">CDKTF</a>… only to learn that it had been deprecated two days before I looked into it. That’s how I discovered <a href="https://www.pulumi.com/">Pulumi</a>.</p>



<p>I’m still learning it and migrating things slowly (so far, only the DNS records), but I’ve already figured out a couple of interesting things:</p>



<ul class="wp-block-list">
<li>Importing existing Terraform resources from a Terraform Enterprise–hosted state into Pulumi.</li>



<li>Reading and writing secrets directly from 1Password (something I never automated with Terraform).</li>
</ul>



<p>I’ll cover both topics in future posts. This one is just an introduction to the series about my Homelab and its migration to <a href="https://www.pulumi.com/">Pulumi</a>. The full migration will take months—partly because I’m not in a hurry (everything is already running fine) and partly because I don’t want to lose anything during the transition. Importing existing resources is essential for me.</p>



<p>You will find all my posts about this migration in the <a href="https://llu.is/category/projects/homelab/">homelab category</a>, as well as listed below:</p>



<ul class="wp-block-list">
<li><a href="https://llu.is/migrating-ovh-dns-records-from-terraform-to-pulumi/">Migrating OVH DNS records from Terraform to Pulumi</a></li>
</ul>



<p></p>The post <a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/">Why I am migrating my Homelab IaC from Terraform to Pulumi</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>2026 New Year&#8217;s resolutions</title>
		<link>https://llu.is/2026-new-years-resolutions/</link>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Wed, 07 Jan 2026 17:26:29 +0000</pubDate>
				<category><![CDATA[General]]></category>
		<category><![CDATA[personal]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15632</guid>

					<description><![CDATA[<p>Starting 2026 with a renewed push to write more, as my learning notebook.</p>
The post <a href="https://llu.is/2026-new-years-resolutions/">2026 New Year’s resolutions</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p>Every end of the year, I think about getting back to writing regularly on this blog. And every year, it somehow slips away. Sometimes I lack inspiration. Sometimes I’m unsure what to write about. Other times I worry that what I share might be too obvious or not worth posting.</p>



<p>This year, however, I want to do things differently. Over the past few months, I’ve been working on a couple of projects that have sparked new ideas. I now have notes, discoveries, and stories worth sharing—both about the work itself and the motivation behind it. Writing this down publicly should also help keep me accountable.</p>



<p>I’ll begin with <a href="https://llu.is/why-i-am-migrating-my-homelab-iac-from-terraform-to-pulumi/" title="">my Homelab and its migration from Terraform to Pulumi</a>. I’m also planning to redesign this website and potentially move away from WordPress on the client side. Expect a few posts on each project, along with regular progress updates.</p>



<p>Although I’m writing mainly for myself, as a kind of learning journal, I hope some of it proves useful to you as well.</p>



<p>See you around!</p>The post <a href="https://llu.is/2026-new-years-resolutions/">2026 New Year’s resolutions</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>How to write type-safe nested key paths in TypeScript</title>
		<link>https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/</link>
					<comments>https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/#respond</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Sat, 08 Mar 2025 10:20:01 +0000</pubDate>
				<category><![CDATA[Tips]]></category>
		<category><![CDATA[TypeScript]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15608</guid>

					<description><![CDATA[<p>Learn how to write type-safe utilities for key-paths (root.key) in TypeScript, setters and getters included!</p>
The post <a href="https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/">How to write type-safe nested key paths in TypeScript</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<p>Recently I had to write a type-safe function that allowed to update (nested) paths of a JavaScript object. I wanted the function to prevent setting a wrong value to the requested path, even nested ones with optional values.</p>



<p>After playing a bit with <strong>TypeScript</strong> type system I came up with these types which I think can be useful and will be using for sure in future projects. You can play with it in <a href="https://www.typescriptlang.org/play/?#code/PTAEBUE8AcFMGdQBNYDMCWA7dAXdB7TAKCNgA9p8AnHUHGWUAaVkgAUBDHACwB4jQgiKHI5YmJIng4qWAOYAaAUKYiyYiVJnylQ0AGVY0DlS7U1GyaGmzMc0AF5QAIgB0zpQD5HoAAYASAG9wAF8g52cLcStwUAB+F0iALgMjEzMqMMCmEN8AbhJySho6BmZWTh54XljRaMR8ACMAK1gAYxwFUDYozWttOx8IrsNjUxxzOr6beSH3bydA5UEAbVUsUABrVnxUCABdAFo4lNUpqxm7Zb140AA5QjuAVwAbF45Gl9gatf3vc4aLXaOGuNyECRY7C4fDYXSYIzS42o3gAPuUoVVeA9MM83h8vj8mH84RVobxYcwEWMMp4qekJlRPKCwaBTqSeOS4XSkYzmYIUphYAA3WBUAohFbbSC7YQAMn6tjk+wKpAo1Fo9DgoAA4rAcFA4ABBHCQyp8cBdM29C4DRSpakM61aRVzZwLbrQp1bHZ7cDXBLgFZm-bXFJWgF+IJYVCi5hZUb06hZaOxgBKCBwuWZEK9UplfpZ4Puj1e70+30DRP+6nqoCarQ6fJZCV1+oYxtNZOxuLLBMrxNA6ek3JpTbBAuForHrNAgpFVGZE-nKpAoAAomQOABbaBfeAkLBiKioDhtRgAVXgsaWgkw29gKRvQgwVGkKUucmu7zfCvkyhCyiNOgNDcCcoBPoIkCwCYApPFujRToIAEAUQbSENIoBPFeVApJesaLMod5bg+4HXC+P4AOQADIvE8AC38AUboQjfjgKQUeeLwAF5PFQVBPJgyCMIa8AcJgXEcAYAAHsBcUx-5KChaGYBhTzQEgXCwHhVDGtojRPGIPi8GcNZ9J2mLaZ4ngABS4LAW64dhXRSmapxdIKADuABqHC0SRrYGrAHbsnw2lwp4ACUjjeE+q4AJI7l8xGYDgXAEIJ6CIJg+C0OgO7qmJIJKehtByHq2m6bI+mGU4xleuZ3DVJZNl2Q5oBhd6GLcKcEUpAF7YmiFvAdUwCwxcoq7gNwmWgFQeq8YJQq+U8jAzR5VCEPYHCIEUwKwEgoDWUBIGgFBJigDNkmYHBCFUAAhBFyhzTgC2gBR8D4MRoBLX5FFEChq6XhwZUkJN3AIIwbS+S8iAmIw+CbEQakaWIFU4HpBmwNZWGil0FHHTwrhnVQTGgAADI9yOaWjGNiNjTlvQT3CkwJKAYIKSCPWDM1Q28F2IOtm1dEzRPQVQoBblhtAIaAV03aKd1I+p1PYZV6DVVjONUHjIvE6TFFkxRXNgFNM2aqtiDQBtcBUC8kAXZgMZ8ftqEladYs+GVOA01VmP07jjPAYTesRUAA" target="_blank" rel="noopener" title="">this playground</a> or see the code right below:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript shcb-code-table shcb-line-numbers shcb-wrap-lines"><span class='shcb-loc'><span><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> KeyPath&lt;
</span></span><span class='shcb-loc'><span>    T <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span>,
</span></span><span class='shcb-loc'><span>    K <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span>,
</span></span><span class='shcb-loc'><span>    Separator <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span> = <span class="hljs-string">"."</span>,
</span></span><span class='shcb-loc'><span>&gt; = <span class="hljs-string">`<span class="hljs-subst">${T}</span><span class="hljs-subst">${<span class="hljs-string">""</span> <span class="hljs-keyword">extends</span> T ? <span class="hljs-string">""</span> : Separator}</span><span class="hljs-subst">${K}</span>`</span>;
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> KeyPaths&lt;T <span class="hljs-keyword">extends</span> object, P <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span> = <span class="hljs-string">""</span>, Separator <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span> = <span class="hljs-string">"."</span>&gt; = {
</span></span><span class='shcb-loc'><span>    &#91;K <span class="hljs-keyword">in</span> keyof T]-?: K <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span>
</span></span><span class='shcb-loc'><span>        ? NonNullable&lt;T&#91;K]&gt; <span class="hljs-keyword">extends</span> object
</span></span><span class='shcb-loc'><span>            ? KeyPath&lt;P, K, Separator&gt; | KeyPaths&lt;NonNullable&lt;T&#91;K]&gt;, KeyPath&lt;P, K, Separator&gt;, Separator&gt;
</span></span><span class='shcb-loc'><span>            : KeyPath&lt;P, K, Separator&gt;
</span></span><span class='shcb-loc'><span>        : never;
</span></span><span class='shcb-loc'><span>}&#91;keyof T &amp; <span class="hljs-built_in">string</span>];
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> GetTypeAtKeyPath&lt;T, Path <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span>, Separator <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span> = <span class="hljs-string">"."</span>&gt; = Path <span class="hljs-keyword">extends</span> keyof T
</span></span><span class='shcb-loc'><span>    ? T&#91;Path]
</span></span><span class='shcb-loc'><span>    : Path <span class="hljs-keyword">extends</span> <span class="hljs-string">`<span class="hljs-subst">${infer K}</span><span class="hljs-subst">${Separator}</span><span class="hljs-subst">${infer Rest}</span>`</span>
</span></span><span class='shcb-loc'><span>      ? K <span class="hljs-keyword">extends</span> keyof T
</span></span><span class='shcb-loc'><span>          ? NonNullable&lt;T&#91;K]&gt; <span class="hljs-keyword">extends</span> object
</span></span><span class='shcb-loc'><span>              ? GetTypeAtKeyPath&lt;NonNullable&lt;T&#91;K]&gt;, Rest, Separator&gt;
</span></span><span class='shcb-loc'><span>              : never
</span></span><span class='shcb-loc'><span>          : never
</span></span><span class='shcb-loc'><span>      : never;
</span></span></code></span></pre>


<p>Let&#8217;s dig into how to use it and how <code><a href="https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/#key-path-how-to" title="How to write type-safe nested key paths in TypeScript">KeyPath</a></code>, <code><a href="https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/#key-paths-how-to" title="">KeyPaths</a></code> and <code><a href="https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/#get-type-at-key-path-how-to" title="">GetTypeAtKeyPath</a></code> work and <a href="https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/#usage-how-to" title="">how to use it</a>.</p>



<span id="more-15608"></span>



<h2 class="wp-block-heading" id="key-path-how-to">List key paths with <code>KeyPath</code></h2>



<p>First we define a type for a <code>KeyPath</code>, that is, sequence of paths joined with a specific separator (usually a dot, <code>.</code>,  but we make it customizable). To build this type we use <a href="https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html">TypeScript&#8217;s template literal types</a>.</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> KeyPath&lt;
    T <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span>,
    K <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span>,
    Separator <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span> = <span class="hljs-string">"."</span>,
&gt; = <span class="hljs-string">`<span class="hljs-subst">${T}</span><span class="hljs-subst">${<span class="hljs-string">""</span> <span class="hljs-keyword">extends</span> T ? <span class="hljs-string">""</span> : Separator}</span><span class="hljs-subst">${K}</span>`</span>;</code></span></pre>


<p>We will leverage <code>KeyPath</code> type to extract each component of a key path.</p>



<h2 class="wp-block-heading" id="key-paths-how-to">Check key paths with <code>KeyPaths</code></h2>



<p>Next we need a type that represents all possible key paths for a given object. The simplest scenario is when the object has no nested objects, in this situation its keys are its key paths as there is no nesting. The complex scenario is when the object has nested keys: each key is a key path but we also have to extract all the key paths for the nested objects and prefix them with the key in the parent object where we found the nested object. <strong>Recursion,</strong> that is.</p>



<p>So we define a type <code>KeyPaths</code> that given an object, a <em>&#8220;parent key path&#8221;</em> and a separator, it represents all the key paths in the object, prefixed with the <em>&#8220;parent key path&#8221;</em>.</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> KeyPaths&lt;
  <span class="hljs-comment">// The object we will extract key paths from</span>
  T <span class="hljs-keyword">extends</span> object,
  <span class="hljs-comment">// The key path to this object, empty string for object's root</span>
  P <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span> = <span class="hljs-string">""</span>,
  <span class="hljs-comment">// The separator for key paths</span>
  <span class="hljs-comment">// Default to dot (.) because it's a frequent notation</span>
  Separator <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span> = <span class="hljs-string">"."</span>
&gt; = {
    <span class="hljs-comment">// Type a new object where values are the union of possible key paths with key as prefix</span>
    <span class="hljs-comment">// We disregard nullable values with -?</span>
    <span class="hljs-comment">// nullable values leak undefined into keys</span>
    &#91;K <span class="hljs-keyword">in</span> keyof T]-?: K <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span>
        <span class="hljs-comment">// Remove nullable to look for nested objects</span>
        ? NonNullable&lt;T&#91;K]&gt; <span class="hljs-keyword">extends</span> object
            <span class="hljs-comment">// There's a nested object, so we must explore it</span>
            ? KeyPath&lt;P, K, Separator&gt; | KeyPaths&lt;NonNullable&lt;T&#91;K]&gt;, KeyPath&lt;P, K, Separator&gt;, Separator&gt;
            <span class="hljs-comment">// No nested object, we only return the current key</span>
            : KeyPath&lt;P, K, Separator&gt;
        <span class="hljs-comment">// Key is not a string so we can't really build a key path with it</span>
        : never;
}&#91;keyof T &amp; <span class="hljs-built_in">string</span>]; <span class="hljs-comment">// Extract all values in the new object</span></code></span></pre>


<h2 class="wp-block-heading" id="get-type-at-key-path-how-to">Get type for key path with <code>GetTypeAtKeyPath</code></h2>



<p>We also need some type to extract the type at a given key path, let&#8217;s call it <code>GetTypeAtKeyPath</code>. To do so we build on top of <code>KeyPath</code> foundations. We begin by extracting the first key of the key path, then extracting the type of the value at that key and finally repeating the process with the rest of the key path and the value we just extracted. What if we reach the last key in the key path? We just return the type of the value at that key.</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> GetTypeAtKeyPath&lt;
  <span class="hljs-comment">// The object to extract types from</span>
  T,
  <span class="hljs-comment">// The key path</span>
  Path <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span>,
  <span class="hljs-comment">// Key path separator</span>
  Separator <span class="hljs-keyword">extends</span> <span class="hljs-built_in">string</span> = <span class="hljs-string">"."</span>
&gt; = Path <span class="hljs-keyword">extends</span> keyof T
    <span class="hljs-comment">// Key path is an actual key of this object</span>
    ? T&#91;Path]
    <span class="hljs-comment">// Extract first key in the key path</span>
    : Path <span class="hljs-keyword">extends</span> <span class="hljs-string">`<span class="hljs-subst">${infer K}</span><span class="hljs-subst">${Separator}</span><span class="hljs-subst">${infer Rest}</span>`</span>
      <span class="hljs-comment">// Check that first key in key path is an actual key of the object</span>
      ? K <span class="hljs-keyword">extends</span> keyof T
          <span class="hljs-comment">// Ensure the key path is referring to a nested object</span>
          ? NonNullable&lt;T&#91;K]&gt; <span class="hljs-keyword">extends</span> object
              <span class="hljs-comment">// Extract type for remaining key path in nested object</span>
              ? GetTypeAtKeyPath&lt;NonNullable&lt;T&#91;K]&gt;, Rest, Separator&gt;
              <span class="hljs-comment">// Value for first key is not an object so key path is invalid</span>
              : never
          <span class="hljs-comment">// First key in the path is not a key of the object so key path is invalid</span>
          : never
      <span class="hljs-comment">// Key path is wrongly formatted</span>
      : never;</code></span></pre>


<h2 class="wp-block-heading" id="usage-how-to">Type-safe getters/setters</h2>



<p>Combining <code>KeyPaths</code> and <code>GetTypeAtKeyPath</code> we can define a generic type-safe <em>getters</em> and <em>setters</em>. Let&#8217;s assume we have a simple <code>User</code> data structure like:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">interface</span> User {
  name: {
    first: <span class="hljs-built_in">string</span>
    last: <span class="hljs-built_in">string</span>
  }
  birth?: {
    year: <span class="hljs-built_in">number</span>
  }
}

<span class="hljs-keyword">const</span> me: User = {
  name: {
    first: <span class="hljs-string">'Lluís'</span>,
    last: <span class="hljs-string">'Ulzurrun de Asanza Sàez'</span>,
  },
}</code></span></pre>


<p>A type-safe key-path based <em>setter</em> for the <code>User</code> data structure would look like:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">const</span> updateUserAttribute = &lt;
  <span class="hljs-comment">// Hold the key path here to refer it later</span>
  K <span class="hljs-keyword">extends</span> KeyPaths&lt;User&gt;
&gt;(
  <span class="hljs-comment">// Given a user</span>
  user: User,
  <span class="hljs-comment">// Some key path</span>
  keyPath: K,
  <span class="hljs-comment">// And a valid value</span>
  newValue: GetTypeAtKeyPath&lt;User, K&gt;
) =&gt; {
  <span class="hljs-comment">// Implementation is not important</span>
}

<span class="hljs-comment">// This is valid at build time</span>
updateUserAttribute(me, <span class="hljs-string">'birth.year'</span>, <span class="hljs-number">0</span>)
updateUserAttribute(me, <span class="hljs-string">'birth'</span>, <span class="hljs-literal">undefined</span>)

<span class="hljs-comment">// This will fail at build time</span>
updateUserAttribute(me, <span class="hljs-string">'birth.year'</span>, <span class="hljs-string">'0'</span>)</code></span></pre>


<p>Finally we can define a type-safe key-path <em>getter</em> for the <code>User</code> data structure with:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">const</span> getUserAttribute = &lt;
  <span class="hljs-comment">// Hold the key path here to refer it later</span>
  K <span class="hljs-keyword">extends</span> KeyPaths&lt;User&gt;
&gt;(
  <span class="hljs-comment">// Given a user</span>
  user: User,
  <span class="hljs-comment">// Some key path</span>
  keyPath: K
): 
<span class="hljs-comment">// Returns a valid value</span>
GetTypeAtKeyPath&lt;User, K&gt; =&gt; {
  <span class="hljs-comment">// The compiler detects this return value as wrong as expected (birth year is a number!)</span>
  <span class="hljs-keyword">return</span> <span class="hljs-string">'some value'</span>
}

<span class="hljs-comment">// This type is properly inferred</span>
<span class="hljs-keyword">const</span> name = getUserAttribute(me, <span class="hljs-string">'name.first'</span>)</code></span></pre>The post <a href="https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/">How to write type-safe nested key paths in TypeScript</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/how-to-write-type-safe-nested-key-paths-in-typescript/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>How to write WordPress Themes with SASS, TypeScript, and HMR</title>
		<link>https://llu.is/how-to-write-wordpress-themes-with-sass-typescript-and-hmr/</link>
					<comments>https://llu.is/how-to-write-wordpress-themes-with-sass-typescript-and-hmr/#comments</comments>
		
		<dc:creator><![CDATA[Sumolari]]></dc:creator>
		<pubDate>Mon, 09 Jan 2023 07:08:39 +0000</pubDate>
				<category><![CDATA[CSS]]></category>
		<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[frontend]]></category>
		<category><![CDATA[HMR]]></category>
		<category><![CDATA[SASS]]></category>
		<category><![CDATA[TypeScript]]></category>
		<category><![CDATA[Vite]]></category>
		<guid isPermaLink="false">https://llu.is/?p=15486</guid>

					<description><![CDATA[<p>Not interested in all the details? I shared a twentytwenty child theme with SASS, TypeScript, and HMR applying all the ideas in this post that you can use as a starting point. Modern front-end developer experience is nicer than the one we had in 2007 when WordPress came out. Regular WordPress themes require reloading the [&#8230;]</p>
The post <a href="https://llu.is/how-to-write-wordpress-themes-with-sass-typescript-and-hmr/">How to write WordPress Themes with SASS, TypeScript, and HMR</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></description>
										<content:encoded><![CDATA[<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Not interested in all the details?</strong> I shared a <a href="https://github.com/Sumolari/wp-hmr-theme" target="_blank" rel="noopener" title="WordPress Twentytwenty child theme with SASS, TypeScript and HMR">twentytwenty child theme with SASS, TypeScript, and HMR</a> applying all the ideas in this post that you can use as a starting point.</p>
</div></div>



<p>Modern front-end developer experience is nicer than the one we had in 2007 when WordPress came out.</p>



<ul class="wp-block-list">
<li><strong>CSS preprocessors</strong> help you reduce boilerplate and reuse mixins. </li>



<li><a href="https://www.typescriptlang.org/" target="_blank" rel="noopener" title="TypeScript"><strong>TypeScript</strong></a> minimizes runtime errors with its type-checking and IDE auto-completion. </li>



<li>The latest syntactic sugar from <strong>ECMAScript drafts</strong> makes your code simpler.</li>



<li>Partial content refresh when files change with <strong><abbr title="Hot Module Replacement" lang="en">HMR</abbr></strong> enables a seamless experience.</li>
</ul>



<p>Regular WordPress themes require reloading the entire page when any JavaScript or CSS changes. However, with tools like <a href="https://vitejs.dev/" target="_blank" rel="noopener" title="Vite">Vite</a> we can achieve a state-of-the-art developer experience. </p>



<p>In this post I will guide you through all the steps required to add SASS, TypeScript, and HMR support to any WordPress theme.</p>



<figure class="wp-block-video alignwide"><video height="1116" style="aspect-ratio: 1828 / 1116;" width="1828" controls loop muted src="https://llu.is/wp-content/uploads/2022/12/WordPress-SCSS-HMR.mp4"></video><figcaption class="wp-element-caption">A Twenty Twenty child theme with SASS stylesheet and HMR, and early preview of what we will achieve by the end of the post.</figcaption></figure>



<span id="more-15486"></span>



<h2 class="wp-block-heading">Hot module replacement</h2>



<div class="wp-block-group is-style-tip"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>If you are familiar with HMR fundamentals, just <a href="#using-vite-in-wordpress" title="Using Vite in WordPress section">skip to the code</a>.</p>
</div></div>



<p><abbr title="Hot Module Replacement" lang="en">HMR</abbr> is based on browser-server collaboration. First, the browser loads an initial version of all the files. After that, it opens a bidirectional connection to the server (typically using a <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API" target="_blank" rel="noopener nofollow" title="Documentation about WebSocket API">WebSocket</a>).</p>



<p>The server uses the bidirectional connection to notify the browser when any file changes. Then, the browser replaces the old script/link tag in the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model" target="_blank" rel="noopener nofollow" title="Description and documentation of the Document Object Model">DOM</a> with a new tag that loads again the affected file. To force reloading the asset, the browser appends a timestamp to the URL of file.</p>



<p>Some assets may not be directly usable in the browser. For instance, <a href="https://sass-lang.com/documentation/cli/dart-sass#one-to-one-mode" target="_blank" rel="noopener" title="Documentation about how to compile a SASS file">SASS files must be compiled into CSS</a>, <a href="https://www.typescriptlang.org/docs/handbook/compiler-options.html" target="_blank" rel="noopener" title="Documentation about building TypeScript files into JavaScript">TypeScript files into JavaScript</a>, and some modern ECMAScript features might need some transformations to be compatible with the stable version of the browsers.</p>



<p id="vite-hmr-internals"><a href="https://vitejs.dev/" target="_blank" rel="noopener" title="Vite">Vite</a> solves these problems using a WebSocket interface for notifying file changes, a client script that handles the tag updates, and a dev server to provide the client script and the browser-ready assets.</p>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>Note that the <strong>Vite dev server and HMR are used only during development</strong>. When the theme is shipped to our public-facing WordPress site we must just load the built CSS and JS files.</p>
</div></div>



<h3 class="wp-block-heading" id="using-vite-in-wordpress">Using Vite in WordPress</h3>



<p>We will be working on a new file named <code>hmr.php</code>. The code can be added directly to your theme <code>functions.php</code> but keeping it in a different file will make things cleaner. To load <code>hmr.php</code> in your theme <code>functions.php</code> we can use <code><a href="https://www.php.net/manual/en/function.require-once.php" target="_blank" rel="noopener" title="PHP require_once function documentation">require_once</a></code> and <code><a href="https://developer.wordpress.org/reference/functions/get_theme_file_path/" target="_blank" rel="noopener" title="WordPress get_theme_file_path function documentation">get_theme_file_path</a></code>:</p>


<pre class="wp-block-code"><span><code class="hljs language-php"><span class="hljs-keyword">require_once</span> get_theme_file_path(<span class="hljs-string">'/hmr.php'</span>);</code></span></pre>


<h4 class="wp-block-heading">Adding Vite dev server helpers</h4>



<p>The first thing to add to <code>hmr.php</code> are a couple of helper functions: one to get the Vite dev server URL and the other to check whether HMR should be used or not. We can define the Vite dev server URL in our <code>wp-config.php</code> file, right after the <code>&lt;?php</code> opening tag at beginning of the file:</p>


<pre class="wp-block-code"><span><code class="hljs language-php">define(<span class="hljs-string">'VITE_DEV_SERVER_URL'</span>, <span class="hljs-string">'http://localhost:1337'</span>);</code></span></pre>


<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>Running your <a href="https://docs.docker.com/samples/wordpress/" target="_blank" rel="noopener" title="WordPress in Docker samples">WordPress installation in Docker</a>? Then you might be running the Vite dev server in a different container or in your host machine. You can set the Vite dev server URL in an environment variable instead of a PHP constant so you can customize it when starting the container.  </p>
</div></div>



<p>Now we can create the two helpers in <code>hmr.php</code>: </p>



<ul class="wp-block-list">
<li><code>getViteDevServerAddress</code> to return the Vite dev server URL.</li>



<li> <code>isViteHMRAvailable</code> to check that the Vite dev server URL is defined.</li>
</ul>


<pre class="wp-block-code"><span><code class="hljs language-php"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getViteDevServerAddress</span><span class="hljs-params">()</span>
</span>{
    <span class="hljs-keyword">return</span> VITE_DEV_SERVER_URL;
}

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">isViteHMRAvailable</span><span class="hljs-params">()</span>
</span>{
    <span class="hljs-keyword">return</span> !<span class="hljs-keyword">empty</span>(getViteDevServerAddress());
}</code></span></pre>


<h4 class="wp-block-heading">Loading Vite client script</h4>



<p>Vite client script is an <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules" target="_blank" rel="noopener nofollow" title="Documentation about JavaScript modules">ECMAScript module</a>. ECMAScript modules can be loaded in <a href="https://caniuse.com/es6-module" target="_blank" rel="noopener" title="List of browsers that support loading ECMAScript modules">most browsers</a> but they need an additional <code>type="module"</code> attribute added to the <code>script</code> tag, which WordPress doesn&#8217;t add. We will need a helper function to simplify adding this attribute to our scripts. Let&#8217;s open <code>hmr.php</code> and add a <code>loadJSScriptAsESModule</code> helper:</p>


<pre class="wp-block-code"><span><code class="hljs language-php"><span class="hljs-comment">/**
 * Loads given script as a EcmaScript module.
 * 
 * <span class="hljs-doctag">@param</span> string $script_handle Name of the script to load as
 * module.
 *
 * <span class="hljs-doctag">@return</span> void
 */</span>
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loadJSScriptAsESModule</span><span class="hljs-params">($script_handle)</span>
</span>{
    add_filter(
        <span class="hljs-string">'script_loader_tag'</span>,
        <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">($tag, $handle, $src)</span> <span class="hljs-title">use</span> <span class="hljs-params">($script_handle)</span> </span>{
            <span class="hljs-keyword">if</span> ($script_handle === $handle) {
                <span class="hljs-keyword">return</span> sprintf(
                    <span class="hljs-string">'&lt;script type="module" src="%s"&gt;&lt;/script&gt;'</span>,
                    esc_url($src)
                );
            }
            <span class="hljs-keyword">return</span> $tag;
        }, <span class="hljs-number">10</span>, <span class="hljs-number">3</span>
    );
}</code></span></pre>


<p>Now we can load the Vite client script from the dev server when available. We should add to <code>hmr.php</code>:</p>


<pre class="wp-block-code"><span><code class="hljs language-php shcb-code-table"><span class='shcb-loc'><span><span class="hljs-keyword">const</span> VITE_HMR_CLIENT_HANDLE = <span class="hljs-string">'vite-client'</span>;
</span></span><span class='shcb-loc'><span><span class="hljs-keyword">if</span> (isViteHMRAvailable()) {
</span></span><span class='shcb-loc'><span>    wp_enqueue_script(
</span></span><span class='shcb-loc'><span>        VITE_HMR_CLIENT_HANDLE,
</span></span><span class='shcb-loc'><span>        getViteDevServerAddress().<span class="hljs-string">'/@vite/client'</span>,
</span></span><span class='shcb-loc'><span>        <span class="hljs-keyword">array</span>(), <span class="hljs-comment">// This script has no dependencies</span>
</span></span><mark class='shcb-loc'><span>        <span class="hljs-keyword">null</span> <span class="hljs-comment">// WordPress appends a version queryString to the URL when this value is not null</span>
</span></mark><span class='shcb-loc'><span>    );
</span></span><span class='shcb-loc'><span>    loadJSScriptAsESModule(VITE_HMR_CLIENT_HANDLE);
</span></span><span class='shcb-loc'><span>}
</span></span></code></span></pre>


<div class="wp-block-group is-style-warning"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>It&#8217;s important to add <code>null</code> as script version.</strong> WordPress appends the version number as a queryString parameter to script URLs and the Vite dev server doesn&#8217;t return the client script if when requesting it with additional queryString parameters.</p>
</div></div>



<h4 class="wp-block-heading">The dev server is not available</h4>



<p>Load your site right now and you will realize that the browser tries to load the <code>/@vite/client</code> file but fails. That&#8217;s because we haven&#8217;t started the Vite dev server yet, so let&#8217;s set it up and start it afterward.</p>



<h3 class="wp-block-heading" id="creating-package-json">Creating a <code>package.json</code></h3>



<div class="wp-block-group is-style-warning"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Vite</strong> is distributed as an <a href="https://www.npmjs.com/package/vite" target="_blank" rel="noopener" title="Vite NPM package">NPM package</a> so <a href="https://docs.npmjs.com/downloading-and-installing-node-js-and-npm" target="_blank" rel="noopener" title="NPM installation instructions">be sure you have NPM installed</a> in your system.</p>
</div></div>



<p>In the NPM ecosystem, the recommended way to install dependencies is by defining them in a <code>package.json</code> file. This file can live in your theme&#8217;s folder.</p>



<div class="wp-block-group is-style-warning"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>NPM</strong> will download the dependencies to a <code>node_modules</code> folder that it will create next to the <code>package.json</code> file. You won&#8217;t need those dependencies in your production environment so don&#8217;t forget to <strong>delete the <code>node_modules</code> folder before shipping your theme</strong> to the public.</p>
</div></div>



<p>Our initial <code>package.json</code> will look like this:</p>


<pre class="wp-block-code"><span><code class="hljs language-json shcb-code-table shcb-line-numbers"><span class='shcb-loc'><span>{
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"wordpress-theme"</span>,
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0.0"</span>,
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"private"</span>: <span class="hljs-literal">true</span>,
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"license"</span>: <span class="hljs-string">"UNLICENSED"</span>,
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"scripts"</span>: {
</span></span><span class='shcb-loc'><span>    <span class="hljs-attr">"start"</span>: <span class="hljs-string">"vite build &amp;&amp; vite dev"</span>,
</span></span><span class='shcb-loc'><span>    <span class="hljs-attr">"build"</span>: <span class="hljs-string">"vite build"</span>
</span></span><span class='shcb-loc'><span>  },
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"devDependencies"</span>: {
</span></span><span class='shcb-loc'><span>    <span class="hljs-attr">"vite"</span>: <span class="hljs-string">"^4.0.3"</span>
</span></span><span class='shcb-loc'><span>  }
</span></span><span class='shcb-loc'><span>}
</span></span></code></span></pre>


<p>This file has 2 important sections:</p>



<ol class="wp-block-list">
<li><code>scripts</code> section allows us to group some commands into scripts that we will use later on. We define two:
<ul class="wp-block-list">
<li><code>start</code> will build our scripts for production (you need some production assets like <code>style.css</code> in order to activate the new theme in WordPress, even if you want to run it in development mode) and start the Vite dev server.</li>



<li><code>build</code> will build our scripts for production.</li>
</ul>
</li>



<li><code>devDependencies</code> section allows us to define dependencies that are required during development. We only have one dependency: <code>vite</code>.</li>
</ol>



<p><strong>Install the dependencies</strong> now by running: <code>npm install</code>.</p>



<h3 class="wp-block-heading">Setting up Vite</h3>



<p>Vite setup lives in a file named <code>vite.config.ts</code>, next to the <code>package.json</code> file we just created. It&#8217;s a regular TypeScript file that must have a default export with the Vite settings. We can use the <code>defineConfig</code> function that Vite offers to add type-checking support to our config object, spotting typos and other errors. A basic <code>vite.config.ts</code> file with HMR looks like this:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript shcb-code-table shcb-line-numbers"><span class='shcb-loc'><span><span class="hljs-keyword">import</span> { defineConfig } <span class="hljs-keyword">from</span> <span class="hljs-string">'vite'</span>;
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> defineConfig({
</span></span><span class='shcb-loc'><span>  server: {
</span></span><span class='shcb-loc'><span>    port: <span class="hljs-number">1337</span>,
</span></span><span class='shcb-loc'><span>    host: <span class="hljs-string">'0.0.0.0'</span>,
</span></span><span class='shcb-loc'><span>  },
</span></span><span class='shcb-loc'><span>});
</span></span></code></span></pre>


<p>In line 5 we define the port the Vite dev server listens to – <code>1337</code> – and in line 6 we allow connections from any host (useful if you <a href="https://docs.docker.com/samples/wordpress/" target="_blank" rel="noopener" title="WordPress in Docker samples">run your WordPress site in a Docker container</a>).</p>



<h4 class="wp-block-heading">The dev server is not ready yet</h4>



<p>If you start the Vite dev server right now it will fail because it can&#8217;t find an <code>index.html</code> file. Vite looks for an <code>index.html</code> file by default and serves any imported script. We can&#8217;t use an <code>index.html</code> file as the entry point because that&#8217;s not how WordPress themes work. Instead, we will provide the list of files to build and serve. The first one is our theme <code>style.css</code>, which we will write using SASS.</p>



<h3 class="wp-block-heading" id="writing-stylesheet-with-sass">Writing <code>style.css</code> using SASS</h3>



<p>Create a new folder for our SASS files, <code>sass</code>. Then create there a new SASS file to hold our styles, <code>sass/style.scss</code>. To keep things simple, <code>style.scss</code> will be quite short:</p>


<pre class="wp-block-code"><span><code class="hljs language-scss"><span class="hljs-comment">/*!
Theme Name: YOUR THEME
Theme URI: https://your-theme-url.com
Description: A blank WordPress Theme with SASS, TypeScript and Hot Module Replacement (HMR) support
Author: YOUR NAME
Version: 1.0
*/</span>

<span class="hljs-variable">$background-color</span>: red;

<span class="hljs-selector-tag">body</span> {
  <span class="hljs-attribute">background</span>: <span class="hljs-variable">$background-color</span>;
}</code></span></pre>


<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Vite doesn&#8217;t support stylesheets as input files</strong>, so we have to import the stylesheet in a TypeScript file (which Vite does support) and then tweak our Vite config a bit so it automatically copies the output CSS file to the right folder.</p>
</div></div>



<p>We need an additional file,  <code>sass/style.ts</code> , to import <code>sass/style.scss</code>. It only has to import <code>sass/style.scss</code>, so this one-liner is enough:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">import</span> <span class="hljs-string">'./style.scss'</span>;</code></span></pre>


<p>Vite can&#8217;t build SASS files by default so we must update our <code>package.json</code> to install an additional dependency to build our SASS files.</p>



<p>Add the following code inside the <code>devDependencies</code> section, right before line 11, declaring Vite as a dependency: <code>"sass": "^1.56.1",</code>. <code>package.json</code> will look like this:</p>


<pre class="wp-block-code"><span><code class="hljs language-json shcb-code-table shcb-line-numbers"><span class='shcb-loc'><span>{
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"wordpress-theme"</span>,
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0.0"</span>,
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"private"</span>: <span class="hljs-literal">true</span>,
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"license"</span>: <span class="hljs-string">"UNLICENSED"</span>,
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"scripts"</span>: {
</span></span><span class='shcb-loc'><span>    <span class="hljs-attr">"start"</span>: <span class="hljs-string">"vite build &amp;&amp; vite dev"</span>,
</span></span><span class='shcb-loc'><span>    <span class="hljs-attr">"build"</span>: <span class="hljs-string">"vite build"</span>
</span></span><span class='shcb-loc'><span>  },
</span></span><span class='shcb-loc'><span>  <span class="hljs-attr">"devDependencies"</span>: {
</span></span><span class='shcb-loc'><span>    <span class="hljs-attr">"sass"</span>: <span class="hljs-string">"^1.56.1"</span>,
</span></span><span class='shcb-loc'><span>    <span class="hljs-attr">"vite"</span>: <span class="hljs-string">"^4.0.3"</span>
</span></span><span class='shcb-loc'><span>  }
</span></span><span class='shcb-loc'><span>}
</span></span></code></span></pre>


<div class="wp-block-group is-style-warning"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>Don&#8217;t forget to install the new dependency running <code>npm install</code>.</p>
</div></div>



<h4 class="wp-block-heading">Loading stylesheet from Vite dev server</h4>



<p>Now we will update our <code>hmr.php</code> file so our theme loads the new SASS file instead of the original <code>style.css</code>, but only when the Vite dev server is available. To achieve this we will use the <code><a href="https://developer.wordpress.org/reference/functions/add_filter/" target="_blank" rel="noopener" title="add_filter WordPress function documentation">add_filter</a></code> WordPress function to customize the stylesheet URI and directory URI.</p>


<pre class="wp-block-code"><span><code class="hljs language-php"><span class="hljs-keyword">if</span> (isViteHMRAvailable()) {
  add_filter(
      <span class="hljs-string">'stylesheet_uri'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> </span>{
          <span class="hljs-keyword">return</span> getViteDevServerAddress().<span class="hljs-string">'/sass/style.scss'</span>;
      }
  );

  add_filter(
      <span class="hljs-string">'stylesheet_directory_uri'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> </span>{
          <span class="hljs-keyword">return</span> getViteDevServerAddress().<span class="hljs-string">'/sass'</span>;
      }
  );
}</code></span></pre>


<h4 class="wp-block-heading">A working SCSS stylesheet with HMR</h4>



<p>Start the Vite dev server using <code>npm start</code> now and go to your site. The background color will be red. Modify the <code>style.scss</code> file. Notice how the site applies the latest changes without any kind of reload. Sweet.</p>



<figure class="wp-block-video alignwide"><video height="1116" style="aspect-ratio: 1828 / 1116;" width="1828" controls loop muted src="https://llu.is/wp-content/uploads/2022/12/WordPress-SCSS-HMR.mp4"></video><figcaption class="wp-element-caption">A Twenty Twenty child theme with SASS stylesheet and HMR.</figcaption></figure>



<h4 class="wp-block-heading">Generating production assets</h4>



<p>So far we got the SASS files and the HMR working but we are still missing a piece: building the final <code>style.css</code> file for production usage. This step requires a bit of coding, too, since by default we cannot really customize the destination path of the internal assets that Vite generates.</p>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>We can customize the output path of the input files we provide Vite. However, in our config we are passing a <code>style.ts</code> proxy file as Vite doesn&#8217;t support the actual <code>style.scss</code> as input. This forces us to write a Vite plugin that will copy the <code>style.scss</code> build result to the right path.</p>
</div></div>



<h4 class="wp-block-heading">A Vite plugin to copy build results</h4>



<p>Let&#8217;s open <code>vite.config.ts</code> and write a small <code>CopyFile</code> function. This function will take 2 parameters: the source file name and the destination path and it will return a new Vite plugin. The plugin will copy the source file to the destination.</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript shcb-code-table shcb-line-numbers shcb-wrap-lines"><span class='shcb-loc'><span><span class="hljs-keyword">import</span> fs <span class="hljs-keyword">from</span> <span class="hljs-string">'fs'</span>;
</span></span><span class='shcb-loc'><span><span class="hljs-keyword">import</span> { resolve <span class="hljs-keyword">as</span> resolvePath, dirname } <span class="hljs-keyword">from</span> <span class="hljs-string">'path'</span>;
</span></span><span class='shcb-loc'><span><span class="hljs-keyword">import</span> { Plugin } <span class="hljs-keyword">from</span> <span class="hljs-string">'vite'</span>;
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span><span class="hljs-keyword">const</span> CopyFile = ({
</span></span><span class='shcb-loc'><span>  sourceFileName,
</span></span><span class='shcb-loc'><span>  absolutePathToDestination,
</span></span><span class='shcb-loc'><span>}: {
</span></span><span class='shcb-loc'><span>  sourceFileName: <span class="hljs-built_in">string</span>;
</span></span><span class='shcb-loc'><span>  absolutePathToDestination: <span class="hljs-built_in">string</span>;
</span></span><span class='shcb-loc'><span>}): <span class="hljs-function"><span class="hljs-params">Plugin</span> =&gt;</span> ({
</span></span><span class='shcb-loc'><span>  name: <span class="hljs-string">'copy-file-plugin'</span>,
</span></span><span class='shcb-loc'><span>  writeBundle: <span class="hljs-keyword">async</span> (options, bundle) =&gt; {
</span></span><span class='shcb-loc'><span>    <span class="hljs-keyword">const</span> fileToCopy = <span class="hljs-built_in">Object</span>.values(bundle).find(<span class="hljs-function">(<span class="hljs-params">{ <span class="hljs-params">name</span> }</span>) =&gt;</span> name === sourceFileName);
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span>    <span class="hljs-keyword">if</span> (!fileToCopy) {
</span></span><span class='shcb-loc'><span>      <span class="hljs-keyword">return</span>;
</span></span><span class='shcb-loc'><span>    }
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span>    <span class="hljs-keyword">const</span> sourcePath = resolvePath(options.dir, fileToCopy.fileName);
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span>    <span class="hljs-keyword">await</span> fs.promises.mkdir(dirname(absolutePathToDestination), {
</span></span><span class='shcb-loc'><span>      recursive: <span class="hljs-literal">true</span>,
</span></span><span class='shcb-loc'><span>    });
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span>    <span class="hljs-keyword">await</span> fs.promises.copyFile(sourcePath, absolutePathToDestination);
</span></span><span class='shcb-loc'><span>  },
</span></span><span class='shcb-loc'><span>});
</span></span></code></span></pre>


<p>Next, we can use the plugin in the <code>defineConfig</code> call.</p>



<h4 class="wp-block-heading">Copying files with our Vite plugin</h4>



<p>Vite has a <code>plugins</code> option where we can provide an array of plugins that it will use. Let&#8217;s update the <code>defineConfig</code> call to pass a new instance of our <code>copy-file-plugin</code> plugin configured to copy the <code>style.css</code> file to the right destination:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript shcb-code-table shcb-line-numbers shcb-wrap-lines"><span class='shcb-loc'><span><span class="hljs-keyword">const</span> __dirname = <span class="hljs-keyword">new</span> URL(<span class="hljs-string">'.'</span>, <span class="hljs-keyword">import</span>.meta.url).pathname;
</span></span><span class='shcb-loc'><span>
</span></span><span class='shcb-loc'><span><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> defineConfig({
</span></span><span class='shcb-loc'><span>  plugins: &#91;
</span></span><span class='shcb-loc'><span>    CopyFile({
</span></span><mark class='shcb-loc'><span>      sourceFileName: <span class="hljs-string">'style.css'</span>,
</span></mark><span class='shcb-loc'><span>      absolutePathToDestination: resolvePath(__dirname, <span class="hljs-string">'./style.css'</span>),
</span></span><span class='shcb-loc'><span>    }),
</span></span><span class='shcb-loc'><span>  ],
</span></span><span class='shcb-loc'><span>  build: {
</span></span><span class='shcb-loc'><span>    target: <span class="hljs-string">'modules'</span>,
</span></span><span class='shcb-loc'><span>    outDir: <span class="hljs-string">'.vite-dist'</span>,
</span></span><span class='shcb-loc'><span>    rollupOptions: {
</span></span><span class='shcb-loc'><span>      input: {
</span></span><mark class='shcb-loc'><span>        <span class="hljs-string">'stylesheet'</span>: <span class="hljs-string">'./sass/style.ts'</span>,
</span></mark><span class='shcb-loc'><span>      },
</span></span><span class='shcb-loc'><span>    },
</span></span><span class='shcb-loc'><span>  },
</span></span><span class='shcb-loc'><span>  server: {
</span></span><span class='shcb-loc'><span>    port: <span class="hljs-number">1337</span>,
</span></span><span class='shcb-loc'><span>    host: <span class="hljs-string">'0.0.0.0'</span>,
</span></span><span class='shcb-loc'><span>  },
</span></span><span class='shcb-loc'><span>});
</span></span></code></span></pre>


<p>Note that we are also setting an explicit bundle in <code><a href="https://vitejs.dev/config/build-options.html#build-rollupoptions" target="_blank" rel="noopener" title="Vite documentation about rollupOptions">rollupOptions</a></code>. The value of the key we use – <code>stylesheet</code> – is not important but the name of the entry point file will affect the source file in line 6: we use <code>style.css</code> as the source file name in line 6 because we are using the <code>style.ts</code> proxy file as the entry point for the bundle in line 15.</p>



<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>When Vite builds a file that imports assets that should not be inlined in the resulting JavaScript code (like CSS code), it creates new assets using the entry point file name as an internal name. Eventually, Vite writes those assets using the internal name as a prefix.</p>



<p>Our <code>copy-file-plugin</code> runs right after Vite writes all the assets and compares each asset&#8217;s internal name with the <code>sourceFileName</code>, copying only those files that match.</p>



<p>For our theme&#8217;s stylesheet, Vite generates a file with an internal name <code>style.css</code> because it reuses the name of the entry point, <code>style.ts</code>, but uses the right extension for the file MIME type, <code>css</code>.</p>
</div></div>



<h4 class="wp-block-heading">Building the theme with Vite</h4>



<p>Build the theme running <code>npm run build</code>. If you <a href="#using-vite-in-wordpress" title="Vite setup section, including details about how to set the theme in development mode">run the theme in production mode</a> you will see the right styles. Make any changes to <code>style.scss</code>. To see the result you will need to build the theme again with <code>npm run build</code> and reload the page. Old school development, right? Let&#8217;s enable development mode again by undoing the changes in <code>wp-config.php</code> and let&#8217;s add some TypeScript to our theme.</p>



<h3 class="wp-block-heading" id="using-typescript">Using TypeScript in a WordPress theme</h3>



<div class="wp-block-group is-style-warning"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p><strong>Using TypeScript in a Gutenberg block is possible</strong> but trickier and needs some additional boilerplate to get access to the right types and support multiple blocks without global namespace pollution. <strong>I will explain how in a future post</strong>.</p>
</div></div>



<p>Let&#8217;s write some TypeScript for our theme. We will create an example script that will append an item to the WordPress admin bar if it&#8217;s visible or log a warning otherwise.</p>



<h4 class="wp-block-heading">Writing the TypeScript file</h4>



<p>Create a new <code>ts</code> folder for all our TypeScript sources and a <code>ts/hello-world.ts</code> file with the following content:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-keyword">const</span> appendDebugEntry = (config: {
  adminBarSelector: <span class="hljs-built_in">string</span>;
  debugEntryContainerClasses: <span class="hljs-built_in">Array</span>&lt;<span class="hljs-built_in">string</span>&gt;;
  debugEntryContainerTagName: <span class="hljs-built_in">string</span>;
  debugEntryTagName: <span class="hljs-built_in">string</span>;
  debugEntryText: <span class="hljs-built_in">string</span>;
}): <span class="hljs-function"><span class="hljs-params">void</span> =&gt;</span> {
  <span class="hljs-keyword">const</span> adminBar = <span class="hljs-built_in">document</span>.querySelector(config.adminBarSelector);

  <span class="hljs-keyword">if</span> (!adminBar) {
    <span class="hljs-built_in">console</span>.warn(<span class="hljs-string">'WordPress admin bar is disabled so no debug item has been added'</span>);
    <span class="hljs-keyword">return</span>;
  }

  <span class="hljs-keyword">const</span> debugEntryContainer = <span class="hljs-built_in">document</span>.createElement(config.debugEntryContainerTagName);
  debugEntryContainer.classList.add(...config.debugEntryContainerClasses);

  <span class="hljs-keyword">const</span> debugEntry = <span class="hljs-built_in">document</span>.createElement(config.debugEntryTagName);
  debugEntry.textContent = config.debugEntryText;
  debugEntryContainer.appendChild(debugEntry);

  adminBar.appendChild(debugEntryContainer);
};

appendDebugEntry({
  adminBarSelector: <span class="hljs-string">'#wpadminbar'</span>,
  debugEntryTagName: <span class="hljs-string">'li'</span>,
  debugEntryContainerClasses: &#91;<span class="hljs-string">'ab-top-secondary'</span>, <span class="hljs-string">'ab-top-menu'</span>],
  debugEntryContainerTagName: <span class="hljs-string">'ul'</span>,
  debugEntryText: <span class="hljs-string">'Written from TypeScript'</span>,
});</code></span></pre>


<div class="wp-block-group is-style-detailed-steps"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>Don&#8217;t mind the unnecessary complexity of this script: the goal is to show that TypeScript code is transpiled and a browser-working version shipped to the users.</p>
</div></div>



<p>Next, we have to update our Vite config to build the new file.</p>



<h4 class="wp-block-heading">Adding the TypeScript file to our Vite config</h4>



<p>We must update our <code>vite.config.ts</code> file to:</p>



<ol class="wp-block-list">
<li>Create a new bundle with <code>ts/hello-world.ts</code>.</li>



<li>Copy the bundle build results to <code>js/hello-world.js</code> so we can ship a production version when running <code>npm run build</code>.</li>
</ol>



<p>Specifically, we must add the highlighted lines (7-10 and 18).</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript shcb-code-table shcb-line-numbers"><span class='shcb-loc'><span><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> defineConfig({
</span></span><span class='shcb-loc'><span>  plugins: &#91;
</span></span><span class='shcb-loc'><span>    CopyFilePlugin({
</span></span><span class='shcb-loc'><span>      sourceFileName: <span class="hljs-string">'style.css'</span>,
</span></span><span class='shcb-loc'><span>      absolutePathToDestination: resolvePath(__dirname, <span class="hljs-string">'./style.css'</span>),
</span></span><span class='shcb-loc'><span>    }),
</span></span><mark class='shcb-loc'><span>    CopyFilePlugin({
</span></mark><mark class='shcb-loc'><span>      sourceFileName: <span class="hljs-string">'helloWorld'</span>,
</span></mark><mark class='shcb-loc'><span>      absolutePathToDestination: resolvePath(__dirname, <span class="hljs-string">'./js/hello-world.js'</span>),
</span></mark><mark class='shcb-loc'><span>    }),
</span></mark><span class='shcb-loc'><span>  ],
</span></span><span class='shcb-loc'><span>  build: {
</span></span><span class='shcb-loc'><span>    target: <span class="hljs-string">'modules'</span>,
</span></span><span class='shcb-loc'><span>    outDir: <span class="hljs-string">'.vite-dist'</span>,
</span></span><span class='shcb-loc'><span>    rollupOptions: {
</span></span><span class='shcb-loc'><span>      input: {
</span></span><span class='shcb-loc'><span>        stylesheet: <span class="hljs-string">'./sass/style.ts'</span>,
</span></span><mark class='shcb-loc'><span>        helloWorld: <span class="hljs-string">'./ts/hello-world.ts'</span>,
</span></mark><span class='shcb-loc'><span>      },
</span></span><span class='shcb-loc'><span>    },
</span></span><span class='shcb-loc'><span>  },
</span></span><span class='shcb-loc'><span>  server: {
</span></span><span class='shcb-loc'><span>    port: <span class="hljs-number">1337</span>,
</span></span><span class='shcb-loc'><span>    host: <span class="hljs-string">'0.0.0.0'</span>,
</span></span><span class='shcb-loc'><span>  },
</span></span><span class='shcb-loc'><span>});
</span></span></code></span></pre>


<p>After this, we must update our theme so it enqueues the new TypeScript file.</p>



<h4 class="wp-block-heading">Enqueueing TypeScript in our WordPress theme</h4>



<p>Then we can enqueue the script in our <code>functions.php</code> file. We have to take into account that there are 2 different scripts to be enqueued:</p>



<ol class="wp-block-list">
<li>We want to load the TypeScript source we wrote during development. To do so we request the file from the Vite dev server so it gets transpiled into something our browser can run.</li>



<li>We want to load the production-ready JavaScript output when the theme is running in production. Vite generates this file when running <code>npm run build</code>.</li>
</ol>



<p>We will add an action to <code><a href="https://developer.wordpress.org/reference/hooks/wp_enqueue_scripts/" target="_blank" rel="noopener" title="Documentation for wp_enqueue_scripts WordPress hook">wp_enqueue_scripts</a></code> hook and use <code><a href="https://developer.wordpress.org/reference/functions/wp_enqueue_script/" target="_blank" rel="noopener" title="Documentation for wp_enqueue_script WordPress function">wp_enqueue_script</a></code> to load our script right after we require <code>hmr.php</code> in our <code>functions.php</code>. It&#8217;s important to require <code>hmr.php</code> before because it defines <code>isViteHMRAvailable</code> and <code>getViteDevServerAddress</code> that we need to decide which file to load.</p>


<pre class="wp-block-code"><span><code class="hljs language-php">add_action(
    <span class="hljs-string">'wp_enqueue_scripts'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> </span>{
        $handle = <span class="hljs-string">'hello-world'</span>;
        $dependencies = <span class="hljs-keyword">array</span>();
        $version = <span class="hljs-keyword">null</span>;

        <span class="hljs-keyword">if</span> (isViteHMRAvailable()) {
            loadJSScriptAsESModule($handle);
            wp_enqueue_script(
                $handle,
                getViteDevServerAddress() . <span class="hljs-string">'/ts/hello-world.ts'</span>,
                $dependencies,
                $version
            );
        } <span class="hljs-keyword">else</span> {
            wp_enqueue_script(
                $handle,
                get_stylesheet_directory_uri() . <span class="hljs-string">'/js/hello-world.js'</span>,
                $dependencies,
                $version
            );
        }
    }
);</code></span></pre>


<h4 class="wp-block-heading">A working TypeScript file with HMR</h4>



<p>You can load your site now. If the WordPress admin bar is enabled you will notice a new entry on the trailing side that says <em>«Written from TypeScript»</em>. You will see a warning logged in the console otherwise. Make changes to <code>hello-world.ts</code> and you will notice the page reloads automatically to apply the changes.</p>



<figure class="wp-block-video alignwide"><video height="1116" style="aspect-ratio: 1828 / 1116;" width="1828" controls loop muted src="https://llu.is/wp-content/uploads/2023/01/WordPress-TypeScript-HMR.mp4"></video><figcaption class="wp-element-caption">A Twenty Twenty child theme running a TypeScript script with HMR.</figcaption></figure>



<h2 class="wp-block-heading">Summary and downloads</h2>



<p>You can add SASS, TypeScript, and HMR to any WordPress theme, no matter how old it is. You must do some changes to support modern frontend tooling:</p>



<ol class="wp-block-list">
<li><a href="#using-vite-in-wordpress" title="Section of this post about loading Vite client script in your WordPress theme">Load Vite client script</a>.</li>



<li>Add a <code>package.json</code> and dependencies (<a href="#creating-package-json" title="Section of this post about adding Vite to WordPress">more details</a>).</li>



<li><em>(Optional)</em> <a href="#writing-stylesheet-with-sass" title="Section of this post about writing your stylesheet using SASS">Writing your stylesheet using SASS</a>.</li>



<li><em>(Optional)</em> <a href="#using-typescript" title="Section of this post about using TypeScript in your WordPress theme">Using TypeScript code in your theme</a>.</li>
</ol>



<div class="wp-block-group is-style-tip"><div class="wp-block-group__inner-container is-layout-constrained wp-block-group-is-layout-constrained">
<p>As a starting point, I shared a <a href="https://github.com/Sumolari/wp-hmr-theme" target="_blank" rel="noopener" title="WordPress Twentytwenty child theme with SASS, TypeScript and HMR">twentytwenty child theme with SASS, TypeScript, and HMR</a>, following the steps in this post.</p>
</div></div>



<p>Enjoy a better developer experience with your next WordPress theme and let me know what you think in the comments!</p>The post <a href="https://llu.is/how-to-write-wordpress-themes-with-sass-typescript-and-hmr/">How to write WordPress Themes with SASS, TypeScript, and HMR</a> first appeared on <a href="https://llu.is">Lluís Ulzurrun de Asanza i Sàez</a>.]]></content:encoded>
					
					<wfw:commentRss>https://llu.is/how-to-write-wordpress-themes-with-sass-typescript-and-hmr/feed/</wfw:commentRss>
			<slash:comments>4</slash:comments>
		
		<enclosure url="https://llu.is/wp-content/uploads/2022/12/WordPress-SCSS-HMR.mp4" length="435794" type="video/mp4" />
<enclosure url="https://llu.is/wp-content/uploads/2023/01/WordPress-TypeScript-HMR.mp4" length="421482" type="video/mp4" />

			</item>
	</channel>
</rss>
