diff --git a/src/libcalamares/CMakeLists.txt b/src/libcalamares/CMakeLists.txt
index 407dc5f61e0663ad7f80d0cce4810591b27f2e34..2baa3366a5a078d211e8b3f2292b1fedf8b0c21d 100644
--- a/src/libcalamares/CMakeLists.txt
+++ b/src/libcalamares/CMakeLists.txt
@@ -69,6 +69,7 @@ set(libSources
     utils/Retranslator.cpp
     utils/Runner.cpp
     utils/String.cpp
+    utils/StringExpander.cpp
     utils/UMask.cpp
     utils/Variant.cpp
     utils/Yaml.cpp
@@ -146,7 +147,7 @@ calamares_automoc( calamares )
 target_link_libraries(
     calamares
     LINK_PRIVATE ${OPTIONAL_PRIVATE_LIBRARIES}
-    LINK_PUBLIC yamlcpp::yamlcpp Qt5::Core ${OPTIONAL_PUBLIC_LIBRARIES}
+    LINK_PUBLIC yamlcpp::yamlcpp Qt5::Core KF5::CoreAddons ${OPTIONAL_PUBLIC_LIBRARIES}
 )
 
 add_library(Calamares::calamares ALIAS calamares)
diff --git a/src/libcalamares/utils/StringExpander.cpp b/src/libcalamares/utils/StringExpander.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..044e3aee1bcf3a51ec2952880cff4471e939ec49
--- /dev/null
+++ b/src/libcalamares/utils/StringExpander.cpp
@@ -0,0 +1,82 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2022 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ *
+ */
+
+#include "StringExpander.h"
+#include "Logger.h"
+
+namespace Calamares
+{
+namespace String
+{
+
+struct DictionaryExpander::Private
+{
+    QHash< QString, QString > dictionary;
+    QStringList missing;
+};
+
+DictionaryExpander::DictionaryExpander()
+    : KWordMacroExpander( '$' )
+    , d( std::make_unique< Private >() )
+{
+}
+
+DictionaryExpander::~DictionaryExpander() {}
+
+
+void
+DictionaryExpander::insert( const QString& key, const QString& value )
+{
+    d->dictionary.insert( key, value );
+}
+
+void
+DictionaryExpander::clearErrors()
+{
+    d->missing.clear();
+}
+
+bool
+DictionaryExpander::hasErrors() const
+{
+    return !d->missing.isEmpty();
+}
+
+QStringList
+DictionaryExpander::errorNames() const
+{
+    return d->missing;
+}
+
+QString
+DictionaryExpander::expand( QString s )
+{
+    clearErrors();
+    expandMacros( s );
+    return s;
+}
+
+bool
+DictionaryExpander::expandMacro( const QString& str, QStringList& ret )
+{
+    if ( d->dictionary.contains( str ) )
+    {
+        ret << d->dictionary[ str ];
+        return true;
+    }
+    else
+    {
+        d->missing << str;
+        return false;
+    }
+}
+
+}  // namespace String
+}  // namespace Calamares
diff --git a/src/libcalamares/utils/StringExpander.h b/src/libcalamares/utils/StringExpander.h
new file mode 100644
index 0000000000000000000000000000000000000000..afcd03ef7d035b377f3f5cb422576811114dad92
--- /dev/null
+++ b/src/libcalamares/utils/StringExpander.h
@@ -0,0 +1,66 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2022 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ *
+ */
+
+#ifndef UTILS_STRINGEXPANDER_H
+#define UTILS_STRINGEXPANDER_H
+
+#include "DllMacro.h"
+
+#include <KMacroExpander>
+
+#include <QString>
+#include <QStringList>
+
+namespace Calamares
+{
+namespace String
+{
+
+/** @brief Expand variables in a string against a dictionary.
+ *
+ * This class provides a convenience API for building up a dictionary
+ * and using it to expand strings. Use the `expand()` method to
+ * do standard word-based expansion with `$` as macro-symbol.
+ *
+ * Unlike straight-up `KMacroExpander::expandMacros()`, this
+ * provides an API to find out which variables were missing
+ * from the dictionary during expansion. Use `hasErrors()` and
+ * `errorNames()` to find out which variables those were.
+ *
+ * Call `clearErrors()` to reset the stored errors. Calling
+ * `expand()` implicitly clears the errors before starting
+ * a new expansion, as well.
+ */
+class DictionaryExpander : public KWordMacroExpander
+{
+public:
+    DictionaryExpander();
+    virtual ~DictionaryExpander() override;
+
+    void insert( const QString& key, const QString& value );
+
+    void clearErrors();
+    bool hasErrors() const;
+    QStringList errorNames() const;
+
+    QString expand( QString s );
+
+protected:
+    virtual bool expandMacro( const QString& str, QStringList& ret ) override;
+
+private:
+    struct Private;
+    std::unique_ptr< Private > d;
+};
+
+}  // namespace String
+}  // namespace Calamares
+
+#endif
diff --git a/src/libcalamares/utils/Tests.cpp b/src/libcalamares/utils/Tests.cpp
index b4b2251cc6620b057dafbca7a86f18089e688020..49f1b1ed8b29d8f793748d75c284c78f6afe9ce1 100644
--- a/src/libcalamares/utils/Tests.cpp
+++ b/src/libcalamares/utils/Tests.cpp
@@ -15,6 +15,7 @@
 #include "RAII.h"
 #include "Runner.h"
 #include "String.h"
+#include "StringExpander.h"
 #include "Traits.h"
 #include "UMask.h"
 #include "Variant.h"
@@ -75,6 +76,10 @@ private Q_SLOTS:
     void testStringRemoveTrailing_data();
     void testStringRemoveTrailing();
 
+    /** @section Test String expansion. */
+    void testStringMacroExpander_data();
+    void testStringMacroExpander();  // The KF5::CoreAddons bits
+
     /** @section Test Runner directory-manipulation. */
     void testRunnerDirs();
     void testCalculateWorkingDirectory();
@@ -817,6 +822,60 @@ LibCalamaresTests::testStringRemoveTrailing()
     QCOMPARE( string, result );
 }
 
