profile: build the plist with a builder, not a template
The template filled its blanks through an escape it rolled by hand;
now Nokogiri writes each string, and the plist parses the same, every key where it would stand.

Assisted-by: Claude Opus 5 via Claude Code
change psoptzrsonmzkzutvxzmrswozmplokrw
commit 44b253f91f4b7f8e62ca2e3d4484d319c9e25ac8
author Alpha Chen <alpha@kejadlen.dev>
date
parent wlosqvrt
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index afc52d8..85a0062 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -53,16 +53,65 @@ module ProTacts
     #: (hostname: String, username: String) -> String
     def self.render(hostname:, username:)
       identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
+      name = account_name
 
-      template % {
-        hostname: escape(hostname),
-        username: escape(username),
-        name: escape(account_name),
-        identifier:,
-        account_identifier: "#{identifier}.account",
-        top_level_uuid: SecureRandom.uuid,
-        payload_uuid: SecureRandom.uuid
-      }
+      builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |x|
+        x.doc.create_internal_subset(
+          "plist",
+          "-//Apple//DTD PLIST 1.0//EN",
+          "http://www.apple.com/DTDs/PropertyList-1.0.dtd",
+        )
+        x.plist(version: "1.0") do
+          x.dict do
+            x.key "PayloadContent"
+            x.array do
+              x.dict do
+                x.key "PayloadType"
+                x.string "com.apple.carddav.account"
+                x.key "PayloadVersion"
+                x.integer 1
+                x.key "PayloadIdentifier"
+                x.string "#{identifier}.account"
+                x.key "PayloadUUID"
+                x.string SecureRandom.uuid
+                x.key "PayloadDisplayName"
+                x.string name
+                x.key "PayloadOrganization"
+                x.string "pro-tacts"
+                x.key "CardDAVAccountDescription"
+                x.string name
+                x.key "CardDAVHostName"
+                x.string hostname
+                x.key "CardDAVUsername"
+                x.string username
+                x.key "CardDAVPassword"
+                x.string "carddav-dev"
+                x.key "CardDAVUseSSL"
+                x.true
+                # CardDAVPrincipalURL is omitted on purpose: no Server Path,
+                # matching the bare-hostname setup the working session used.
+              end
+            end
+            x.key "PayloadDisplayName"
+            x.string "#{name} CardDAV"
+            x.key "PayloadIdentifier"
+            x.string identifier
+            x.key "PayloadOrganization"
+            x.string "pro-tacts"
+            x.key "PayloadRemovalDisallowed"
+            x.false
+            x.key "PayloadScope"
+            x.string "User"
+            x.key "PayloadType"
+            x.string "Configuration"
+            x.key "PayloadUUID"
+            x.string SecureRandom.uuid
+            x.key "PayloadVersion"
+            x.integer 1
+          end
+        end
+      end
+      builder.to_xml
     end
 
     # Picks our profile identifiers out of `profiles list` output so
@@ -77,69 +126,6 @@ module ProTacts
       list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}-[\w.-]+/).uniq #: Array[String]
     end
 
-    #: () -> String
-    def self.template
-      <<~XML
-        <?xml version="1.0" encoding="UTF-8"?>
-        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-        <plist version="1.0">
-        <dict>
-          <key>PayloadContent</key>
-          <array>
-            <dict>
-              <key>PayloadType</key>
-              <string>com.apple.carddav.account</string>
-              <key>PayloadVersion</key>
-              <integer>1</integer>
-              <key>PayloadIdentifier</key>
-              <string>%{account_identifier}</string>
-              <key>PayloadUUID</key>
-              <string>%{payload_uuid}</string>
-              <key>PayloadDisplayName</key>
-              <string>%{name}</string>
-              <key>PayloadOrganization</key>
-              <string>pro-tacts</string>
-              <key>CardDAVAccountDescription</key>
-              <string>%{name}</string>
-              <key>CardDAVHostName</key>
-              <string>%{hostname}</string>
-              <key>CardDAVUsername</key>
-              <string>%{username}</string>
-              <key>CardDAVPassword</key>
-              <string>carddav-dev</string>
-              <key>CardDAVUseSSL</key>
-              <true/>
-            </dict>
-          </array>
-          <key>PayloadDisplayName</key>
-          <string>%{name} CardDAV</string>
-          <key>PayloadIdentifier</key>
-          <string>%{identifier}</string>
-          <key>PayloadOrganization</key>
-          <string>pro-tacts</string>
-          <key>PayloadRemovalDisallowed</key>
-          <false/>
-          <key>PayloadScope</key>
-          <string>User</string>
-          <key>PayloadType</key>
-          <string>Configuration</string>
-          <key>PayloadUUID</key>
-          <string>%{top_level_uuid}</string>
-          <key>PayloadVersion</key>
-          <integer>1</integer>
-        </dict>
-        </plist>
-      XML
-    end
-
-    # CardDAVPrincipalURL is omitted on purpose: no Server Path, matching
-    # the bare-hostname setup the working session used.
-
-    #: (String text) -> String
-    def self.escape(text)
-      text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
-    end
-
     #: () -> String
     def self.unique_hex
       "#{Time.now.utc.strftime('%Y%m%d%H%M%S%L')}#{rand(1 << 16).to_s(16)}"
diff --git a/sig/gems/nokogiri.rbs b/sig/gems/nokogiri.rbs
index 6aa933b..0e31a1d 100644
--- a/sig/gems/nokogiri.rbs
+++ b/sig/gems/nokogiri.rbs
@@ -1,8 +1,8 @@
 # The nokogiri surface pro-tacts uses to read REPORT bodies: parse, drop
 # the namespaces, and pull elements out by xpath. And to write the DAV
-# bodies, through a Builder whose elements are method_missing calls,
-# which DavXml::Namespace#tag makes with send so nothing else needs
-# declaring.
+# bodies and the profile, through a Builder whose elements are
+# method_missing calls: DavXml::Namespace#tag makes its own with send,
+# and the plist elements Profile calls directly are declared below.
 #
 # `root` is declared non-nil because Nokogiri::XML only returns a
 # document without one for input that is not XML at all, and web.rb
@@ -20,6 +20,7 @@ module Nokogiri
     class Document < Node
       def root: () -> Node?
       def remove_namespaces!: () -> Document
+      def create_internal_subset: (String name, String external_id, String system_id) -> untyped
     end
 
     class NodeSet
@@ -31,7 +32,17 @@ module Nokogiri
     class Builder
       def self.new: (?encoding: String) { (Builder) -> void } -> Builder
       def []: (String prefix) -> Builder
+      def doc: () -> Document
       def to_xml: () -> String
+
+      def plist: (version: String) { () -> void } -> void
+      def dict: () { () -> void } -> void
+      def array: () { () -> void } -> void
+      def key: (String text) -> void
+      def string: (String text) -> void
+      def integer: (Integer value) -> void
+      def `true`: () -> void
+      def `false`: () -> void
     end
   end
 end