feat: classify imported documents as posts or pages - #789
Conversation
chubes4
left a comment
There was a problem hiding this comment.
Thanks for working on this. The problem and conservative fallback are correct: explicit source intent should win, strong article/date evidence should produce posts, and undated documents should remain pages. This also addresses a real gap we reproduced on a large mixed-content import.
The main issue is where classification happens. By the time SSI's materializer receives the WordPress Site Plan, Blocks Engine has already projected every document into a page route hierarchy, generated synthetic page ancestors, and assigned parent_source_path. Changing post_type during SSI preflight leaves those page-only route and parent assumptions attached to posts.
That causes concrete WordPress behavior problems:
materialize_page()assigns the generated page parent ID aspost_parentfor posts. The parent is commonly apage, whileget_page_by_path( $route, OBJECT, 'post' )only loads post/attachment candidates when resolving ancestry, so nested post conflict detection will miss these rows.get_permalink()for a post follows the site's post permalink structure, not the canonical source route carried by the plan. A source route such as/blog/hellocan therefore materialize at a different URL.- The classifier uses
date()and then writes the same value topost_dateandpost_date_gmt. On a non-UTC PHP timezone,2024-03-12T10:00:00Zis normalized to local time while being labeled as GMT. - Updating every resolved row with
array_search( ..., array_column( ... ) )makes preflight O(n²), which is a regression for large-site imports.
The evidence contract is also incomplete relative to #513. Blocks Engine currently preserves dated <meta> rows, but does not expose usable <article>, <time datetime>, JSON-LD Article/BlogPosting, microdata, or chronological-list membership evidence. Consequently this implementation treats a generic meta name="date" as sufficient by itself, while missing many actual posts under /blog/, /essays/, or /news/.
Could you revise this through the existing ownership boundary?
- Have Blocks Engine extract generic article/publication evidence from source documents.
- Have its WordPress Site Plan projection decide the declared
post_typeand normalized publication timestamp before constructing route hierarchy and operations. - Define canonical route materialization for posts instead of carrying page parents into them.
- Keep SSI focused on validating and materializing the plan's declared type/date.
- Add WordPress-backed coverage for mixed posts/pages, nested source routes, non-UTC dates, page-to-post reclassification, and repeated/resumable imports. The current
get_page_by_path()smoke mock ignores post type and hierarchy, so it cannot prove those behaviors.
This likely becomes a small Blocks Engine producer change plus an SSI consumer change. The classifier and fixtures here are useful groundwork and should be retained where they fit that split.
The branch currently conflicts with main and has no CI results, so it will need a refresh after the contract is updated.
AI assistance: OpenAI GPT-5.6 Sol via OpenCode reviewed the current SSI, Blocks Engine, and WordPress core paths and helped draft these findings. Chris Huber is responsible for the review.
SSI imported every static document as a page. Add a document type classifier that reads plan signals to decide post vs page instead. - New Static_Site_Importer_Document_Type_Classifier keys off dated head meta (article:published_time, pubdate, dc.date, ...), a /YYYY/MM/ URL, or an explicit metadata.post_type from markdown frontmatter. - The producer-declared type wins over consumer-side detection; SSI trusts the plan row's post_type and only falls back to detection for undecided rows (page default). - The plan materializer honors the classified post_type, applies the detected publish date to post_date_gmt (UTC; wp_insert_post derives the site-local post_date), and reconciles across both posts and pages so re-imports stay idempotent. - Posts materialize with no page parent so their permalink follows the site post structure; pages keep the plan's route ancestry. - The theme exporter round-trips imported posts back into the artifact bundle under /post/<slug>/ with shared-slug safekeeping. - The site entrypoint and undated documents stay pages, matching today's behavior, so existing imports are unchanged. Foundation for a follow-up that turns a dated index into a Query Loop. Fixes Automattic#513
Add a dated post nested under a page wrapper (notes/essay.html under notes/index.html) to the WordPress runtime smoke. Proves the real get_page_by_path conflict check resolves the nested post by its own post_type while the wrapper imports as a page, and that the post stays parentless despite the synthetic page ancestor. Refs Automattic#789
1915448 to
4f90e72
Compare
|
Thanks for the thorough review. I addressed the consumer-side issues and refreshed the branch, and I am tracking the producer-side change separately. What changed in this PR:
What I deferred to a separate Blocks Engine producer change, per your ownership boundary:
The classifier and the fixtures are kept here as the consumer side of that split. They validate and materialize the declared type and date, and fall back to detection only when the plan row is undecided. |
Homeboy Lint reports WordPress.Arrays.ArrayDeclarationSpacing and Generic.Formatting.MultipleStatementAlignment on the branch's new code. Expand single-line associative arrays to one value per line and align adjacent assignments. No behavior change.
Summary
The importer treated every static HTML document as a WordPress page. A site with a blog section, like a personal essay index, would flatten dated articles into pages with no publish date, which left no foundation for a later Query Loop on the blog index.
This PR adds generic post vs page classification to the plan materializer. A document is classified as a post when it carries a date signal that survives the Blocks Engine plan projection: a dated head meta tag, a hierarchical
/YYYY/MM/URL, or an explicittype: postin markdown frontmatter. The site entrypoint and undated documents stay pages, which matches current behavior, so existing imports are unchanged. Dated posts get the detected date written topost_date_gmt; WordPress derives the site-localpost_datefrom it.Fixes #513
Changes
includes/class-static-site-importer-document-type-classifier.php(new)Single-purpose final class.
classify()takes one plan page row and returns{ post_type, date, signal }.Signals in precedence order:
post_typeon the plan row (ormetadata.post_type) that differs from the compiler default'page'and is a registered type wins, so the Blocks Engine producer can classify without SSI detection. The compiler already fills this from markdown frontmatter, sotype: postin a.mdfile imports as a post.article:published_time,article:published,pubdate,publishdate,date,dc.date.issued,dc.date,parsely-pub-date,releasedatein head meta, whenstrtotimeparses the content, classifies as a dated post.route.pathmatching/(?:^|\/)\d{4}\/(?:0?[1-9]|1[0-2])(?:/|$)/classifies as a post even without date meta.Why: the materializer is the consumer boundary. It trusts the type and date the producer declared on the plan row and only falls back to consumer-side detection when the row is undecided (
'page', the compiler default). The compiler does not yet carry<article>or JSON-LD into the plan, so head-meta and URL detection is the fallback until the producer change lands.includes/class-static-site-importer-wordpress-site-plan-materializer.phpBefore:
After:
preflight_state()runs the classifier once per page and writespost_type,detected_date, andclassification_signalback into the plan row, so the existing-match check, conflict check, and theme generator all read the same value.reconciled_post()now looks up the reconciliation identity across posts and pages instead of pages only, which keeps re-imports idempotent for documents that were previously classified as posts.static-site-importer.phpLoads the new classifier next to the plan materializer.
tests/smoke-wordpress-site-plan-materializer.phpAdded a
get_post_type_object()stub and classifier scenarios covering: undated stays page, dated meta becomes a dated post,/YYYY/MM/URL becomes a post, post-like URL without a date stays a page, a dated nested article becomes a post, explicit markdowntype: postwins, and a re-import reuses post IDs with stable types.Testing
Test 1: Classifier smoke (automated)
php tests/smoke-wordpress-site-plan-materializer.phpnpm run test:site-plan-materializerResult: all classifier scenarios pass;
npm testshows 44 passed, 0 failed. The two legacy failures (smoke-importer-block.php,smoke-inline-svg-materialization.php) now pass on the rebased branch.Test 2: Manual import
index.html,blog/hello-world.htmlcarrying<meta property="article:published_time" content="2024-03-12T10:00:00Z">,about.html, and2024/03/essay.html.blog/hello-world.htmland2024/03/essay.htmlin the admin Posts list with their publish dates, andindex.html/about.htmlstill Pages.WordPress 6.6+ / PHP 8.1+. No new compatibility issues:
get_post_type_objectandget_postswithpost_type => 'any'are standard on the supported version range.Follow-up (tracked separately)
This PR is the SSI consumer half of the classification split. The producer half, per review, is a separate Blocks Engine change:
<article>,<time datetime>, JSON-LD Article / BlogPosting, microdata).post_typeand normalized publication timestamp in the WordPress Site Plan projection beforeparent_source_path/ route hierarchy is built.SSI will honor a producer-declared
post_typeon the plan row and bump theautomattic/blocks-engine-php-transformercomposer pin once that release lands.