Skip to content

Blog and article support guide

Diagnostic and troubleshooting reference for blog listing and article reading experience — featured-article hero, tag-chip filter, author chips, reading time, list/grid toggle, author bio, social share, related articles, audio playback, BlogPosting JSON-LD structured data, and microdata.

For merchant setup and settings configuration, see the Blog guide.


FAQ

Q1: Featured-article hero is not showing on the blog listing

Check in order:

  1. Is show_featured_hero ON? Theme editor → blog template section settings → show_featured_hero. Default is ON; if a previous merchant turned it OFF, re-enable it.

  2. Is this the first page? The featured hero is suppressed on page 2+ (paginate.current_page == 1 guard). Navigate to /blogs/news (page 1) — the hero should appear there.

  3. Is a tag filter active? The hero is suppressed on tag-archive pages (/blogs/<handle>/tagged/<tag>) — the hero would incorrectly show an article that may not match the active tag. Navigate to the main blog URL (no tag) to see the hero.

  4. Does the blog have at least one published article? The hero uses blog.articles.first — if no articles are published, the hero section is empty and rendered invisible.

  5. Is the article image set? The hero shows a Celtic knot SVG placeholder if no article image is set. The hero renders regardless — it does not require an image.

Q2: Author chip shows initials only, not the avatar photo

The author avatar comes from a shop metafield: shop.metafields.author.<handle>_avatar. The initials circle is the fallback when this metafield is absent or empty.

Check:

  1. Is the metafield definition created? Shopify admin → Settings → Custom data → Shop → look for author namespace definitions.
  2. Is the metafield value set for this author's handle? The handle is the author name lowercased with spaces replaced by underscores (e.g., "Aoife Murphy" → aoife_murphy). In Shopify admin → Settings → Custom data → Shop → author.aoife_murphy_avatar → verify a file is selected.
  3. Is the Shopify Files URL accessible? The metafield type is file_reference — the image must be uploaded to Shopify admin → Content → Files first.
  4. Open DevTools → Elements → find .blog-author-chip__avatar — does it have an src attribute? If src is absent, the metafield lookup returned blank.

Q3: Tag chips not showing on the blog listing

Tag chips are driven by blog.all_tags. They only appear if at least one published article in the blog has tags.

Check:

The chip row renders automatically whenever any published article has tags. Verify at least one article has tags assigned (Shopify admin → Blog posts → select article → Tags field).

  1. Open Shopify admin → Blog posts → filter by your blog → look for any article with tags assigned. If no articles have tags, blog.all_tags is empty and the chip row is hidden.
  2. The "All" chip always appears if any tags exist. If only the "All" chip shows, your articles have tags but Liquid is reading blog.all_tags correctly — the individual tag chips should follow.

Q4: Audio player not appearing on the article

Check:

  1. Is enable_audio_playback ON? Theme editor → article template section settings → enable_audio_playback. Default is ON.
  2. Does the browser support speechSynthesis? The audio player is hidden on unsupported browsers (feature-detected). Run the console diagnostic snippet → look for speechSynthesis: true. If false, the browser does not support Web Speech API (unusual — most modern browsers support it; check if an extension or policy is blocking it).
  3. Is there an <article-audio> element in the DOM? Open DevTools → Elements → search for article-audio. If absent, the section setting is OFF or the JS bundle did not load.

Q5: Audio plays but the reading-position highlight does not move

The highlight tracks SpeechSynthesisUtterance boundary events. Potential issues:

  1. prefers-reduced-motion is ON. Under reduced-motion, the solid highlight is replaced with a wavy underline (CSS text-decoration). This is correct accessibility behavior — the underline still moves, but more subtly.
  2. The speechSynthesis.speaking flag is true but boundary events are not firing. This is a browser bug — some older Chromium versions emit no boundary events. Workaround: update the browser. The theme cannot work around missing browser events.
  3. Article content is in a non-paragraph element. The highlight targets <p> tags in .article__content. If the article content uses unusual markup, the highlight may skip elements.

Q6: Audio does not play on iOS Safari

iOS Safari enforces a user-gesture lock on audio/speech. The <article-audio> Custom Element handles this:

  1. First visit: a "Tap to listen" prompt replaces the Play button until the first user click.
  2. After the user taps Play, speechSynthesis.speak() is called within the click handler — this satisfies iOS's gesture requirement.
  3. If playback still doesn't start: verify the shopper has not turned off "Speak Screen" or "Speak Selection" in iOS accessibility settings (these affect SpeechSynthesis API availability).

