1111import io
1212import json
1313from pathlib import Path , PurePosixPath
14+ import posixpath
1415import re
1516import shutil
1617import sys
@@ -348,7 +349,10 @@ def fail(code, detail):
348349 entry ['mapper' ].finding (code , detail )
349350 return None
350351 owned = [[i for i , (node , part , place ) in enumerate (e ['items' ]) if part is None and place in ('head' , 'main' , 'tail' )] for e in entries ]
351- if not owned [0 ] or any (o != owned [0 ] for o in owned ) or owned [0 ] != list (range (owned [0 ][0 ], owned [0 ][- 1 ] + 1 )):
352+ # Contiguous up to the shared parts between them (a drawer or overlay
353+ # before the header): the template renders each part in its place.
354+ between = lambda e , o : [i for i in range (o [0 ], o [- 1 ] + 1 ) if e ['items' ][i ][1 ] is None ] if o else []
355+ if not owned [0 ] or any (o != owned [0 ] for o in owned ) or owned [0 ] != between (entries [0 ], owned [0 ]):
352356 return fail ('article-template-variant' , 'Post pages do not share one contiguous section layout; no article template was derived' )
353357 index = owned [0 ]
354358 if any (shallow (e ['items' ][i ][0 ]) != shallow (entries [0 ]['items' ][i ][0 ]) for e in entries for i in index ):
@@ -358,6 +362,8 @@ def fail(code, detail):
358362 if not differing :
359363 return fail ('article-template-variant' , 'Post pages have identical sections; no article body found' )
360364 body_index = max (differing , key = lambda i : sum (text_size (e ['items' ][i ][0 ]) for e in entries ))
365+ if any (e ['items' ][i ][1 ] for e in entries for i in range (body_index , index [- 1 ] + 1 )):
366+ return fail ('article-template-variant' , 'A shared part sits between the article body and the sections after it; no article template was derived' )
361367
362368 def path (node ):
363369 steps = []
@@ -392,6 +398,15 @@ def head_end(body):
392398 if body_from and any (head_end (b ) != body_from or any (shallow (x ) != shallow (y ) for x , y in zip (
393399 [c for c in b .children [:body_from ] if isinstance (c , Node )], [c for c in bodies [0 ].children [:body_from ] if isinstance (c , Node )])) for b in bodies ):
394400 body_from = 0
401+ # The article's own picture right after the headline (each post its own
402+ # file) belongs to the head too: the template binds it to the post's
403+ # featured image, which the listing's cards then show.
404+ def next_node (body , start ):
405+ return next (((j , c ) for j , c in enumerate (body .children ) if j >= start and isinstance (c , Node )), (None , None ))
406+ if body_from :
407+ heroes = [next_node (b , body_from ) for b in bodies ]
408+ if all (n is not None and n .tag == 'img' and j == heroes [0 ][0 ] for j , n in heroes ) and len ({n .attrs .get ('src' ) for _ , n in heroes }) == len (heroes ):
409+ body_from = heroes [0 ][0 ] + 1
395410 overrides = {id (bodies [0 ]): {'body' : True , ** ({'from' : body_from } if body_from else {})}}
396411 body_ids = {id (b ) for b in bodies }
397412 post_metas = [metas [e ['key' ]] for e in entries ]
@@ -945,13 +960,29 @@ def card_target(self, node):
945960 targets .add (target [5 :].split ('#' )[0 ])
946961 return targets .pop () if len (targets ) == 1 else None
947962
963+ @staticmethod
964+ def rule (node ):
965+ """An empty decorative box (a divider closing a list): no text, media or links."""
966+ return isinstance (node , Node ) and not plain (node ).strip () and not any (
967+ n .tag in ('img' , 'svg' , 'video' , 'iframe' , 'picture' , 'canvas' , 'a' , 'button' , 'input' ) for n in walk (node ))
968+
969+ def card_children (self , node ):
970+ """node's element children less the dividers that open or close the list."""
971+ kids = [c for c in node .children if isinstance (c , Node )]
972+ start , end = 0 , len (kids )
973+ while start < end and self .rule (kids [start ]):
974+ start += 1
975+ while end > start and self .rule (kids [end - 1 ]):
976+ end -= 1
977+ return kids [start :end ]
978+
948979 def card_list (self , node ):
949980 """Post keys when node's children are >= 2 identical cards linking to distinct posts."""
950981 if not self .posts or not isinstance (node , Node ) or node .tag not in ELEMENT_TAGS or node .tag in ('a' , 'svg' ):
951982 return None
952983 if any (isinstance (c , str ) and c .strip () for c in node .children ):
953984 return None
954- cards = [ c for c in node . children if isinstance ( c , Node )]
985+ cards = self . card_children ( node )
955986 if len (cards ) < 2 or len ({shape (c ) for c in cards }) != 1 :
956987 return None
957988 targets = [self .card_target (c ) for c in cards ]
@@ -974,7 +1005,10 @@ def query_block(self, node, targets, cards=None):
9741005 """core/query > core/post-template > first card with element binds (spec 2 D6).
9751006 `cards` defaults to node's children; a lone lead card passes itself."""
9761007 lone = cards is not None
977- cards = cards or [c for c in node .children if isinstance (c , Node )]
1008+ cards = cards or self .card_children (node )
1009+ # Dividers that open or close the list stay the container's own.
1010+ kids = [c for c in node .children if isinstance (c , Node )]
1011+ rules = ([], []) if lone else (kids [:kids .index (cards [0 ])], kids [kids .index (cards [- 1 ]) + 1 :])
9781012 overrides , found = {}, [{} for _ in targets ]
9791013 diff_walk (cards , [self .posts [key ] for key in targets ], overrides , found , self , native_title = False )
9801014 # The summary a card prints becomes that post's excerpt (unless the
@@ -1016,7 +1050,7 @@ def query_block(self, node, targets, cards=None):
10161050 # The container keeps its element, and the cards stay its own children,
10171051 # when the design styles a card by its place among them
10181052 # (`.row:first-child`, `.row:nth-child(4)`, `.row + .row`).
1019- wrap = bool (extra or attrs .get ('anchor' )) or (not lone and self .structural (cards [0 ]))
1053+ wrap = bool (extra or attrs .get ('anchor' ) or rules [ 0 ] or rules [ 1 ] ) or (not lone and self .structural (cards [0 ]))
10201054 template = {'name' : 'core/post-template' , 'attributes' : {** ({'className' : attrs ['className' ]} if attrs .get ('className' ) and not wrap else {}), 'layout' : {'type' : 'default' }}, 'innerBlocks' : [card ] if card else []}
10211055 # Cards listing the posts after the newest ones (a grid below a lead
10221056 # article) skip those, so no post is shown twice.
@@ -1031,7 +1065,8 @@ def query_block(self, node, targets, cards=None):
10311065 # the cards stay the container's own grid or flex items.
10321066 block ['attributes' ]['className' ] = 'h2wp-query-contents'
10331067 template ['attributes' ]['className' ] = 'h2wp-query-contents'
1034- return {'name' : 'h2wp/element' , 'attributes' : {** attrs , 'tagName' : node .tag , ** ({'htmlAttributes' : extra } if extra else {})}, 'innerBlocks' : [block ]}
1068+ around = [[b for b in (self .block (r ) for r in side ) if b ] for side in rules ]
1069+ return {'name' : 'h2wp/element' , 'attributes' : {** attrs , 'tagName' : node .tag , ** ({'htmlAttributes' : extra } if extra else {})}, 'innerBlocks' : around [0 ] + [block ] + around [1 ]}
10351070 return block
10361071
10371072 def container_block (self , node , inner ):
@@ -2829,7 +2864,7 @@ def label(block, child, mapper):
28292864 # Nothing mappable (e.g. only unmapped elements): keep coverage on a placeholder.
28302865 mapper .finding ('article-body-empty' , 'Article body has no mappable content; an empty group carries its source ids' )
28312866 kids = [{'name' : 'core/group' , 'attributes' : {'layout' : {'type' : 'default' }}, 'innerBlocks' : []}]
2832- kids [0 ]['sourceIds' ] = [sections [i ] for i in owned if i < index ] + [section ]
2867+ kids [0 ]['sourceIds' ] = [sections [i ] for i in owned if i < index and not any ( items [ j ][ 1 ] for j in range ( i , index )) ] + [section ]
28332868 kids [- 1 ]['sourceIds' ] = kids [- 1 ].get ('sourceIds' , []) + [sections [i ] for i in owned if i > index ]
28342869 blocks .extend (kids )
28352870 continue
@@ -2839,10 +2874,16 @@ def label(block, child, mapper):
28392874 if block and part :
28402875 page_parts [part ] = {'section' : section , 'blocks' : [block ], 'source' : child , 'active' : has_active (child )}
28412876 block = {'name' : 'core/template-part' , 'attributes' : {'slug' : part }}
2877+ # Template-owned sections just before this part (an overlay
2878+ # ahead of the header) are covered here, in source order: the
2879+ # article body covers only those it is not cut off from by a part.
2880+ body = article ['body_index' ] if owned else - 1
2881+ earlier = [sections [i ] for i in owned if i < index and i < body and not any (items [j ][1 ] for j in range (i , index ))
2882+ and any (items [j ][1 ] for j in range (i , body ))]
28422883 elif block :
28432884 label (block , child , mapper )
28442885 if block :
2845- block ['sourceIds' ] = [section ]
2886+ block ['sourceIds' ] = ( earlier if block [ 'name' ] == 'core/template-part' and owned else []) + [section ]
28462887 blocks .append (block )
28472888 mapped .append ((block , place ))
28482889 frames .append ((key , entry ['frame' ], page_parts ))
@@ -3331,11 +3372,14 @@ def phrasing(node):
33313372
33323373
33333374def fingerprint (node ):
3334- """An element tree's structure without its content: tags, classes,
3335- attribute names and the recorder's naming data-spa-* values. Phrasing
3336- inside text is content."""
3375+ """An element tree's structure without its content: tags, attribute names
3376+ and the recorder's naming data-spa-* values. Phrasing inside text is
3377+ content, and so is an element's class list: restyling an element that
3378+ stays (a Tailwind `bg-soft` -> `bg-deep`) is merged like its text; a
3379+ class that reshapes the plan (a card list no longer alike) still diverges
3380+ in the three-way merge."""
33373381 text = node .tag in TEXT_TAGS or any (isinstance (c , str ) and c .strip () for c in node .children )
3338- return [node .tag , sorted (( node .attrs . get ( 'class' ) or '' ). split ()), sorted ( node . attrs ),
3382+ return [node .tag , sorted (k for k in node .attrs if k != 'class' ),
33393383 sorted ((k , v ) for k , v in node .attrs .items () if k .startswith ('data-spa-' ) and v and SPA_TOKEN .fullmatch (v )),
33403384 [fingerprint (c ) for c in node .children if isinstance (c , Node ) and not (text and phrasing (c ))]]
33413385
@@ -3365,6 +3409,35 @@ def planned(manifest, dist, root):
33653409 'pages' : {p ['key' ]: read (root / 'block-plan/pages' / (p ['key' ] + '.json' )) for p in contract ['pages' ]}, 'dist' : root / 'astro-project/dist' }
33663410
33673411
3412+ CSS_REF = re .compile (r'url\(\s*([\'"]?)([^)\'"]+)\1\s*\)|@import\s+([\'"])([^\'"]+)\3' , re .I )
3413+
3414+
3415+ def carry_contract_files (contract , previous , dist ):
3416+ """Files the reviewed contract loads that the coordinator placed in the
3417+ dist (a self-hosted font sheet and its woff2) and a rebuilt dist lacks:
3418+ copied over from the previous dist, with the local files their CSS
3419+ references. Returns the dist paths carried."""
3420+ wanted = [p for key in ('styles' , 'scripts' , 'headScripts' , 'editorStyles' ) for p in contract .get (key , []) if isinstance (p , str )]
3421+ carried , seen = [], set ()
3422+ while wanted :
3423+ name = posixpath .normpath (wanted .pop (0 ).lstrip ('/' ))
3424+ if name in seen or name .startswith ('../' ) or '/../' in name :
3425+ continue
3426+ seen .add (name )
3427+ source , target = local_file (previous , name ), local_file (dist , name )
3428+ if target .is_file () or not source .is_file ():
3429+ continue
3430+ target .parent .mkdir (parents = True , exist_ok = True )
3431+ shutil .copy2 (source , target )
3432+ carried .append (name )
3433+ if name .endswith ('.css' ):
3434+ for match in CSS_REF .finditer (source .read_text (errors = 'ignore' )):
3435+ ref = (match .group (2 ) or match .group (4 ) or '' ).split ('?' )[0 ].split ('#' )[0 ]
3436+ if ref and not re .match (r'^(?:[a-z]+:|//|#)' , ref , re .I ):
3437+ wanted .append (ref .lstrip ('/' ) if ref .startswith ('/' ) else (PurePosixPath (name ).parent / ref ).as_posix ())
3438+ return carried
3439+
3440+
33683441def refresh (args ):
33693442 """Carry a reviewed plan over an edit of its source dist. Pages whose
33703443 structure is unchanged keep every reviewed edit (a three-way merge with
@@ -3389,6 +3462,7 @@ def refresh(args):
33893462 raise ValueError ('--previous-dist is not the dist this plan was prepared from' )
33903463 after = {p ['key' ]: digest (local_file (dist , p ['file' ]).read_bytes ()) for p in pages }
33913464 reviewed = {k : read (output / 'pages' / (k + '.json' )) for k in keys }
3465+ carried_files = carry_contract_files (contract , previous , dist )
33923466 raw = read (manifest_path )
33933467 with tempfile .TemporaryDirectory () as temp :
33943468 base = planned (raw , previous , Path (temp ) / 'base' )
@@ -3513,7 +3587,7 @@ def refresh(args):
35133587 (manifest_path , merged_manifest , raw ), (state / 'tasks.json' , new_tasks , tasks ), (state / 'checkpoint.json' , new_checkpoint , checkpoint )):
35143588 if value != old :
35153589 write (path , value )
3516- print (json .dumps ({'ok' : True , 'refreshed' : refreshed , 'reopened' : reopened , 'reopenedWhy' : reasons , 'unchanged' : unchanged , 'contract' : contract_state , 'contractDiverged' : diverged ,
3590+ print (json .dumps ({'ok' : True , 'refreshed' : refreshed , 'reopened' : reopened , 'reopenedWhy' : reasons , 'unchanged' : unchanged , 'contract' : contract_state , 'contractDiverged' : diverged , 'carried' : carried_files ,
35173591 'assets' : 'refreshed' if new_checkpoint .get ('assetHashes' ) != checkpoint .get ('assetHashes' ) else 'unchanged' ,
35183592 'distWritten' : copied , 'plannerDrift' : drift ,
35193593 'tasksReopened' : tasks_reopened , 'resolutionsKept' : len (kept ), 'resolutionsDropped' : len (set (resolutions ) - carried ),
0 commit comments