+void
+LibCalamaresTests::testStringMacroExpander_data()
+{
+    QTest::addColumn< QString >( "source" );
+    QTest::addColumn< QString >( "result" );
+    QTest::addColumn< QStringList >( "errors" );
+
+    QTest::newRow( "empty   " ) << QString() << QString() << QStringList {};
+    QTest::newRow( "constant" ) << QStringLiteral( "bunnies!" ) << QStringLiteral( "bunnies!" ) << QStringList {};
+    QTest::newRow( "escaped " ) << QStringLiteral( "$$bun" ) << QStringLiteral( "$bun" )
+                                << QStringList {};  // Double $$ is an escaped $
+    QTest::newRow( "whole   " ) << QStringLiteral( "${ROOT}" ) << QStringLiteral( "wortel" ) << QStringList {};
+    QTest::newRow( "unbraced" ) << QStringLiteral( "$ROOT" ) << QStringLiteral( "wortel" )
+                                << QStringList {};  // Does not need {}
+    QTest::newRow( "bad-var1" ) << QStringLiteral( "${ROOF}" ) << QStringLiteral( "${ROOF}" )
+                                << QStringList { QStringLiteral( "ROOF" ) };  // Not replaced
+    QTest::newRow( "twice   " ) << QStringLiteral( "${ROOT}x${ROOT}" ) << QStringLiteral( "wortelxwortel" )
+                                << QStringList {};
+    QTest::newRow( "bad-var2" ) << QStringLiteral( "${ROOT}x${ROPE}" ) << QStringLiteral( "wortelx${ROPE}" )
+                                << QStringList { QStringLiteral( "ROPE" ) };  // Not replaced
+    // This is a borked string with a "nested" variable. The variable-name-
+    // scanner goes from ${ to the next } and tries to match that.
+    QTest::newRow( "confuse1" ) << QStringLiteral( "${RO${ROOT}" ) << QStringLiteral( "${ROwortel" )
+                                << QStringList { "RO${ROOT" };
+    // This one doesn't have a { for the first name to match with
+    QTest::newRow( "confuse2" ) << QStringLiteral( "$RO${ROOT}" ) << QStringLiteral( "$ROwortel" )
+                                << QStringList { "RO" };
+    // Here we see it just doesn't nest
+    QTest::newRow( "confuse3" ) << QStringLiteral( "${RO${ROOT}}" ) << QStringLiteral( "${ROwortel}" )
+                                << QStringList { "RO${ROOT" };
+}
+
+void
+LibCalamaresTests::testStringMacroExpander()
+{
+    QHash< QString, QString > dict;
+    dict.insert( QStringLiteral( "ROOT" ), QStringLiteral( "wortel" ) );
+
+    Calamares::String::DictionaryExpander d;
+    d.insert( QStringLiteral( "ROOT" ), QStringLiteral( "wortel" ) );
+
+    QFETCH( QString, source );
+    QFETCH( QString, result );
+    QFETCH( QStringList, errors );
+
+    QString km_expanded = KMacroExpander::expandMacros( source, dict, '$' );
+    QCOMPARE( km_expanded, result );
+
+    QString de_expanded = d.expand( source );
+    QCOMPARE( de_expanded, result );
+    QCOMPARE( d.errorNames(), errors );
+    QCOMPARE( d.hasErrors(), !errors.isEmpty() );
+}
+
 static QString
 dirname( const QTemporaryDir& d )
 {