contact: one walk for the rows a repeatable property reads as
Four accessors opened the same three lines before they did their work:
lift the property off its line, and step past the ones that balk.
Now #rows keeps the walk and the block keeps the row, one nil for both:
the line that would not read, and the row with nothing to show.

Assisted-by: Claude Opus 5 via Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WjrPJFvetad8vvCHqjujh
change
commit fbd9c82351ea5a58351578ea9db15f315e49af76
author Claude <noreply@anthropic.com>
date
parent ef7277ac
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 3181354..8d7a359 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -276,10 +276,7 @@ module ProTacts
 
     #: () -> Array[Phone]
     def phones
-      of_line("TEL").filter_map { |line|
-        property = line.property
-        next if property.nil?
-
+      rows("TEL") { |property, line|
         value = text_of(property)
         Phone.new(value:, type: type_of(property), line:) if value
       }
@@ -287,10 +284,7 @@ module ProTacts
 
     #: () -> Array[Email]
     def emails
-      of_line("EMAIL").filter_map { |line|
-        property = line.property
-        next if property.nil?
-
+      rows("EMAIL") { |property, line|
         value = text_of(property)
         Email.new(value:, type: type_of(property), line:) if value
       }
@@ -298,12 +292,7 @@ module ProTacts
 
     #: () -> Array[Address]
     def addresses
-      of_line("ADR").filter_map { |line|
-        property = line.property
-        next if property.nil?
-
-        address_of(property, line:)
-      }
+      rows("ADR") { |property, line| address_of(property, line:) }
     end
 
     # The card's NOTEs (RFC 2426 section 3.6.2), in text form. Every
@@ -315,10 +304,7 @@ module ProTacts
     # which is always the group's (see #vcard).
     #: () -> Array[Note]
     def notes
-      of_line("NOTE").filter_map { |line|
-        property = line.property
-        next if property.nil?
-
+      rows("NOTE") { |property, line|
         value = text_of(property)
         Note.new(value:, line:) if value
       }
@@ -355,11 +341,31 @@ module ProTacts
       }
     end
 
-    # The lines naming `name`, parsed beside their bytes — the walk the
-    # provenance-carrying accessors read from, so a row can carry the
-    # line it was read from as its address. A line that would not
-    # read is a row no form can address and no accessor reads; it
-    # stays enumerable in VCard#lines, served from its bytes.
+    # The rows a repeatable property reads as: the lines naming it,
+    # in the card's own order, each folded by the block into a typed
+    # value, with the nils dropped. Two absences arrive as one that
+    # way, which is what every one of these accessors wants — a line
+    # that would not read is a row no form can address, and a line
+    # the block finds nothing in (a blank value, an ADR blank
+    # throughout) is a row with nothing to show; neither is an error,
+    # and a card is served from its bytes either way. The unreadable
+    # line stays enumerable in VCard#lines regardless.
+    #
+    # The block takes the parsed property and the line it came from,
+    # because a row carries both: the property for what it says, the
+    # line for the address a save names the row by
+    # (docs/plans/2026-09-05-web-card-editor.md).
+    #: [T] (String name) { (VCard::Parser::Property, VCard::Parser::Line) -> T? } -> Array[T]
+    def rows(name)
+      of_line(name).filter_map { |line|
+        property = line.property
+        yield(property, line) unless property.nil?
+      }
+    end
+
+    # The lines naming `name`, parsed beside their bytes — the walk
+    # #rows reads over, and the one accessor-facing read that hands
+    # back the lines that would not parse along with the rest.
     #: (String name) -> Array[VCard::Parser::Line]
     def of_line(name)
       vcard.lines.select { it.names?(name) }