Q7: BlogPosting JSON-LD not validating in Rich Results Test

If you see validation errors for the article's structured data:

  1. Check for Liquid errors in the <script> block. Open DevTools → Sources → search for BlogPosting. If the JSON block contains Liquid error, the JSON is invalid and no parser will read it. Common cause: article has no featured image (see next step).

  2. Missing article image: If article.image is blank, the "image" field is omitted (this is valid per schema.org — image is recommended, not required for BlogPosting). If you see Liquid error: invalid url input instead of a clean omission, you may be on an older version of the Uisce theme. Ensure you are running the latest version.

  3. Verify @type is "BlogPosting" (not "Article"). Theme 06.7-03 upgraded the type. If you still see "Article", the json-ld.liquid snippet may not have been deployed to your store.

  4. Run the JSON-LD inspection command from the curl cheat-sheet to check all fields programmatically.

  5. Password-protected store: Google Rich Results Test URL mode cannot access password-protected dev stores. Use HTML-paste mode: curl -s -b cookies.txt {article_url} > /tmp/article.html → paste contents into the "Code" tab at https://search.google.com/test/rich-results.

Q8: Social share rail not appearing on desktop

The sticky social share rail appears on desktop (≥ 1024 px viewport width) at the inline-end (right) edge of the content column. Check:

  1. Is the viewport ≥ 1024 px? The rail is hidden on mobile via CSS. Resize the window or use DevTools responsive mode.
  2. Are any share platforms enabled? If all show_share_* settings are OFF, the rail renders empty and the container collapses. Re-enable at least one platform.
  3. RTL stores: In right-to-left languages, the rail appears on the left (logical inset-inline-end = physical left in RTL). This is correct behavior.
  4. Run the diagnostic snippet → shareToast field confirms the share-related DOM is present.

The copy-link button uses navigator.clipboard.writeText() with a fallback to document.execCommand('copy'). Common issues:

  1. HTTP vs HTTPS: navigator.clipboard requires HTTPS (or localhost). Dev stores use HTTPS, so this should not apply.
  2. Browser permissions: In some browsers, clipboard access requires explicit permission. The fallback execCommand('copy') bypasses this but may not work in all iframes.
  3. Toast element: Run the diagnostic snippet → shareToast: true confirms [data-share-toast] is in the DOM. If false, the share snippet did not render.

Related articles use a cascade: same-tag articles (3 most recent, excluding current) → same-blog most-recent (3, excluding current) → hidden if < 2 candidates.

To influence which articles appear:

  1. Tag consistently. Articles with the same tags are related to each other. Use broad topic tags (not per-article unique tags) to maximize related-article connections.
  2. If all related articles come from "same-blog most-recent", your articles have no tags or no overlapping tags — this is correct behavior.
  3. If the related block is hidden, you have fewer than 2 articles in the blog (excluding the current one) — publish more articles.

Decision tree: audio playback

Article audio player not working

├─ `enable_audio_playback` ON in theme editor?
│   No → enable it
│   Yes → continue

├─ `<article-audio>` in DOM? (DevTools Elements search)
│   No → JS bundle not loaded → check browser console errors
│   Yes → continue

├─ `speechSynthesis` in window? (see diagnostic snippet)
│   No → browser does not support Web Speech API
│       Workaround: none; feature hidden by design on unsupported browsers
│   Yes → continue

├─ iOS Safari?
│   Yes → first user gesture required
│       → "Tap to listen" prompt should appear; tap it
│       → If still no audio: check iOS Settings → Accessibility → Spoken Content
│   No → continue

├─ Click Play button → audio starts?
│   No → check voicesCount in diagnostic (speechSynthesis.getVoices().length)
│       0 voices → browser hasn't loaded voice list yet → try after page settles
│       > 0 voices → check console for SpeechSynthesisUtterance errors
│   Yes → check highlight (Q5)

Console diagnostic snippet

Paste into browser DevTools Console on any article page. Read-only, adds zero theme code.

