wrapper around a standalone
+ // image, unwrap so the picture becomes a top-level grid cell.
+ // NEVER unwrap
— those are block-content cells (cards,
+ // carousel, columns); unwrapping them collapses the block's row
+ // structure and decorators can't find their image cells.
+ if (isUnwrappableMarkdownParagraph(a)) {
+ a.parentElement.replaceWith(picture);
+ } else {
+ a.replaceWith(picture);
+ }
+ });
+}
+
+// Register the DM dispatcher for createOptimizedPicture interop.
+// The aem.js patch checks for this hook and delegates DM URLs to our
+// renderer, so block decorators that call createOptimizedPicture(img.src, ...)
+// on Scene7 IS/Image template URLs or DM Open API URLs preserve their
+// query parameters instead of having them stripped by the path-only
+// optimizer in aem.js. No-op when the auto-block is not installed
+// (hook unregistered → aem.js falls through to standard logic).
+//
+// Returning null for non-DM URLs lets the caller (createOptimizedPicture)
+// fall through to its standard path-only optimization. This is the
+// regression guard for non-DM images on the same page.
+window.__dmRender__ = (src, alt) => {
+ const family = detectDynamicMediaUrl(src);
+ if (!family) return null;
+ return family === 'scene7'
+ ? renderScene7Picture(src, alt)
+ : renderDmOpenApiPicture(src, alt);
+};
+
+// --- END DM/Scene7 auto-block ---
+
function getExperimentationContext() {
return {
getAllMetadata, getMetadata, loadCSS, loadScript, sampleRUM, toCamelCase, toClassName,
@@ -126,6 +345,7 @@ async function loadFragments(section) {
function buildAutoBlocks(main) {
try {
buildEmbedBlocks(main);
+ buildDynamicMediaImages(main);
} catch (error) {
console.error('Auto Blocking failed', error);
}
diff --git a/styles/brand.css b/styles/brand.css
new file mode 100644
index 0000000..85cd544
--- /dev/null
+++ b/styles/brand.css
@@ -0,0 +1,127 @@
+/* ==========================================================================
+ Total Wireless brand tokens (migrated from totalwireless.com/m/home)
+ Overrides the boilerplate design tokens with the source site's palette,
+ typography, and spacing. GalanoGrotesque is proprietary; Montserrat
+ (already bundled) is the closest geometric-sans fallback.
+ ========================================================================== */
+
+:root,
+:host {
+ /* brand palette */
+ --tw-navy: #000330;
+ --tw-cream: #fff1cc;
+ --tw-text: #202020;
+ --tw-white: #fff;
+
+ /* fonts — GalanoGrotesque with Montserrat fallback */
+ --heading-font-family: "GalanoGrotesque", montserrat, "Trebuchet MS", sans-serif;
+ --body-font-family: "GalanoGrotesque", montserrat, "Trebuchet MS", sans-serif;
+ --font-family: var(--body-font-family);
+ --font-family-serif: var(--heading-font-family);
+
+ /* semantic colors mapped to brand */
+ --background-color: var(--tw-white);
+ --text-color: var(--tw-text);
+ --link-color: var(--tw-navy);
+ --link-hover-color: var(--tw-navy);
+ --color-text: var(--tw-text);
+ --color-link: var(--tw-navy);
+ --color-link-hover: var(--tw-navy);
+ --color-brand: var(--tw-navy);
+ --color-accent: var(--tw-cream);
+ --color-light: var(--tw-white);
+ --color-dark: var(--tw-navy);
+ --color-shaded: var(--tw-cream);
+ --light-color: var(--tw-cream);
+ --dark-color: var(--tw-navy);
+
+ /* heading sizes (desktop values from source) */
+ --heading-font-size-xxl: 48px;
+ --heading-font-size-xl: 48px;
+ --heading-font-size-l: 24px;
+ --heading-font-size-m: 20px;
+ --heading-font-size-s: 18px;
+ --heading-font-size-xs: 16px;
+ --heading-line-height: 1.25;
+
+ /* body */
+ --body-font-size-m: 16px;
+ --body-line-height: 1.5;
+
+ /* spacing */
+ --section-padding: 24px;
+ --nav-height: 64px;
+}
+
+@media (width >= 600px) {
+ :root,
+ :host {
+ --heading-font-size-xxl: 48px;
+ --heading-font-size-xl: 48px;
+ }
+}
+
+/* ==========================================================================
+ Brand button + link overrides (Total Wireless: solid navy pill buttons,
+ navy underlined links). Overrides the boilerplate rust/gold gradient
+ buttons defined in styles.css.
+ ========================================================================== */
+
+main a.button:any-link,
+main button.button {
+ border: 1px solid var(--tw-navy);
+ border-radius: 40px;
+ padding: 14px 32px;
+ font-weight: 700;
+ font-size: 16px;
+ letter-spacing: normal;
+ text-transform: none;
+ background-color: var(--tw-navy);
+ background-image: none;
+ color: var(--tw-white);
+}
+
+main a.button.primary:any-link,
+main button.button.primary {
+ border-color: var(--tw-navy);
+ background-color: var(--tw-navy);
+ color: var(--tw-white);
+}
+
+main a.button:any-link:hover,
+main a.button:any-link:focus-visible,
+main button.button:hover,
+main button.button:focus-visible,
+main a.button.primary:hover,
+main a.button.primary:focus-visible {
+ background-color: #1a1d52;
+ border-color: #1a1d52;
+ color: var(--tw-white);
+ box-shadow: none;
+ transform: none;
+}
+
+/* Secondary = outline navy */
+main a.button.secondary:any-link,
+main button.button.secondary {
+ border: 1.5px solid var(--tw-navy);
+ background-color: transparent;
+ color: var(--tw-navy);
+}
+
+main a.button.secondary:hover,
+main a.button.secondary:focus-visible,
+main button.button.secondary:hover,
+main button.button.secondary:focus-visible {
+ border-color: var(--tw-navy);
+ background-color: rgb(0 3 48 / 6%);
+ color: var(--tw-navy);
+ transform: none;
+ box-shadow: none;
+}
+
+/* Default (non-block) links */
+main a:not(.button):any-link {
+ color: var(--tw-navy);
+}
+
diff --git a/styles/styles.css b/styles/styles.css
index a8f1304..1bf20f3 100644
--- a/styles/styles.css
+++ b/styles/styles.css
@@ -1,3 +1,5 @@
+@import url('brand.css');
+
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
@@ -730,3 +732,24 @@ main > .section.warm {
background-clip: text;
color: transparent;
}
+
+/* ==========================================================================
+ Total Wireless brand token re-assertion.
+ styles.css :root (above) redefines --heading-font-family etc. AFTER the
+ @import 'brand.css', so brand values are re-declared on html:root —
+ higher specificity than the plain :root above AND later in source, so it
+ wins. Keep in sync with brand.css.
+ ========================================================================== */
+html:root {
+ --heading-font-family: "GalanoGrotesque", montserrat, "Trebuchet MS", sans-serif;
+ --body-font-family: "GalanoGrotesque", montserrat, "Trebuchet MS", sans-serif;
+ --font-family: var(--body-font-family);
+ --font-family-serif: var(--heading-font-family);
+ --text-color: var(--tw-text);
+ --link-color: var(--tw-navy);
+ --link-hover-color: var(--tw-navy);
+ --background-color: var(--tw-white);
+ --color-text: var(--tw-text);
+ --color-link: var(--tw-navy);
+ --color-link-hover: var(--tw-navy);
+}
diff --git a/tools/importer/import-home.js b/tools/importer/import-home.js
new file mode 100644
index 0000000..c908f27
--- /dev/null
+++ b/tools/importer/import-home.js
@@ -0,0 +1,220 @@
+/* eslint-disable */
+/* global WebImporter */
+
+// PARSER IMPORTS
+import heroPromoParser from './parsers/hero-promo.js';
+import columnsPromoParser from './parsers/columns-promo.js';
+import cardsOptionsParser from './parsers/cards-options.js';
+import columnsBenefitsParser from './parsers/columns-benefits.js';
+import cardsPlansParser from './parsers/cards-plans.js';
+import teaserPromoParser from './parsers/teaser-promo.js';
+import productGridPicksParser from './parsers/product-grid-picks.js';
+import cardsBentoParser from './parsers/cards-bento.js';
+import faqHomeParser from './parsers/faq-home.js';
+
+// TRANSFORMER IMPORTS
+import cleanupTransformer from './transformers/totalwireless-cleanup.js';
+import sectionsTransformer from './transformers/totalwireless-sections.js';
+import dmImagesTransformer from './transformers/totalwireless-dm-images.js';
+
+// PARSER REGISTRY
+const parsers = {
+ 'hero-promo': heroPromoParser,
+ 'columns-promo': columnsPromoParser,
+ 'cards-options': cardsOptionsParser,
+ 'columns-benefits': columnsBenefitsParser,
+ 'cards-plans': cardsPlansParser,
+ 'teaser-promo': teaserPromoParser,
+ 'product-grid-picks': productGridPicksParser,
+ 'cards-bento': cardsBentoParser,
+ 'faq-home': faqHomeParser,
+};
+
+// PAGE TEMPLATE CONFIGURATION - Embedded from page-templates.json
+const PAGE_TEMPLATE = {
+ name: 'home',
+ description: 'Total Wireless mobile home page - hero, plan/product grids, feature promos, and supporting content sections',
+ urls: [
+ 'https://www.totalwireless.com/m/home',
+ ],
+ blocks: [
+ {
+ name: 'hero-promo',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(4)',
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(21)',
+ ],
+ },
+ {
+ name: 'columns-promo',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(6)',
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(19)',
+ ],
+ },
+ {
+ name: 'cards-options',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(8)',
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(28)',
+ ],
+ },
+ {
+ name: 'columns-benefits',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(10)',
+ ],
+ },
+ {
+ name: 'cards-plans',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(12)',
+ ],
+ },
+ {
+ name: 'teaser-promo',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(14)',
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(23)',
+ ],
+ },
+ {
+ name: 'product-grid-picks',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(17)',
+ ],
+ },
+ {
+ name: 'cards-bento',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(24)',
+ ],
+ },
+ {
+ name: 'faq-home',
+ instances: [
+ 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(27)',
+ ],
+ },
+ ],
+ sections: [
+ { id: 'section-1', name: 'section-1', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(4)', style: null, blocks: ['hero-promo'], defaultContent: [] },
+ { id: 'section-2', name: 'section-2', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(6)', style: null, blocks: ['columns-promo'], defaultContent: [] },
+ { id: 'section-3', name: 'section-3', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(8)', style: null, blocks: ['cards-options'], defaultContent: [] },
+ { id: 'section-4', name: 'section-4', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(10)', style: null, blocks: ['columns-benefits'], defaultContent: [] },
+ { id: 'section-5', name: 'section-5', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(12)', style: null, blocks: ['cards-plans'], defaultContent: [] },
+ { id: 'section-6', name: 'section-6', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(14)', style: null, blocks: ['teaser-promo'], defaultContent: [] },
+ { id: 'section-7', name: 'section-7', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn:nth-child(15)', style: null, blocks: [], defaultContent: ['body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn:nth-child(15)'] },
+ { id: 'section-8', name: 'section-8', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(17)', style: null, blocks: ['product-grid-picks'], defaultContent: [] },
+ { id: 'section-9', name: 'section-9', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(19)', style: null, blocks: ['columns-promo'], defaultContent: [] },
+ { id: 'section-10', name: 'section-10', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(21)', style: null, blocks: ['hero-promo'], defaultContent: [] },
+ { id: 'section-11', name: 'section-11', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(23)', style: null, blocks: ['teaser-promo'], defaultContent: [] },
+ { id: 'section-12', name: 'section-12', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(24)', style: null, blocks: ['cards-bento'], defaultContent: [] },
+ { id: 'section-13', name: 'section-13', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(26)', style: null, blocks: [], defaultContent: ['body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(26)'] },
+ { id: 'section-14', name: 'section-14', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(27)', style: null, blocks: ['faq-home'], defaultContent: [] },
+ { id: 'section-15', name: 'section-15', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(28)', style: null, blocks: ['cards-options'], defaultContent: [] },
+ ],
+};
+
+// TRANSFORMER REGISTRY
+// cleanup runs first (beforeTransform), sections + dm-images run in afterTransform.
+const transformers = [
+ cleanupTransformer,
+ ...(PAGE_TEMPLATE.sections && PAGE_TEMPLATE.sections.length > 1 ? [sectionsTransformer] : []),
+ dmImagesTransformer,
+];
+
+/**
+ * Execute all page transformers for a specific hook
+ */
+function executeTransformers(hookName, element, payload) {
+ const enhancedPayload = { ...payload, template: PAGE_TEMPLATE };
+ transformers.forEach((transformerFn) => {
+ try {
+ transformerFn.call(null, hookName, element, enhancedPayload);
+ } catch (e) {
+ console.error(`Transformer failed at ${hookName}:`, e);
+ }
+ });
+}
+
+/**
+ * Find all blocks on the page based on the embedded template configuration
+ */
+function findBlocksOnPage(document, template) {
+ const pageBlocks = [];
+ template.blocks.forEach((blockDef) => {
+ blockDef.instances.forEach((selector) => {
+ const elements = document.querySelectorAll(selector);
+ if (elements.length === 0) {
+ console.warn(`Block "${blockDef.name}" selector not found: ${selector}`);
+ }
+ elements.forEach((element) => {
+ pageBlocks.push({
+ name: blockDef.name,
+ selector,
+ element,
+ section: blockDef.section || null,
+ });
+ });
+ });
+ });
+ console.log(`Found ${pageBlocks.length} block instances on page`);
+ return pageBlocks;
+}
+
+export default {
+ transform: (payload) => {
+ const {
+ document, url, html, params,
+ } = payload;
+
+ const main = document.body;
+
+ // 1. beforeTransform (initial cleanup)
+ executeTransformers('beforeTransform', main, payload);
+
+ // 2. Find blocks on page
+ const pageBlocks = findBlocksOnPage(document, PAGE_TEMPLATE);
+
+ // 3. Parse each block using registered parsers
+ pageBlocks.forEach((block) => {
+ if (!block.element.parentNode) return; // Already replaced by earlier parser
+ const parser = parsers[block.name];
+ if (parser) {
+ try {
+ parser(block.element, { document, url, params });
+ } catch (e) {
+ console.error(`Failed to parse ${block.name} (${block.selector}):`, e);
+ }
+ } else {
+ console.warn(`No parser found for block: ${block.name}`);
+ }
+ });
+
+ // 4. afterTransform (section breaks + DM image anchors + final cleanup)
+ executeTransformers('afterTransform', main, payload);
+
+ // 5. WebImporter built-in rules
+ const hr = document.createElement('hr');
+ main.appendChild(hr);
+ WebImporter.rules.createMetadata(main, document);
+ WebImporter.rules.transformBackgroundImages(main, document);
+ WebImporter.rules.adjustImageUrls(main, url, params.originalURL);
+
+ // 6. Generate sanitized path
+ const path = WebImporter.FileUtils.sanitizePath(
+ new URL(params.originalURL).pathname.replace(/\/$/, '').replace(/\.html$/, ''),
+ );
+
+ return [{
+ element: main,
+ path,
+ report: {
+ title: document.title,
+ template: PAGE_TEMPLATE.name,
+ blocks: pageBlocks.map((b) => b.name),
+ },
+ }];
+ },
+};
diff --git a/tools/importer/page-templates.json b/tools/importer/page-templates.json
new file mode 100644
index 0000000..8dc501d
--- /dev/null
+++ b/tools/importer/page-templates.json
@@ -0,0 +1,228 @@
+{
+ "metadata": {
+ "projectType": "xwalk"
+ },
+ "templates": [
+ {
+ "name": "home",
+ "urls": [
+ "https://www.totalwireless.com/m/home"
+ ],
+ "representativeUrl": "https://www.totalwireless.com/m/home",
+ "coverageGaps": [],
+ "description": "Total Wireless mobile home page - hero, plan/product grids, feature promos, and supporting content sections",
+ "blocks": [
+ {
+ "name": "hero-promo",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(4)",
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(21)"
+ ]
+ },
+ {
+ "name": "columns-promo",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(6)",
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(19)"
+ ]
+ },
+ {
+ "name": "cards-options",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(8)",
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(28)"
+ ]
+ },
+ {
+ "name": "columns-benefits",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(10)"
+ ]
+ },
+ {
+ "name": "cards-plans",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(12)"
+ ]
+ },
+ {
+ "name": "teaser-promo",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(14)",
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(23)"
+ ]
+ },
+ {
+ "name": "product-grid-picks",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(17)"
+ ]
+ },
+ {
+ "name": "cards-bento",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(24)"
+ ]
+ },
+ {
+ "name": "faq-home",
+ "instances": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(27)"
+ ]
+ }
+ ],
+ "sections": [
+ {
+ "id": "section-1",
+ "name": "section-1",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(4)",
+ "style": null,
+ "blocks": [
+ "hero-promo"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-2",
+ "name": "section-2",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(6)",
+ "style": null,
+ "blocks": [
+ "columns-promo"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-3",
+ "name": "section-3",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(8)",
+ "style": null,
+ "blocks": [
+ "cards-options"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-4",
+ "name": "section-4",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(10)",
+ "style": null,
+ "blocks": [
+ "columns-benefits"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-5",
+ "name": "section-5",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(12)",
+ "style": null,
+ "blocks": [
+ "cards-plans"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-6",
+ "name": "section-6",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(14)",
+ "style": null,
+ "blocks": [
+ "teaser-promo"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-7",
+ "name": "section-7",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn:nth-child(15)",
+ "style": null,
+ "blocks": [],
+ "defaultContent": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn:nth-child(15)"
+ ]
+ },
+ {
+ "id": "section-8",
+ "name": "section-8",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(17)",
+ "style": null,
+ "blocks": [
+ "product-grid-picks"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-9",
+ "name": "section-9",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(19)",
+ "style": null,
+ "blocks": [
+ "columns-promo"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-10",
+ "name": "section-10",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(21)",
+ "style": null,
+ "blocks": [
+ "hero-promo"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-11",
+ "name": "section-11",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(23)",
+ "style": null,
+ "blocks": [
+ "teaser-promo"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-12",
+ "name": "section-12",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(24)",
+ "style": null,
+ "blocks": [
+ "cards-bento"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-13",
+ "name": "section-13",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(26)",
+ "style": null,
+ "blocks": [],
+ "defaultContent": [
+ "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(26)"
+ ]
+ },
+ {
+ "id": "section-14",
+ "name": "section-14",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(27)",
+ "style": null,
+ "blocks": [
+ "faq-home"
+ ],
+ "defaultContent": []
+ },
+ {
+ "id": "section-15",
+ "name": "section-15",
+ "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(28)",
+ "style": null,
+ "blocks": [
+ "cards-options"
+ ],
+ "defaultContent": []
+ }
+ ]
+ }
+ ]
+}
diff --git a/tools/importer/parsers/cards-bento.js b/tools/importer/parsers/cards-bento.js
new file mode 100644
index 0000000..bf74938
--- /dev/null
+++ b/tools/importer/parsers/cards-bento.js
@@ -0,0 +1,108 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for cards-bento. Base block: cards (block/v1/block container).
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / bento-box cards)
+ * Model (blocks/cards-bento/_cards-bento.json):
+ * cards-bento (container) → cards-bento-item { image (reference), imageAlt (collapsed), text (richtext) }.
+ * Library convention: each card row has TWO cells — cell 1 = image (image/imageAlt),
+ * cell 2 = text (leading label paragraph, heading, description, CTA link). Empty cell still present.
+ *
+ * Each ".bento-box-card" is one card: optional image + eyebrow label + title + description + CTA.
+ * A leading section heading ("Get more with Total Wireless") is emitted as default content.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+
+ // Exact card containers: class token "bento-box-card" (not the __image/__content/-wrapper variants).
+ let items = [...element.querySelectorAll('[class~="bento-box-card"]')];
+ if (!items.length) {
+ // Fallback: leaf elements whose class merely contains the token.
+ items = [...element.querySelectorAll('[class*="bento-box-card"]')]
+ .filter((el) => !el.querySelector('[class*="bento-box-card"]'));
+ }
+ if (!items.length) {
+ element.replaceWith(...element.childNodes);
+ return;
+ }
+
+ const cells = [];
+ const usedHeadings = new Set();
+ const seen = new Set();
+
+ items.forEach((item) => {
+ const seenImg = new Set();
+ let image = null;
+ item.querySelectorAll('img').forEach((img) => {
+ const k = (img.getAttribute('src') || '').split('?')[0];
+ if (k && !seenImg.has(k)) { seenImg.add(k); if (!image) image = img; }
+ });
+
+ const eyebrow = item.querySelector('[class*="eyebrow"]');
+ const headingEl = [...item.querySelectorAll('h1, h2, h3, h4, h5, h6')].find((h) => norm(h.textContent))
+ || item.querySelector('[class*="card-title"]');
+ const descEl = item.querySelector('[class*="description"]');
+ const link = item.querySelector('a[href]');
+
+ const eyebrowText = norm(eyebrow && eyebrow.textContent);
+ const headingText = norm(headingEl && headingEl.textContent);
+ const descText = norm(descEl && descEl.textContent);
+
+ if (!headingText && !eyebrowText && !image) return;
+
+ // Dedupe responsive duplicates.
+ const key = `${eyebrowText}|${headingText}|${descText}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ if (headingText) usedHeadings.add(headingText);
+
+ // Cell 1 — image (reference). Empty cell (no hint) if no image.
+ const imageCell = document.createDocumentFragment();
+ if (image) {
+ imageCell.appendChild(document.createComment(' field:image '));
+ imageCell.appendChild(image);
+ }
+
+ // Cell 2 — text (richtext): eyebrow label, heading, description, CTA.
+ const textCell = document.createDocumentFragment();
+ textCell.appendChild(document.createComment(' field:text '));
+ if (eyebrowText && eyebrowText !== headingText) {
+ const p = document.createElement('p');
+ p.textContent = eyebrowText;
+ textCell.appendChild(p);
+ }
+ if (headingText) {
+ const h = document.createElement(headingEl && /^H[1-6]$/.test(headingEl.tagName) ? headingEl.tagName.toLowerCase() : 'h3');
+ h.textContent = headingText;
+ textCell.appendChild(h);
+ }
+ if (descText && descText !== headingText) {
+ const p = document.createElement('p');
+ p.textContent = descText;
+ textCell.appendChild(p);
+ }
+ if (link) {
+ const wrap = document.createElement('p');
+ const a = document.createElement('a');
+ a.setAttribute('href', link.getAttribute('href'));
+ a.textContent = norm(link.textContent) || 'Learn more';
+ wrap.appendChild(a);
+ textCell.appendChild(wrap);
+ }
+
+ cells.push([imageCell, textCell]);
+ });
+
+ const leadHeading = [...element.querySelectorAll('h1, h2')]
+ .find((h) => norm(h.textContent) && !usedHeadings.has(norm(h.textContent)));
+
+ const block = WebImporter.Blocks.createBlock(document, { name: 'cards-bento', cells });
+
+ if (leadHeading) {
+ const h = document.createElement(leadHeading.tagName.toLowerCase());
+ h.textContent = norm(leadHeading.textContent);
+ element.replaceWith(h, block);
+ } else {
+ element.replaceWith(block);
+ }
+}
diff --git a/tools/importer/parsers/cards-options.js b/tools/importer/parsers/cards-options.js
new file mode 100644
index 0000000..e09cf6e
--- /dev/null
+++ b/tools/importer/parsers/cards-options.js
@@ -0,0 +1,110 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for cards-options. Base block: cards (block/v1/block container).
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment)
+ * Model (blocks/cards-options/_cards-options.json):
+ * cards-options (container) → cards-options-item { image (reference), imageAlt (collapsed), text (richtext) }.
+ * Library convention: each card row has TWO cells — cell 1 = image (image/imageAlt),
+ * cell 2 = text (title/description/CTA). An empty cell must still be present.
+ *
+ * Cross-page resilience: instance A uses ".tilelet-section" (icon-forward heading-only options),
+ * instance B uses nested ".content-card-wrapper" contact cards (eyebrow label + copy + CTA).
+ * The item finder tries several card container patterns and keeps only leaf items.
+ * A leading section heading (e.g. "How would you like to start?") is emitted as default content.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+
+ const findItems = (root) => {
+ const sels = ['[class*="tilelet-section"]', '[class*="content-card-wrapper"]', '[class*="card-wrapper"]'];
+ for (const s of sels) {
+ let found = [...root.querySelectorAll(s)];
+ // Keep only leaf items (no nested item of the same selector inside).
+ found = found.filter((el) => !el.querySelector(s));
+ found = found.filter((el) => el.querySelector('h1,h2,h3,h4,h5,h6') || el.querySelector('[class*="eyebrow"], [class*="title"]'));
+ if (found.length >= 2) return found;
+ }
+ return [...root.querySelectorAll('li')].filter((el) => el.querySelector('h1,h2,h3,h4,h5,h6'));
+ };
+
+ const items = findItems(element);
+ if (!items.length) {
+ element.replaceWith(...element.childNodes);
+ return;
+ }
+
+ const cells = [];
+ const usedHeadings = new Set();
+ const seenRows = new Set();
+
+ items.forEach((item) => {
+ // Optional icon/image.
+ const seenImg = new Set();
+ let image = null;
+ item.querySelectorAll('img').forEach((img) => {
+ const k = (img.getAttribute('src') || '').split('?')[0];
+ if (k && !seenImg.has(k)) { seenImg.add(k); if (!image) image = img; }
+ });
+
+ const headingEl = [...item.querySelectorAll('h1, h2, h3, h4, h5, h6')].find((h) => norm(h.textContent));
+ const eyebrowEl = item.querySelector('[class*="eyebrow"]');
+ const titleEl = item.querySelector('[class*="card-title"], [class*="headline"]');
+ const descEl = item.querySelector('[class*="description"]');
+ const link = item.querySelector('a[href]');
+
+ const label = norm((headingEl && headingEl.textContent) || (eyebrowEl && eyebrowEl.textContent));
+ let body = norm((descEl && descEl.textContent) || (titleEl && titleEl.textContent));
+ if (body === label) body = '';
+
+ // Dedupe responsive duplicate cards.
+ const rowKey = `${label}|${body}`;
+ if (seenRows.has(rowKey)) return;
+ seenRows.add(rowKey);
+ if (label) usedHeadings.add(label);
+
+ // Cell 1 — image (reference). Empty cell if no icon.
+ const imageCell = document.createDocumentFragment();
+ if (image) {
+ imageCell.appendChild(document.createComment(' field:image '));
+ imageCell.appendChild(image);
+ }
+
+ // Cell 2 — text (richtext): heading, optional description, optional CTA link.
+ const textCell = document.createDocumentFragment();
+ textCell.appendChild(document.createComment(' field:text '));
+ if (label) {
+ const h = document.createElement(headingEl ? headingEl.tagName.toLowerCase() : 'h3');
+ h.textContent = label;
+ textCell.appendChild(h);
+ }
+ if (body) {
+ const p = document.createElement('p');
+ p.textContent = body;
+ textCell.appendChild(p);
+ }
+ if (link) {
+ const wrap = document.createElement('p');
+ const a = document.createElement('a');
+ a.setAttribute('href', link.getAttribute('href'));
+ a.textContent = norm(link.textContent) || 'Learn more';
+ wrap.appendChild(a);
+ textCell.appendChild(wrap);
+ }
+
+ cells.push([imageCell, textCell]);
+ });
+
+ const leadHeading = [...element.querySelectorAll('h1, h2')]
+ .find((h) => norm(h.textContent) && !usedHeadings.has(norm(h.textContent)));
+
+ const block = WebImporter.Blocks.createBlock(document, { name: 'cards-options', cells });
+
+ if (leadHeading) {
+ const h = document.createElement(leadHeading.tagName.toLowerCase());
+ h.textContent = norm(leadHeading.textContent);
+ element.replaceWith(h, block);
+ } else {
+ element.replaceWith(block);
+ }
+}
diff --git a/tools/importer/parsers/cards-plans.js b/tools/importer/parsers/cards-plans.js
new file mode 100644
index 0000000..0ca497c
--- /dev/null
+++ b/tools/importer/parsers/cards-plans.js
@@ -0,0 +1,99 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for cards-plans. Base block: cards (block/v1/block container).
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / plan cards)
+ * Model (blocks/cards-plans/_cards-plans.json):
+ * cards-plans (container) → cards-plans-item { name (richtext), price (text), features (richtext) }.
+ * Container block: each plan card is one row; child properties are columns, so each row has
+ * THREE cells matching the item model fields: name, price, features. The features cell holds the
+ * feature bullet list plus the Select CTA link. Generated for xwalk: one field hint per cell.
+ *
+ * Notes:
+ * - price is normalised to a clean base per-line amount + "/mo" (block JS reads this and
+ * multiplies by the selected number of lines).
+ * - feature bullets are deduped and stripped of trailing "Check footnote…" screen-reader text.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+ const cleanFeature = (el) => norm(el.textContent).replace(/\s*Check footnote.*$/i, '').trim();
+
+ let items = [...element.querySelectorAll('[class*="plan-card"]')];
+ // Keep only leaf plan cards (avoid wrapper containers nesting other plan cards).
+ items = items.filter((el) => !el.querySelector('[class*="plan-card"]'));
+ if (!items.length) {
+ element.replaceWith(...element.childNodes);
+ return;
+ }
+
+ const cells = [];
+ const seen = new Set();
+
+ items.forEach((item) => {
+ const nameEl = item.querySelector('[class*="plan_title"], [class*="plan-title"], h2, h3');
+ const name = norm(nameEl && nameEl.textContent);
+
+ // Base per-line price: first dollar amount in the price element + "/mo".
+ let price = '';
+ const rawPrice = norm(item.querySelector('[class*="plan_price"], [class*="plan-price"]') && item.querySelector('[class*="plan_price"], [class*="plan-price"]').textContent);
+ const m = rawPrice.match(/\$\s*\d+(?:\.\d+)?/);
+ if (m) price = `${m[0].replace(/\s/, '')}/mo`;
+
+ if (!name && !price) return;
+
+ // Dedupe responsive duplicate cards.
+ const key = `${name}|${price}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+
+ // Cell 1 — name (richtext).
+ const nameCell = document.createDocumentFragment();
+ nameCell.appendChild(document.createComment(' field:name '));
+ const h = document.createElement('h3');
+ h.textContent = name;
+ nameCell.appendChild(h);
+
+ // Cell 2 — price (text field group). Empty (no hint) if absent.
+ const priceCell = document.createDocumentFragment();
+ if (price) {
+ priceCell.appendChild(document.createComment(' field:price '));
+ const p = document.createElement('p');
+ p.textContent = price;
+ priceCell.appendChild(p);
+ }
+
+ // Cell 3 — features (richtext): feature bullet list + Select CTA.
+ const featuresCell = document.createDocumentFragment();
+ featuresCell.appendChild(document.createComment(' field:features '));
+ const featureItems = [...item.querySelectorAll('[class*="zelda-ul"] li, [class*="feature-points"] li, [class*="standard-benefits"] li')]
+ .map((li) => cleanFeature(li))
+ .filter(Boolean);
+ const uniqFeatures = [...new Set(featureItems)];
+ if (uniqFeatures.length) {
+ const ul = document.createElement('ul');
+ uniqFeatures.forEach((t) => {
+ const li = document.createElement('li');
+ li.textContent = t;
+ ul.appendChild(li);
+ });
+ featuresCell.appendChild(ul);
+ }
+ // Select / primary action CTA.
+ const cta = [...item.querySelectorAll('a[href]')]
+ .find((a) => /select|bring your own|shop|choose/i.test(norm(a.textContent)))
+ || item.querySelector('a[href]');
+ if (cta) {
+ const wrap = document.createElement('p');
+ const a = document.createElement('a');
+ a.setAttribute('href', cta.getAttribute('href'));
+ a.textContent = norm(cta.textContent) || 'Select';
+ wrap.appendChild(a);
+ featuresCell.appendChild(wrap);
+ }
+
+ cells.push([nameCell, priceCell, featuresCell]);
+ });
+
+ const block = WebImporter.Blocks.createBlock(document, { name: 'cards-plans', cells });
+ element.replaceWith(block);
+}
diff --git a/tools/importer/parsers/columns-benefits.js b/tools/importer/parsers/columns-benefits.js
new file mode 100644
index 0000000..a2c05dd
--- /dev/null
+++ b/tools/importer/parsers/columns-benefits.js
@@ -0,0 +1,68 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for columns-benefits. Base block: columns (core/franklin/components/columns).
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / feature-banner)
+ * Model (blocks/columns-benefits/_columns-benefits.json): columns block, 5 columns, 1 row.
+ * Columns blocks use DEFAULT CONTENT ONLY — no field-hint comments (per hinting rules).
+ * Library convention: name row + a content row with one cell per column.
+ *
+ * Source: an intro line ("Pick any plan and get:") followed by 5 icon+label benefit items
+ * (".feature-banner__icon-item" = icon image + label). Output: one row, one cell per benefit
+ * (5 columns). The intro line is emitted as default content immediately before the block.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+
+ // Benefit items: prefer the authored icon-item containers; fall back to grouping images.
+ let items = [...element.querySelectorAll('[class*="feature-banner__icon-item"]')];
+ if (!items.length) {
+ const seen = new Set();
+ element.querySelectorAll('img').forEach((img) => {
+ const key = (img.getAttribute('src') || '').split('?')[0];
+ if (key && !seen.has(key)) { seen.add(key); items.push(img.closest('div') || img); }
+ });
+ }
+ if (!items.length) {
+ element.replaceWith(...element.childNodes);
+ return;
+ }
+
+ const columnCells = items.map((item) => {
+ const cell = [];
+ // Icon image.
+ const img = item.querySelector('img');
+ if (img) cell.push(img);
+ // Label text (icon-text container or the item's text; fall back to alt).
+ const labelEl = item.querySelector('[class*="icon-text"]') || item;
+ let label = norm(labelEl.textContent);
+ if (!label && img) label = norm(img.getAttribute('alt'));
+ if (label) {
+ const p = document.createElement('p');
+ p.textContent = label;
+ cell.push(p);
+ }
+ return cell;
+ });
+
+ // Intro line as default content preceding the columns block (not a column itself).
+ const usedLabels = new Set(
+ columnCells.flatMap((c) => c.map((n) => (n.textContent ? norm(n.textContent) : ''))),
+ );
+ const introEl = [...element.querySelectorAll('h1, h2, h3, h4, p')]
+ .find((el) => {
+ const t = norm(el.textContent);
+ return t && !el.querySelector('img') && !usedLabels.has(t);
+ });
+
+ const cells = [columnCells];
+ const block = WebImporter.Blocks.createBlock(document, { name: 'columns-benefits', cells });
+
+ if (introEl) {
+ const intro = document.createElement('p');
+ intro.textContent = norm(introEl.textContent);
+ element.replaceWith(intro, block);
+ } else {
+ element.replaceWith(block);
+ }
+}
diff --git a/tools/importer/parsers/columns-promo.js b/tools/importer/parsers/columns-promo.js
new file mode 100644
index 0000000..c89e1df
--- /dev/null
+++ b/tools/importer/parsers/columns-promo.js
@@ -0,0 +1,96 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for columns-promo. Base block: columns (core/franklin/components/columns).
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / split-banner)
+ * Model (blocks/columns-promo/_columns-promo.json): columns block, 2 columns, 1 row.
+ * Columns blocks use DEFAULT CONTENT ONLY — no field-hint comments (per hinting rules).
+ * Library convention: name row + a content row with one cell per column; extra rows must
+ * keep the same column count. Here: 1 content row, 2 cells (one per promo tile).
+ *
+ * Each source ".splitbanner-custom__section" is one promo tile (column): image + heading
+ * + supporting text + CTA.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+ const isText = (el) => el
+ && !el.querySelector('button, svg, img, input')
+ && norm(el.textContent).length > 0;
+
+ // Identify the column tiles. Prefer the authored split-banner sections; fall back to
+ // direct grid columns for cross-page resilience.
+ let sections = [...element.querySelectorAll('[class*="splitbanner-custom__section"]')];
+ if (sections.length < 2) {
+ const grid = element.querySelector('.aem-Grid');
+ if (grid) {
+ sections = [...grid.children].filter((c) => c.classList.contains('aem-GridColumn') && norm(c.textContent));
+ }
+ }
+ if (!sections.length) {
+ element.replaceWith(...element.childNodes);
+ return;
+ }
+
+ const columnCells = sections.map((section) => {
+ const cell = [];
+ const seenText = new Set();
+
+ // Image (first unique per column).
+ const seenImg = new Set();
+ let image = null;
+ section.querySelectorAll('img').forEach((img) => {
+ const src = img.getAttribute('src') || '';
+ const k = src.split('?')[0];
+ if (src && !seenImg.has(k)) { seenImg.add(k); if (!image) image = img; }
+ });
+ if (image) cell.push(image);
+
+ // Heading.
+ let heading = [...section.querySelectorAll('h1, h2, h3, h4')].find((h) => norm(h.textContent));
+ if (heading) seenText.add(norm(heading.textContent));
+
+ // Paragraphs (deduped, non-CTA).
+ const paras = [];
+ [...section.querySelectorAll('p')].forEach((p) => {
+ if (!isText(p) || p.querySelector('a')) return;
+ const t = norm(p.textContent);
+ if (seenText.has(t)) return;
+ seenText.add(t);
+ paras.push(t);
+ });
+ if (!heading && paras.length) {
+ heading = document.createElement('h2');
+ heading.textContent = paras.shift();
+ }
+ if (heading) cell.push(heading);
+ paras.forEach((t) => {
+ const p = document.createElement('p');
+ p.textContent = t;
+ cell.push(p);
+ });
+
+ // CTA links (deduped).
+ const seenCta = new Set();
+ section.querySelectorAll('a[href]').forEach((a) => {
+ const t = norm(a.textContent);
+ const key = `${a.getAttribute('href')}|${t}`;
+ if (t && !seenCta.has(key)) {
+ seenCta.add(key);
+ const wrap = document.createElement('p');
+ const na = document.createElement('a');
+ na.setAttribute('href', a.getAttribute('href'));
+ na.textContent = t;
+ wrap.appendChild(na);
+ cell.push(wrap);
+ }
+ });
+
+ return cell;
+ });
+
+ // Single content row, one cell per column (no field hints — columns block).
+ const cells = [columnCells];
+
+ const block = WebImporter.Blocks.createBlock(document, { name: 'columns-promo', cells });
+ element.replaceWith(block);
+}
diff --git a/tools/importer/parsers/faq-home.js b/tools/importer/parsers/faq-home.js
new file mode 100644
index 0000000..3dfd919
--- /dev/null
+++ b/tools/importer/parsers/faq-home.js
@@ -0,0 +1,87 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for faq-home. Base block: faq (block/v1/block container).
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / accordion)
+ * Model (blocks/faq-home/_faq-home.json):
+ * faq-home (container) → faq-home-item { question (richtext), answer (richtext) }.
+ * Container block: each accordion item is one row with TWO cells: question, answer.
+ * Generated for xwalk: one field hint per cell.
+ *
+ * The accordion header renders the question twice (visible label + trigger) plus a caret
+ * icon glyph, so the question text is cleaned of caret noise and de-duplicated.
+ * A leading section heading ("FAQs") is emitted as default content before the block.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+ const clean = (s) => {
+ let t = norm(s).replace(/(?:up|down)-caret icon/ig, ' ').replace(/\s+/g, ' ').trim();
+ if (t.length) {
+ // De-duplicate an exactly-doubled string ("X X" or "XX").
+ const h = Math.floor(t.length / 2);
+ const a = t.slice(0, h).trim();
+ const b = t.slice(h).trim();
+ if (a && a === b) return a;
+ const half = t.slice(0, Math.ceil(t.length / 2)).trim();
+ if (t === half + half) return half;
+ }
+ return t;
+ };
+
+ // Find accordion items; keep the leaf filter using the SAME selector to avoid
+ // false-positive matches on nested module classes (e.g. accordion-item-header-module…).
+ let itemSel = '[class*="accordionItem___"]';
+ let items = [...element.querySelectorAll(itemSel)];
+ if (!items.length) { itemSel = '[class*="accordion-item"]'; items = [...element.querySelectorAll(itemSel)]; }
+ if (!items.length) { itemSel = '[class*="faq__field"]'; items = [...element.querySelectorAll(itemSel)]; }
+ if (!items.length) { items = [...element.querySelectorAll('details')]; itemSel = null; }
+ if (itemSel) items = items.filter((el) => !el.querySelector(itemSel));
+ if (!items.length) {
+ element.replaceWith(...element.childNodes);
+ return;
+ }
+
+ const cells = [];
+ const seen = new Set();
+
+ items.forEach((item) => {
+ const qEl = item.querySelector('[class*="accordionItemHeader"], [class*="accordion-item-header"], [class*="Header"], summary, button, h3, h4');
+ const aEl = item.querySelector('[class*="accordionDetailContent"], [class*="accordionItemDetail"], [class*="accordion-item-detail"], [class*="Detail"], [class*="content"], [class*="panel"]');
+
+ const question = clean(qEl && qEl.textContent);
+ const answer = norm(aEl && aEl.textContent);
+
+ if (!question || seen.has(question)) return;
+ seen.add(question);
+
+ // Cell 1 — question (richtext).
+ const qCell = document.createDocumentFragment();
+ qCell.appendChild(document.createComment(' field:question '));
+ const qp = document.createElement('p');
+ qp.textContent = question;
+ qCell.appendChild(qp);
+
+ // Cell 2 — answer (richtext).
+ const aCell = document.createDocumentFragment();
+ aCell.appendChild(document.createComment(' field:answer '));
+ if (answer) {
+ const ap = document.createElement('p');
+ ap.textContent = answer;
+ aCell.appendChild(ap);
+ }
+
+ cells.push([qCell, aCell]);
+ });
+
+ const leadHeading = [...element.querySelectorAll('h1, h2')].find((h) => norm(h.textContent));
+
+ const block = WebImporter.Blocks.createBlock(document, { name: 'faq-home', cells });
+
+ if (leadHeading && !seen.has(clean(leadHeading.textContent))) {
+ const h = document.createElement(leadHeading.tagName.toLowerCase());
+ h.textContent = norm(leadHeading.textContent);
+ element.replaceWith(h, block);
+ } else {
+ element.replaceWith(block);
+ }
+}
diff --git a/tools/importer/parsers/hero-promo.js b/tools/importer/parsers/hero-promo.js
new file mode 100644
index 0000000..f7fb7f6
--- /dev/null
+++ b/tools/importer/parsers/hero-promo.js
@@ -0,0 +1,100 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for hero-promo. Base block: hero.
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / hero-banner-picture)
+ * Model (blocks/hero-promo/_hero-promo.json): image (reference), imageAlt (collapsed), text (richtext).
+ * Library convention: 1 column, 3 rows (name row, image row, text row: title/subheading/CTA).
+ * Generated for xwalk: field hints on each content cell.
+ *
+ * NOTE: The source renders responsive duplicates (desktop/tablet/mobile) of the same
+ * image and text, so this parser dedupes images by src and text by normalized content.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+ // A meaningful text element carries text and is not a button/icon/media wrapper.
+ const isText = (el) => el
+ && !el.querySelector('button, svg, img, input')
+ && norm(el.textContent).length > 0;
+
+ const seenText = new Set();
+
+ // Prefer a real heading with text; fall back to promoting the first paragraph.
+ let heading = [...element.querySelectorAll('h1, h2, h3, h4')].find((h) => norm(h.textContent));
+ if (heading) seenText.add(norm(heading.textContent));
+
+ // Supporting paragraphs (deduped, excluding CTA-bearing and heading-duplicate text).
+ const paras = [];
+ [...element.querySelectorAll('p')].forEach((p) => {
+ if (!isText(p) || p.querySelector('a')) return;
+ const t = norm(p.textContent);
+ if (seenText.has(t)) return;
+ seenText.add(t);
+ paras.push(t);
+ });
+
+ if (!heading && paras.length) {
+ heading = document.createElement('h2');
+ heading.textContent = paras.shift();
+ }
+
+ // CTA links (deduped by href + text).
+ const ctas = [];
+ const seenCta = new Set();
+ element.querySelectorAll('a[href]').forEach((a) => {
+ const t = norm(a.textContent);
+ const key = `${a.getAttribute('href')}|${t}`;
+ if (t && !seenCta.has(key)) {
+ seenCta.add(key);
+ ctas.push({ href: a.getAttribute('href'), text: t });
+ }
+ });
+
+ // First unique image (dedupe responsive variants by src base).
+ const seenImg = new Set();
+ let image = null;
+ element.querySelectorAll('img').forEach((img) => {
+ const src = img.getAttribute('src') || '';
+ const k = src.split('?')[0];
+ if (src && !seenImg.has(k)) {
+ seenImg.add(k);
+ if (!image) image = img;
+ }
+ });
+
+ // Empty-block guard.
+ if (!heading && !paras.length && !ctas.length && !image) {
+ element.replaceWith(...element.childNodes);
+ return;
+ }
+
+ const cells = [];
+
+ // Content row 1 – image (reference field). imageAlt collapses into
![]()
.
+ const imageCell = document.createDocumentFragment();
+ imageCell.appendChild(document.createComment(' field:image '));
+ if (image) imageCell.appendChild(image);
+ cells.push([imageCell]);
+
+ // Content row 2 – text (richtext field): heading, tagline paragraphs and CTA links.
+ const textCell = document.createDocumentFragment();
+ textCell.appendChild(document.createComment(' field:text '));
+ if (heading) textCell.appendChild(heading);
+ paras.forEach((t) => {
+ const p = document.createElement('p');
+ p.textContent = t;
+ textCell.appendChild(p);
+ });
+ ctas.forEach((c) => {
+ const wrap = document.createElement('p');
+ const a = document.createElement('a');
+ a.setAttribute('href', c.href);
+ a.textContent = c.text;
+ wrap.appendChild(a);
+ textCell.appendChild(wrap);
+ });
+ cells.push([textCell]);
+
+ const block = WebImporter.Blocks.createBlock(document, { name: 'hero-promo', cells });
+ element.replaceWith(block);
+}
diff --git a/tools/importer/parsers/product-grid-picks.js b/tools/importer/parsers/product-grid-picks.js
new file mode 100644
index 0000000..c3c62a6
--- /dev/null
+++ b/tools/importer/parsers/product-grid-picks.js
@@ -0,0 +1,73 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for product-grid-picks. Base block: product-grid.
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / product carousel picks)
+ * Model (blocks/product-grid-picks/_product-grid-picks.json):
+ * product-grid-picks { index (aem-content) } — a link to a product index JSON. The block JS
+ * fetches the index and renders a curated product-pick row (image, name, price, Select CTA).
+ *
+ * This is an index-driven block: the authored content is a single link to a product index.
+ * The source page renders hardcoded product cards, but the EDS block is data-driven, so the
+ * parser emits ONE row with ONE cell containing the index link (field:index). We derive the
+ * index path from the section's "shop all" CTA, defaulting to a conventional products index.
+ * Generated for xwalk: field hint on the index cell.
+ *
+ * A leading section heading ("All the best phones, all in one place") and its shop-all CTA are
+ * emitted as default content before the block.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+
+ // Section heading + "shop all" CTA (default content preceding the block).
+ const heading = [...element.querySelectorAll('h1, h2, h3')].find((h) => norm(h.textContent));
+ const shopAll = [...element.querySelectorAll('a[href]')]
+ .find((a) => /shop all|all phones|view all|see all/i.test(norm(a.textContent)));
+
+ // Derive the product index path. Prefer a JSON index if one is linked; otherwise map the
+ // shop-all destination to a conventional query-index path, defaulting to phones.
+ let indexPath = '/products/phones/index.json';
+ const jsonLink = [...element.querySelectorAll('a[href]')]
+ .find((a) => /\.json(\?|$)/i.test(a.getAttribute('href') || ''));
+ if (jsonLink) {
+ indexPath = jsonLink.getAttribute('href');
+ } else if (shopAll) {
+ try {
+ const p = new URL(shopAll.getAttribute('href'), 'https://www.totalwireless.com').pathname;
+ indexPath = `${p.replace(/\/$/, '')}/index.json`.replace(/^\/+/, '/');
+ } catch { /* keep default */ }
+ }
+
+ // Row 1 (content) — index (aem-content field): a link to the product index JSON.
+ const indexCell = document.createDocumentFragment();
+ indexCell.appendChild(document.createComment(' field:index '));
+ const a = document.createElement('a');
+ a.setAttribute('href', indexPath);
+ a.textContent = indexPath;
+ indexCell.appendChild(a);
+
+ const cells = [[indexCell]];
+ const block = WebImporter.Blocks.createBlock(document, { name: 'product-grid-picks', cells });
+
+ // Emit heading + shop-all CTA as default content before the block.
+ const before = [];
+ if (heading) {
+ const h = document.createElement(heading.tagName.toLowerCase());
+ h.textContent = norm(heading.textContent);
+ before.push(h);
+ }
+ if (shopAll) {
+ const p = document.createElement('p');
+ const link = document.createElement('a');
+ link.setAttribute('href', shopAll.getAttribute('href'));
+ link.textContent = norm(shopAll.textContent);
+ p.appendChild(link);
+ before.push(p);
+ }
+
+ if (before.length) {
+ element.replaceWith(...before, block);
+ } else {
+ element.replaceWith(block);
+ }
+}
diff --git a/tools/importer/parsers/teaser-promo.js b/tools/importer/parsers/teaser-promo.js
new file mode 100644
index 0000000..f6f15f4
--- /dev/null
+++ b/tools/importer/parsers/teaser-promo.js
@@ -0,0 +1,91 @@
+/* eslint-disable */
+/* global WebImporter */
+/**
+ * Parser for teaser-promo. Base block: teaser.
+ * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / cta-banner-with-pictogram)
+ * Model (blocks/teaser-promo/_teaser-promo.json): image (reference), imageAlt (collapsed), text (richtext).
+ * Single promotional tile: image + heading + supporting text + CTA link.
+ * 1 column, 3 rows (name, image, text). Generated for xwalk: field hints per cell.
+ */
+export default function parse(element, { document }) {
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
+ const isText = (el) => el
+ && !el.querySelector('button, svg, img, input')
+ && norm(el.textContent).length > 0;
+
+ const seenText = new Set();
+
+ let heading = [...element.querySelectorAll('h1, h2, h3, h4')].find((h) => norm(h.textContent));
+ if (heading) seenText.add(norm(heading.textContent));
+
+ const paras = [];
+ [...element.querySelectorAll('p')].forEach((p) => {
+ if (!isText(p) || p.querySelector('a')) return;
+ const t = norm(p.textContent);
+ if (seenText.has(t)) return;
+ seenText.add(t);
+ paras.push(t);
+ });
+
+ if (!heading && paras.length) {
+ heading = document.createElement('h2');
+ heading.textContent = paras.shift();
+ }
+
+ const ctas = [];
+ const seenCta = new Set();
+ element.querySelectorAll('a[href]').forEach((a) => {
+ const t = norm(a.textContent);
+ const key = `${a.getAttribute('href')}|${t}`;
+ if (t && !seenCta.has(key)) {
+ seenCta.add(key);
+ ctas.push({ href: a.getAttribute('href'), text: t });
+ }
+ });
+
+ const seenImg = new Set();
+ let image = null;
+ element.querySelectorAll('img').forEach((img) => {
+ const src = img.getAttribute('src') || '';
+ const k = src.split('?')[0];
+ if (src && !seenImg.has(k)) {
+ seenImg.add(k);
+ if (!image) image = img;
+ }
+ });
+
+ if (!heading && !paras.length && !ctas.length && !image) {
+ element.replaceWith(...element.childNodes);
+ return;
+ }
+
+ const cells = [];
+
+ // Content row 1 – image (reference). imageAlt collapses into
![]()
.
+ const imageCell = document.createDocumentFragment();
+ imageCell.appendChild(document.createComment(' field:image '));
+ if (image) imageCell.appendChild(image);
+ cells.push([imageCell]);
+
+ // Content row 2 – text (richtext): heading, supporting text and CTA link.
+ const textCell = document.createDocumentFragment();
+ textCell.appendChild(document.createComment(' field:text '));
+ if (heading) textCell.appendChild(heading);
+ paras.forEach((t) => {
+ const p = document.createElement('p');
+ p.textContent = t;
+ textCell.appendChild(p);
+ });
+ ctas.forEach((c) => {
+ const wrap = document.createElement('p');
+ const a = document.createElement('a');
+ a.setAttribute('href', c.href);
+ a.textContent = c.text;
+ wrap.appendChild(a);
+ textCell.appendChild(wrap);
+ });
+ cells.push([textCell]);
+
+ const block = WebImporter.Blocks.createBlock(document, { name: 'teaser-promo', cells });
+ element.replaceWith(block);
+}
diff --git a/tools/importer/transformers/totalwireless-cleanup.js b/tools/importer/transformers/totalwireless-cleanup.js
new file mode 100644
index 0000000..359566c
--- /dev/null
+++ b/tools/importer/transformers/totalwireless-cleanup.js
@@ -0,0 +1,105 @@
+/* eslint-disable */
+/* global WebImporter */
+
+/**
+ * Transformer: Total Wireless site-wide cleanup.
+ *
+ * Removes non-authorable CMS chrome, global header/footer experience
+ * fragments, tracking/analytics scaffolding, cookie banners, feedback
+ * widgets, and CMS wrapper attributes from the AEM-authored source DOM.
+ *
+ * 🚨 Every selector below was verified against migration-work/cleaned.html
+ * for https://www.totalwireless.com/m/home. None are guessed.
+ */
+
+const TransformHook = { beforeTransform: 'beforeTransform', afterTransform: 'afterTransform' };
+
+export default function transform(hookName, element, payload) {
+ if (hookName === TransformHook.beforeTransform) {
+ // --- Overlays / modals / cookie banners that would interfere with
+ // block parsing (found at body tail and mid-document in cleaned.html).
+ WebImporter.DOMUtils.remove(element, [
+ '#tDrkDiv', // tracking dark-overlay div (top of body)
+ '#__tealiumModalParent', // Tealium consent modal shell
+ '#__tealiumModal', // Tealium modal base (.__tealiumModalBase)
+ '#cookieBannerEN', // Combined cookie bar (English)
+ '#cookieBannerES', // Combined cookie bar (Spanish)
+ '#learnMoreModal', // global "learn more" overlay
+ '#autoPayModal', // global "auto pay" overlay
+ '.QSIFeedbackButton', // Qualtrics feedback widget
+ '[class*="preloaded_lightbox"]', // fancybox preloaded lightbox at body tail
+ ]);
+
+ // Hidden tracking/state
scaffolding at the top of .
+ ['#pageName', '#adtechEvent', '#sourceSystem'].forEach((sel) => {
+ const el = element.querySelector(sel);
+ if (el) el.remove();
+ });
+ }
+
+ if (hookName === TransformHook.afterTransform) {
+ // --- Global chrome: header + footer experience fragments (site shell,
+ // not page content). Verified classes from cleaned.html.
+ WebImporter.DOMUtils.remove(element, [
+ '.cmp-experiencefragment--totalwireless-header', // top nav / butterbar / skinny-banner
+ '.cmp-experiencefragment--totalwireless-footer', // footer link-lists, social, bottom links
+ 'nav.header-primary-menu', // primary menu (belt-and-braces if header stripped)
+ ]);
+
+ // --- Tracking pixels / beacons / analytics iframes at body tail.
+ WebImporter.DOMUtils.remove(element, [
+ '#ttdUniversalPixelTag',
+ '#postup_rules',
+ '[id^="batBeacon"]', // Bing UET beacon container + img
+ '[id^="universal_pixel_"]', // The Trade Desk universal pixel iframes
+ ]);
+
+ // --- Non-content
/