js
(() => {
  const r = {
    audioCE: !!customElements.get('article-audio'),
    speechSynthesis: 'speechSynthesis' in window,
    voicesCount: 'speechSynthesis' in window ? speechSynthesis.getVoices().length : null,
    clipboard: !!navigator.clipboard,
    audioEl: !!document.querySelector('article-audio'),
    audioState: document.querySelector('article-audio [data-audio-status]')?.textContent?.trim() ?? null,
    shareToast: !!document.querySelector('[data-share-toast]'),
    relatedCount: document.querySelectorAll('.article-related__card').length,
    authorBio: !!document.querySelector('.article-author-bio'),
    jsonLdType: (() => {
      try {
        const scripts = document.querySelectorAll('script[type="application/ld+json"]');
        for (const s of scripts) {
          const d = JSON.parse(s.textContent);
          if (d['@type'] === 'BlogPosting') return 'BlogPosting (correct)';
          if (d['@type'] === 'Article') return 'Article (old — check 06.7-03 deploy)';
        }
        return 'BlogPosting not found';
      } catch {
        return 'JSON parse error in ld+json block';
      }
    })(),
  };
  console.table(r);
})();

What to look for:

FieldExpected valueWhat it means if wrong
audioCEtruefalse → article-audio.js not loaded
speechSynthesistruefalse → browser does not support Web Speech API
voicesCountInteger > 00 → voice list not loaded yet; try again after 1s
audioEltruefalseenable_audio_playback OFF or CE not mounted
audioStatenull or "stopped" on loadShows current play/pause/stop state
shareToasttruefalse → article-social-share snippet not rendered
relatedCount2 or 30 → related-articles block hidden (< 2 candidates)
authorBiotrue if show_author_bio=ON AND metafields setfalse → author bio OFF or no metafields
jsonLdType"BlogPosting (correct)""Article (old)" → 06.7-03 not deployed; "JSON parse error" → Liquid error in ld+json block

Curl cheat-sheet

Replace {store} with your storefront domain and {password} with the storefront password (dev stores only). For production stores, skip the cookie step.

bash
curl -s -c cookies.txt https://{store}/password -o /tmp/pw.html
CSRF=$(grep -o 'name="authenticity_token" value="[^"]*"' /tmp/pw.html | head -1 | sed 's/.*value="//;s/"//')
curl -s -c cookies.txt -b cookies.txt \
  --data-urlencode "form_type=storefront_password" \
  --data-urlencode "authenticity_token=${CSRF}" \
  --data-urlencode "password={password}" \
  -L "https://{store}/password" -o /dev/null
# Now add -b cookies.txt to all curl commands below

Verify RSS alternate link in article head

bash
curl -s -b cookies.txt https://{store}/blogs/news/{article-handle} | \
  grep -i 'rel="alternate".*rss\|application/rss\|application/atom'

Expected: <link rel="alternate" type="application/atom+xml" ... href="/blogs/news.atom" />. If absent, check that snippets/meta-tags.liquid has the RSS alternate link injection (Plan 06.7-04 feature).

Verify microdata removal (no article-body itemscope)

bash
curl -s -b cookies.txt https://{store}/blogs/news/{article-handle} | \
  grep -c 'itemscope\|itemprop\|itemtype'

Expected: 1 (breadcrumb microdata only). If > 1, the article body still has microdata attributes — check that Plan 06.7-03 was deployed (microdata reconciliation).

Inspect JSON-LD blocks

bash
curl -s -b cookies.txt https://{store}/blogs/news/{article-handle} | \
  python3 -c "
import re, json, sys
html = sys.stdin.read()
blocks = re.findall(r'<script[^>]*type=[\"'\''']application/ld\+json[\"'\'''][^>]*>(.*?)</script>', html, re.DOTALL)
print(f'{len(blocks)} JSON-LD blocks found')
for i, b in enumerate(blocks):
    b = b.strip()
    if 'Liquid error' in b:
        print(f'Block {i+1}: LIQUID ERROR - {[l for l in b.splitlines() if \"Liquid error\" in l][:1]}')
    else:
        try:
            d = json.loads(b)
            t = d.get('@type', '?')
            print(f'Block {i+1}: @type={t}', end='')
            if t == 'BlogPosting':
                print(f'; mainEntityOfPage={bool(d.get(\"mainEntityOfPage\"))}; articleSection={bool(d.get(\"articleSection\"))}; image={\"ImageObject\" if isinstance(d.get(\"image\"), dict) else (\"absent\" if d.get(\"image\") is None else \"plain\")}', end='')
            print()
        except Exception as e:
            print(f'Block {i+1}: parse error: {e}')
"

Expected output:

4 JSON-LD blocks found
Block 1: @type=Organization
Block 2: @type=WebSite
Block 3: @type=BreadcrumbList
Block 4: @type=BlogPosting; mainEntityOfPage=True; articleSection=True; image=ImageObject (or absent if no featured image)

Verify RSS feed content

bash
curl -s -b cookies.txt https://{store}/blogs/news.atom | head -30

Expected: Atom XML with <feed> root element, <title>, <entry> elements per article. If the feed returns HTML (redirect to /password), the auth cookie is not being passed correctly.

Check reading time rendering

bash
curl -s -b cookies.txt https://{store}/blogs/news/{article-handle} | \
  grep -i "min read\|min de lectura\|reading_time"

Expected: a localized "N min read" string in the article meta line. If absent, show_reading_time is OFF or the locale key blog.article_meta_reading_time is missing.

Google Rich Results Test — HTML paste mode (dev stores)

bash
# Fetch article HTML with auth
curl -s -b cookies.txt https://{store}/blogs/news/{article-handle} > /tmp/article.html

# Validate structured data locally
python3 -c "
import re, json
with open('/tmp/article.html') as f:
    html = f.read()
blocks = re.findall(r'<script[^>]*type=[\"\'']application/ld\+json[\"\''][^>]*>(.*?)</script>', html, re.DOTALL)
for b in blocks:
    b = b.strip()
    if 'BlogPosting' in b:
        if 'Liquid error' in b:
            print('FAIL: Liquid error in JSON-LD block')
        else:
            try:
                d = json.loads(b)
                errors = []
                if d.get('@type') != 'BlogPosting': errors.append('Wrong @type')
                if not d.get('mainEntityOfPage'): errors.append('Missing mainEntityOfPage')
                if not d.get('headline'): errors.append('Missing headline')
                if not d.get('datePublished'): errors.append('Missing datePublished')
                if not d.get('author'): errors.append('Missing author')
                if not d.get('publisher'): errors.append('Missing publisher')
                if errors:
                    print('FAIL:', ', '.join(errors))
                else:
                    print('PASS: BlogPosting valid')
                    print('  image:', 'ImageObject' if isinstance(d.get('image'), dict) else 'absent (articles without featured image — valid)')
            except Exception as e:
                print('PARSE ERROR:', e)
        break
"

To use Google's actual validator in HTML-paste mode:

  1. Run the curl command above to save the article HTML
  2. Visit https://search.google.com/test/rich-results
  3. Click the "Code" tab
  4. Paste the contents of /tmp/article.html
  5. Click "Test code"

Known limitations

Web Speech API voice availability

speechSynthesis.getVoices() returns an empty array synchronously on page load — voices are loaded asynchronously. The audio player waits for the voiceschanged event before enabling the Play button. On very slow connections, this may take 1–2 seconds. This is a browser behavior, not a theme bug.

Web Speech API and content chunking

Long articles are split into multiple SpeechSynthesisUtterance objects (one per paragraph) to work around the Web Speech API's 32,768 character limit per utterance. The reading-position highlight tracks across utterance boundaries. On some browsers, there may be a brief pause between paragraphs — this is expected.

Social share — platforms and intent URLs

All share buttons use direct intent URLs (no JavaScript library, no tracking pixel):

PlatformURL pattern
X (Twitter)https://x.com/intent/tweet?url={url}&text={title}
LinkedInhttps://www.linkedin.com/shareArticle?mini=true&url={url}&title={title}
Facebookhttps://www.facebook.com/sharer/sharer.php?u={url}
Pinteresthttps://pinterest.com/pin/create/button/?url={url}&description={title}
WhatsApphttps://api.whatsapp.com/send?text={title} {url}
Emailmailto:?subject={title}&body={url}
Copy linknavigator.clipboard.writeText(window.location.href)

Platform intent URLs are opened in a new tab. Copy link uses the Clipboard API with a 2-second toast notification.


Need more help?

  • File a GitHub issue with the console-snippet output + browser/OS + preset name.
  • See the Blog guide for settings documentation.

Built for the Shopify Theme Store.