Inspect the code

Source files shipped with this checker

These are the HTML, CSS, and JavaScript files the checker loads. Open a file below to read its contents. This page is generated directly from those files, with no external code viewer or account. To inspect this page itself, use your browser’s View Page Source command.

← Back to the checker

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="color-scheme" content="light">
  <title>Product CSV preflight · Check before you import</title>
  <meta name="description" content="Check likely Shopify product CSV import problems on your device. See row-by-row findings before your test import.">
  <link rel="stylesheet" href="style.css">
  <script defer src="checker.js"></script>
  <script defer src="app.js"></script>
  <script defer src="feedback.js"></script>
</head>
<body>
  <header class="site-header wrap">
    <a class="brand" href="index.html" aria-label="Product CSV preflight home"><span class="brand-mark" aria-hidden="true">▦</span> Product CSV preflight</a>
    <span class="header-note">A local file checker</span>
  </header>
  <main>
    <section class="hero wrap" aria-labelledby="hero-title">
      <div class="hero-copy">
        <p class="eyebrow">Before the import</p>
        <h1 id="hero-title">Catch the CSV problems you can see <em>before</em> Shopify does.</h1>
        <p class="lede">Choose a product CSV. Get a plain-language list of likely issues, with the row and column to fix. Your file stays on this device.</p>
        <a class="text-link" href="#checker">Check a CSV <span aria-hidden="true">↗</span></a>
      </div>
      <div class="hero-card" aria-label="Example preflight finding">
        <div class="card-top"><span class="lights" aria-hidden="true"><i></i><i></i><i></i></span><span>preflight / example</span></div>
        <div class="mini-row"><span class="mini-index">03</span><span class="mini-tag">PRICE</span><span class="mini-price">$19.00</span></div>
        <div class="mini-row flagged"><span class="mini-index">04</span><span class="mini-tag">PRICE</span><span class="mini-price">$nineteen</span></div>
        <p class="card-finding"><span aria-hidden="true">↳</span> Row 4 · Price needs a number without a currency symbol.</p>
      </div>
    </section>

    <section class="checker-band" id="checker" aria-labelledby="checker-title">
      <div class="wrap checker-grid">
        <div class="section-intro">
          <p class="eyebrow">The checker</p>
          <h2 id="checker-title">A quick pass before your test import.</h2>
          <p>Checks headings, handles, variant combinations, required fields, prices, cell counts and CSV quotes. It cannot inspect your store or confirm how Shopify will import a file.</p>
          <div class="privacy-note"><span aria-hidden="true">◌</span><p><strong>On your device.</strong> The CSV is read in your browser. This page has no upload endpoint, external scripts or analytics.</p></div>
        </div>
        <form id="check-form" class="check-panel">
          <fieldset>
            <legend>1 <span>What are you doing?</span></legend>
            <label class="choice"><input type="radio" name="mode" value="new" checked><span><strong>Importing new products</strong><small>Check titles and variant structure.</small></span></label>
            <label class="choice"><input type="radio" name="mode" value="update"><span><strong>Updating products</strong><small>Check handles and variant dependencies.</small></span></label>
          </fieldset>
          <div class="file-step"><label for="csv-file"><span class="step-number">2</span> Choose a product CSV</label><input id="csv-file" type="file" accept=".csv,text/csv" required><p id="file-note" class="field-note">CSV only · up to 15 MB · UTF-8</p></div>
          <button type="submit" class="primary-button">Check my CSV <span aria-hidden="true">→</span></button>
          <p class="panel-foot">Nothing is sent to a server. No account needed.</p>
        </form>
      </div>
    </section>

    <section class="results wrap" id="results" aria-labelledby="results-title" hidden>
      <div class="results-heading"><div><p class="eyebrow">Your report</p><h2 id="results-title">Preflight findings</h2></div><button id="download" type="button" class="secondary-button" hidden>Download findings CSV ↓</button></div>
      <p id="summary" class="summary" role="status" aria-live="polite"></p>
      <ol id="findings" class="findings"></ol>
      <p class="results-limit">A clear report is not a guarantee. Review the file, back up your store, and run a test import.</p>
    </section>

    <section class="feedback-band" id="feedback" aria-labelledby="feedback-title">
      <div class="wrap feedback-grid">
        <div class="section-intro">
          <p class="eyebrow">Optional feedback</p>
          <h2 id="feedback-title">Tell us what helped, and what we missed.</h2>
          <p>If you used the checker on a product CSV problem, record any finding you could act on and any import failure it did not catch. A clear checker report can still miss a real failure.</p>
          <p>Responses stay in this browser and are never submitted by this page. Download the summary to share it yourself. Please omit names, store details, and raw CSV data.</p>
        </div>
        <div class="feedback-panel">
          <form id="feedback-form">
            <label for="operator-ref">Operator reference <span class="optional">(optional)</span></label>
            <input id="operator-ref" name="operatorRef" type="text" maxlength="80" placeholder="For example, operator 1" autocomplete="off">
            <label for="actionable-finding">Actionable pre-import finding <span class="optional">(if any)</span></label>
            <textarea id="actionable-finding" name="actionableFinding" rows="3" maxlength="1200" placeholder="What did the checker flag, and what could you fix?"></textarea>
            <label for="unsupported-failure">Failure the checker missed or could not verify <span class="optional">(if any)</span></label>
            <textarea id="unsupported-failure" name="unsupportedFailure" rows="3" maxlength="1200" placeholder="What did Shopify's preview, import, or final product show?"></textarea>
            <fieldset class="feedback-price">
              <legend>Would you request a complete report for $9?</legend>
              <label><input type="radio" name="nineDollarRequest" value="yes" required> Yes</label>
              <label><input type="radio" name="nineDollarRequest" value="no" required> No</label>
            </fieldset>
            <button type="submit" class="primary-button">Record response <span aria-hidden="true">→</span></button>
          </form>
          <p id="feedback-status" class="field-note" role="status" aria-live="polite"></p>
          <div class="feedback-actions">
            <p id="feedback-count">0 responses recorded in this browser.</p>
            <button id="download-feedback" type="button" class="secondary-button" hidden>Download feedback summary ↓</button>
            <button id="clear-feedback" type="button" class="secondary-button" hidden>Clear recorded responses</button>
          </div>
          <p class="field-note">The $9 answer is an expression of interest, not a purchase or payment request. Nothing is charged.</p>
        </div>
      </div>
    </section>

    <section class="below wrap" aria-labelledby="how-title">
      <div><p class="eyebrow">A safer workflow</p><h2 id="how-title">Check. Fix. Test.</h2></div>
      <div class="steps"><div><b>01</b><h3>Keep a backup</h3><p>Back up your store by exporting a copy of your current products before making changes.</p></div><div><b>02</b><h3>Fix likely issues</h3><p>Use the row and column in each finding, then check the file again.</p></div><div><b>03</b><h3>Test in Shopify</h3><p>This checker does not replace a test import. Check Shopify’s import preview and results.</p></div></div>
    </section>
    <section class="sample-strip wrap"><div><h2>Want to see what it catches?</h2><p>Download a small example and open it above.</p></div><div class="sample-links"><a href="samples/good-products.csv" download>Good CSV ↓</a><a href="samples/bad-products.csv" download>CSV with problems ↓</a></div></section>
    <section class="trust wrap" id="about" aria-labelledby="about-title">
      <div>
        <p class="eyebrow">About</p>
        <h2 id="about-title">Who built this?</h2>
        <p>Built by JARVIS, an AI-run venture. Questions or feedback? Email <a href="mailto:ogdenventures@outlook.com">ogdenventures@outlook.com</a>.</p>
      </div>
      <div id="privacy">
        <p class="eyebrow">Your data</p>
        <h2>How your file stays private</h2>
        <p>When you choose a CSV, your browser reads and checks it on your device. The CSV is not uploaded to us. Findings stay on this page until you choose to download them. Optional feedback is kept in this browser and is only shared if you download and send its summary yourself.</p>
        <p><strong>Try it offline:</strong> Load this page, turn off Wi-Fi (and mobile data, if available), then choose a CSV and run a check without refreshing. The checker still works. Links to Shopify guidance need an internet connection.</p>
        <p><a href="source.html">Read the source files</a> to see the code shipped with this page.</p>
      </div>
    </section>
  </main>
  <footer class="wrap"><span>Product CSV preflight · Free test site</span><span>Independent tool. Not affiliated with Shopify. <a href="README.md">Scope & sources</a> · <a href="source.html">Source files</a></span></footer>
</body>
</html>
style.css
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#182b25;background:#f8f7f2;font-synthesis:none}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-width:0}button,input{font:inherit}a{color:inherit}.wrap{width:min(1160px,calc(100% - 48px));margin-inline:auto}.site-header{min-height:82px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #d9e0d8}.brand{text-decoration:none;font-weight:750;letter-spacing:-.03em;font-size:17px;display:flex;align-items:center;gap:10px}.brand-mark{display:inline-grid;place-items:center;width:30px;height:30px;border-radius:8px;background:#225c40;color:white;font-size:21px}.header-note{font-size:12px;text-transform:uppercase;letter-spacing:.11em;color:#61756c}.hero{padding:95px 0 105px;display:grid;grid-template-columns:1.13fr .87fr;gap:7%;align-items:center}.eyebrow{text-transform:uppercase;font-size:12px;font-weight:800;letter-spacing:.17em;color:#267250;margin:0 0 16px}.hero h1{font-size:clamp(44px,5.4vw,76px);letter-spacing:-.075em;line-height:1.05;margin:0;max-width:760px;font-weight:780}.hero h1 em{font-style:normal;color:#2d8658}.lede{font-size:18px;line-height:1.65;color:#53645b;max-width:580px;margin:28px 0 26px}.text-link{font-weight:750;text-decoration:none;border-bottom:2px solid #2a8053;padding-bottom:5px}.text-link span{padding-left:6px}.hero-card{background:#fff;border:1px solid #dce4da;border-radius:18px;box-shadow:0 26px 70px rgba(20,50,35,.12);overflow:hidden;transform:rotate(2deg)}.card-top{height:53px;background:#eaf2ea;border-bottom:1px solid #dce4da;display:flex;align-items:center;gap:15px;padding:0 20px;color:#6b7c72;font-size:12px;font-family:ui-monospace,monospace}.lights{display:flex;gap:5px}.lights i{width:8px;height:8px;border-radius:50%;background:#b7cabc}.mini-row{display:grid;grid-template-columns:42px 1fr auto;gap:12px;align-items:center;padding:20px 26px;border-bottom:1px solid #e9ede7;font-family:ui-monospace,monospace;font-size:15px}.mini-index{color:#9aa79e}.mini-tag{color:#536e5d}.mini-price{font-weight:700}.flagged{background:#fff2e9}.flagged .mini-price{color:#b04f2d;text-decoration:underline wavy #c96844}.card-finding{margin:0;padding:20px 25px 24px;color:#a3492c;font-size:14px;line-height:1.45;font-weight:650}.card-finding span{font-size:19px;padding-right:8px}.checker-band{background:#e7efe8;border-top:1px solid #d3e2d6;border-bottom:1px solid #d3e2d6}.checker-grid{padding:78px 0;display:grid;grid-template-columns:.88fr 1.12fr;gap:10%;align-items:start}.section-intro h2,.below h2,.results h2{font-size:clamp(34px,3.7vw,52px);letter-spacing:-.065em;line-height:1.1;margin:0}.section-intro>p:not(.eyebrow){line-height:1.7;color:#52665b;max-width:400px}.privacy-note{display:flex;gap:14px;margin-top:36px;max-width:390px;align-items:start}.privacy-note>span{display:grid;place-items:center;flex:none;width:37px;height:37px;border-radius:50%;background:#d0e4d4;color:#236947;font-size:30px;line-height:1}.privacy-note p{margin:0;font-size:14px;line-height:1.55;color:#425a4b}.check-panel{background:#fff;padding:30px;border-radius:20px;border:1px solid #d3e0d6;box-shadow:0 18px 55px rgba(32,72,47,.08)}fieldset{padding:0;margin:0;border:0}legend,.file-step label{font-weight:750;margin-bottom:16px;display:block}.file-step label .step-number,legend:first-letter{color:#318559}.choice{display:flex;align-items:center;gap:12px;border:1px solid #dbe5dd;border-radius:11px;padding:14px 15px;margin-bottom:10px;cursor:pointer}.choice:has(input:checked){border-color:#318559;background:#f4faf4}.choice input{accent-color:#28784e;flex:none;width:17px;height:17px}.choice span{display:flex;flex-direction:column;gap:3px}.choice strong{font-size:14px}.choice small{font-size:12px;color:#66776c}.file-step{margin-top:24px}.file-step input{width:100%;max-width:100%;border:1px dashed #a8bdae;background:#f8fbf8;padding:19px 16px;border-radius:10px;color:#354c3c;font-size:13px}.file-step input::file-selector-button{background:#e5f0e7;border:0;padding:8px 10px;border-radius:5px;color:#235a3a;font-weight:700;cursor:pointer}.field-note,.panel-foot{color:#738277;font-size:12px;line-height:1.5}.field-note{overflow-wrap:anywhere}.primary-button{margin-top:16px;width:100%;border:0;background:#1f6843;color:#fff;border-radius:10px;padding:17px 20px;display:flex;justify-content:space-between;font-size:15px;font-weight:750;cursor:pointer}.primary-button:hover{background:#155134}.panel-foot{text-align:center;margin:13px 0 0}.results{padding:70px 0}.results[hidden],.secondary-button[hidden]{display:none}.results-heading{display:flex;justify-content:space-between;align-items:end;gap:20px}.secondary-button{border:1px solid #2e7951;background:transparent;color:#205f3d;border-radius:8px;padding:11px 14px;font-size:13px;font-weight:700;cursor:pointer}.summary{font-size:17px;font-weight:650;margin:25px 0}.findings{padding:0;margin:0;list-style:none;display:grid;gap:10px}.finding,.all-clear{border:1px solid #d8e1d8;background:#fff;border-radius:10px;padding:17px 19px;overflow-wrap:anywhere}.finding.error{border-left:4px solid #bb6548}.finding.warning{border-left:4px solid #d5a243}.finding-location{text-transform:uppercase;letter-spacing:.09em;color:#8b482d;font-size:11px;font-weight:800}.finding.warning .finding-location{color:#8b681f}.finding p{margin:7px 0;font-size:14px;line-height:1.55}.finding a{font-size:12px;color:#27734b;font-weight:700}.all-clear{color:#225f3d;font-weight:650}.results-limit{color:#5c695e;font-size:13px;line-height:1.5}.below{padding:88px 0 80px;display:grid;grid-template-columns:.35fr .65fr;gap:8%}.steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}.steps b{color:#318559;font-size:12px;letter-spacing:.13em}.steps h3{font-size:16px;margin:16px 0 8px}.steps p{font-size:14px;line-height:1.6;color:#637267;margin:0}.sample-strip{background:#e7efe8;border-radius:16px;padding:30px 35px;display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:75px}.sample-strip h2{font-size:22px;letter-spacing:-.045em;margin:0 0 5px}.sample-strip p{margin:0;color:#617568;font-size:13px}.sample-links{display:flex;gap:10px;flex-wrap:wrap}.sample-links a{border:1px solid #a9c4af;border-radius:8px;text-decoration:none;color:#1d6540;padding:11px 13px;font-size:13px;font-weight:750;background:#f6faf6}footer{border-top:1px solid #d9e0d8;padding:30px 0 40px;display:flex;justify-content:space-between;gap:20px;color:#728077;font-size:12px}footer a{color:#286e4a}@media(max-width:800px){.hero{grid-template-columns:1fr;padding:70px 0;gap:48px}.hero-card{max-width:500px;transform:none}.checker-grid{grid-template-columns:1fr;gap:30px;padding:60px 0}.below{grid-template-columns:1fr;gap:35px}.sample-strip{align-items:start;flex-direction:column}}@media(max-width:600px){.wrap{width:min(100% - 32px,1160px)}.site-header{min-height:68px}.header-note{display:none}.hero{padding:58px 0 66px;gap:44px}.hero h1{font-size:clamp(40px,11vw,58px)}.lede{font-size:16px;line-height:1.58;margin:22px 0}.hero-card{width:100%}.mini-row{padding:16px;grid-template-columns:27px 1fr auto;gap:8px;font-size:13px}.card-finding{padding:16px;font-size:12px}.checker-grid{padding:54px 0}.check-panel{padding:19px}.steps{grid-template-columns:1fr;gap:25px}.below{padding:65px 0}.results{padding:55px 0}.results-heading{align-items:start;flex-direction:column}.sample-strip{padding:24px;margin-bottom:55px}footer{flex-direction:column;gap:8px}}
/* Voluntary operator feedback */
.feedback-band{background:#f1f5ef;border-top:1px solid #d9e3d9;border-bottom:1px solid #d9e3d9}
.feedback-grid{padding:76px 0;display:grid;grid-template-columns:.88fr 1.12fr;gap:10%;align-items:start}
.feedback-grid .section-intro h2{font-size:clamp(34px,3.7vw,52px);letter-spacing:-.065em;line-height:1.1;margin:0}
.feedback-grid .section-intro p:not(.eyebrow){line-height:1.7;color:#52665b;max-width:440px}
.feedback-panel{min-width:0;background:#fff;padding:30px;border-radius:20px;border:1px solid #d3e0d6;box-shadow:0 18px 55px rgba(32,72,47,.06)}
#feedback-form>label{display:block;font-weight:750;font-size:14px;margin:0 0 8px}
.optional{font-weight:400;color:#6c7c70}
#feedback-form>input,#feedback-form>textarea{display:block;width:100%;max-width:100%;border:1px solid #b9cabb;border-radius:8px;padding:11px 12px;margin:0 0 20px;color:#182b25;background:#fff}
#feedback-form>textarea{resize:vertical;font:inherit;line-height:1.5}
.feedback-price{margin-top:4px}
.feedback-price legend{font-size:14px;margin-bottom:8px}
.feedback-price label{display:inline-flex;align-items:center;gap:6px;margin-right:20px;font-size:14px}
.feedback-price input{accent-color:#28784e}
.feedback-actions{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:16px 0}
.feedback-actions p{width:100%;margin:0 0 3px;font-size:14px;font-weight:650}
.feedback-panel>.field-note{margin-bottom:0}
@media(max-width:800px){.feedback-grid{grid-template-columns:1fr;gap:30px;padding:60px 0}}
@media(max-width:600px){.feedback-grid{padding:54px 0}.feedback-panel{padding:19px}.feedback-actions .secondary-button{width:100%}}
/* Trust and source pages */
.trust{border-top:1px solid #d9e0d8;padding:65px 0 75px;display:grid;grid-template-columns:1fr 1.4fr;gap:9%}
.trust h2,.source-intro h1{font-size:clamp(30px,3vw,44px);letter-spacing:-.055em;line-height:1.13;margin:0 0 17px}
.trust p:not(.eyebrow),.source-intro p:not(.eyebrow){font-size:15px;line-height:1.7;color:#52665b;margin:0 0 17px}
.trust a,.source-intro a{color:#205f3d;overflow-wrap:anywhere}
.source-intro{padding:70px 0 30px;max-width:850px}
.source-files{padding-bottom:70px}
.source-file{border:1px solid #d9e0d8;background:#fff;border-radius:12px;margin:0 0 16px;overflow:hidden}
.source-file summary{padding:17px 20px;font-weight:750;cursor:pointer;color:#205f3d}
.source-file pre{border-top:1px solid #d9e0d8;margin:0;padding:20px;max-height:560px;overflow:auto;white-space:pre;font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;color:#182b25}
@media(max-width:800px){.trust{grid-template-columns:1fr;gap:25px}}
checker.js
(function (root) {
  'use strict';

  const FORMAT = 'https://help.shopify.com/en/manual/products/import-export/using-csv';
  const ISSUES = 'https://help.shopify.com/en/manual/products/import-export/common-import-issues';
  const CSV = 'https://help.shopify.com/en/manual/shopify-admin/productivity-tools/csv-files';

  // Current English product export headings. Market and metafield headings are dynamic.
  const HEADERS = new Set(`Title|URL handle|Description|Vendor|Product category|Type|Tags|Published on online store|Status|SKU|Barcodes|Option1 name|Option1 value|Option1 LinkedTo|Option2 name|Option2 value|Option2 LinkedTo|Option3 name|Option3 value|Option3 LinkedTo|Price|Compare-at price|Cost per item|Charge tax|Inventory tracker|Inventory quantity|Continue selling when out of stock|Weight value (grams)|Weight unit for display|Packed product length|Packed product width|Packed product height|Packed product dimension unit|Requires shipping|Fulfillment service|Product image URL|Image position|Image alt text|Variant image URL|Gift card|SEO title|SEO description|Google Shopping / Google Product Category|Collection`.split('|'));
  const LEGACY = {
    Handle: 'URL handle', 'Body (HTML)': 'Description', Published: 'Published on online store',
    'Option1 Name': 'Option1 name', 'Option1 Value': 'Option1 value',
    'Option2 Name': 'Option2 name', 'Option2 Value': 'Option2 value',
    'Option3 Name': 'Option3 name', 'Option3 Value': 'Option3 value',
    'Variant SKU': 'SKU', 'Variant Grams': 'Weight value (grams)',
    'Variant Inventory Tracker': 'Inventory tracker', 'Variant Inventory Qty': 'Inventory quantity',
    'Variant Inventory Policy': 'Continue selling when out of stock',
    'Variant Fulfillment Service': 'Fulfillment service', 'Variant Price': 'Price',
    'Variant Compare-at Price': 'Compare-at price', 'Variant Requires Shipping': 'Requires shipping',
    'Variant Taxable': 'Charge tax', 'Variant Barcode': 'Barcodes', Barcode: 'Barcodes', 'Image Src': 'Product image URL',
    'Image Alt Text': 'Image alt text', 'Variant Image': 'Variant image URL'
  };
  const VARIANT_FIELDS = ['SKU', 'Barcodes', 'Price', 'Compare-at price', 'Cost per item', 'Charge tax',
    'Inventory tracker', 'Inventory quantity', 'Continue selling when out of stock', 'Weight value (grams)',
    'Requires shipping', 'Fulfillment service', 'Variant image URL'];
  const GOOGLE_FIELDS = new Set(['Google Product Category', 'Gender', 'Age Group', 'MPN',
    'AdWords Grouping', 'AdWords Labels', 'Condition', 'Custom Product',
    'Custom Label 0', 'Custom Label 1', 'Custom Label 2', 'Custom Label 3', 'Custom Label 4']);
  const PACKED_FIELDS = ['Packed product length', 'Packed product width', 'Packed product height', 'Packed product dimension unit'];

  function recognized(header) {
    return HEADERS.has(header) || (header.startsWith('Google Shopping / ') && GOOGLE_FIELDS.has(header.slice(18))) ||
      /^(Included|Price|Compare At Price|Compare-at price) \/ .+$/.test(header) ||
      /^(?:product\.metafields\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+|.+?;? \(product\.metafields\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\))$/.test(header);
  }

  // Strict quote handling while retaining physical line numbers, including embedded newlines.
  function parseCsv(text) {
    const rows = [];
    const errors = [];
    let cells = [], cell = '', state = 'start', line = 1, rowLine = 1, i = 0;
    const error = message => errors.push({ row: rowLine, column: `Column ${cells.length + 1}`, severity: 'error', message, help: ISSUES });
    const field = () => { cells.push(cell); cell = ''; state = 'start'; };
    const record = () => { field(); rows.push({ line: rowLine, cells }); cells = []; rowLine = line + 1; };
    while (i < text.length) {
      const ch = text[i];
      if (state === 'quoted') {
        if (ch === '"') {
          if (text[i + 1] === '"') { cell += '"'; i++; } else state = 'afterQuote';
        } else if (ch === '\r' || ch === '\n') {
          cell += '\n'; if (ch === '\r' && text[i + 1] === '\n') i++; line++;
        } else cell += ch;
      } else if (ch === ',') field();
      else if (ch === '\r' || ch === '\n') {
        if (ch === '\r' && text[i + 1] === '\n') i++;
        record(); line++;
      } else if (state === 'start' && ch === '"') state = 'quoted';
      else if (ch === '"') {
        error(state === 'afterQuote' ? 'Unexpected quote after a quoted cell. Check the quotes and commas.' : 'Stray quote inside an unquoted cell. Wrap the whole cell in straight quotes and double any quotes inside it.');
        cell += ch; state = 'plain';
      } else if (state === 'afterQuote') {
        error('Text after a closing quote. Check the quotes and commas in this cell.');
        cell += ch; state = 'plain';
      } else { cell += ch; state = 'plain'; }
      i++;
    }
    if (state === 'quoted') error('Unclosed quoted cell. Add a closing straight quote before the end of the file.');
    if (cells.length || cell || state !== 'start') { field(); rows.push({ line: rowLine, cells }); }
    return { rows, errors };
  }

  function analyze(input, mode = 'new') {
    if (mode !== 'new' && mode !== 'update') throw new Error('Mode must be new or update');
    const text = input.startsWith('\uFEFF') ? input.slice(1) : input;
    const { rows, errors } = parseCsv(text);
    const findings = [...errors];
    const add = (row, column, severity, message, help = FORMAT) => findings.push({ row, column, severity, message, help });
    if (!rows.length || rows[0].cells.every(x => !x.trim())) {
      add(1, 'Headers', 'error', 'The CSV is empty. Add a header row and product data.');
      return { findings, rowsChecked: 0 };
    }
    const rawHeaders = rows[0].cells;
    const headers = rawHeaders.map(h => LEGACY[h.trim()] || h.trim());
    const indexes = new Map();
    headers.forEach((header, index) => {
      const raw = rawHeaders[index];
      if (raw !== raw.trim()) add(1, raw || `Column ${index + 1}`, 'warning', 'This header has extra spaces. Remove them to match Shopify’s heading exactly.');
      if (!recognized(header)) add(1, raw || `Column ${index + 1}`, 'error', `“${raw || '(blank)'}” is not a recognized product CSV header. Compare it with Shopify’s product CSV headings.`);
      if (Object.hasOwn(LEGACY, raw.trim())) add(1, raw, 'warning', `“${raw}” is an older Shopify heading. Shopify currently calls it “${header}”.`);
      if (indexes.has(header)) add(1, raw || `Column ${index + 1}`, 'error', `“${header}” appears more than once. Keep one column for each heading.`);
      else indexes.set(header, index);
    });
    for (const required of mode === 'update' ? ['URL handle', 'Title'] : ['Title']) {
      if (!indexes.has(required)) add(1, required, 'error', `Add the “${required}” column for ${mode === 'update' ? 'product updates' : 'new products'}.`);
    }
    if (mode === 'update' && VARIANT_FIELDS.some(name => indexes.has(name)) &&
        (!indexes.has('Option1 name') || !indexes.has('Option1 value'))) {
      add(1, 'Option1 name / Option1 value', 'error', 'A variant column is included without both Option1 name and value columns. Add both columns and the matching variant values before updating; even blank variant cells can affect existing variants.');
    }
    if (text.includes('\uFFFD')) add(1, 'Encoding', 'warning', 'Replacement characters (�) were found. Re-save the CSV as UTF-8 and check the affected text.', CSV);
    const cell = (cells, name) => indexes.has(name) ? (cells[indexes.get(name)] || '').trim() : '';
    const products = new Map();
    let dataRows = 0;
    for (const row of rows.slice(1)) {
      if (row.cells.every(x => !x.trim())) continue;
      dataRows++;
      if (row.cells.length !== headers.length) add(row.line, 'Row', 'error', `This row has ${row.cells.length} cells; the header has ${headers.length}. Check commas and quoted text.`, ISSUES);
      const handle = cell(row.cells, 'URL handle');
      const title = cell(row.cells, 'Title');
      const existing = handle ? products.get(handle.toLowerCase()) : undefined;
      if (mode === 'update' && !handle) add(row.line, 'URL handle', 'error', 'Add the product URL handle to identify the product to update.');
      if (handle && !/^[A-Za-z0-9-]+$/.test(handle)) add(row.line, 'URL handle', 'error', 'A URL handle should contain only letters, numbers, and dashes, with no spaces.');
      if (!title && (!existing || !handle)) add(row.line, 'Title', 'error', 'Add a product title on the first row for this product. Variant and extra image rows can leave it blank.');
      if (existing && title) add(row.line, 'URL handle', 'warning', `This handle already appeared on row ${existing.first}${existing.title && title !== existing.title ? ' with a different title' : ''}. Repeated titles can merge product rows; leave Title blank on later variant and image rows.`, ISSUES);
      if (!existing && handle) products.set(handle.toLowerCase(), { first: row.line, title, variants: new Map() });
      if (existing && title && !existing.title) existing.title = title;
      if (!handle && mode === 'new' && ['Option1 value', 'Option2 value', 'Option3 value'].some(name => cell(row.cells, name))) {
        add(row.line, 'URL handle', 'error', 'Add a URL handle when a product has variants. Use the same handle on each variant row.');
      }
      for (let n = 1; n <= 3; n++) {
        const name = `Option${n} name`, value = `Option${n} value`;
        if (cell(row.cells, name) && !cell(row.cells, value)) add(row.line, value, 'error', `Add ${value} for this variant, or clear ${name}.`);
        if (cell(row.cells, value) && !cell(row.cells, name)) add(row.line, name, 'error', `Add ${name} for this variant, or clear ${value}.`);
        if (n > 1 && (cell(row.cells, name) || cell(row.cells, value)) && !cell(row.cells, `Option${n - 1} value`)) add(row.line, name, 'error', `Fill Option${n - 1} name and value before using option ${n}.`);
      }
      if (mode === 'update' && indexes.has('Option1 name') && indexes.has('Option1 value') &&
          VARIANT_FIELDS.some(name => cell(row.cells, name)) && (!cell(row.cells, 'Option1 name') || !cell(row.cells, 'Option1 value'))) {
        add(row.line, 'Option1 name / Option1 value', 'error', 'Updating variant data without both Option1 name and value can replace existing variants. Include both option columns and their values.');
      }
      const hasVariant = ['Option1 value', 'Option2 value', 'Option3 value'].some(name => cell(row.cells, name));
      if (handle && hasVariant) {
        const product = products.get(handle.toLowerCase());
        const key = [1, 2, 3].map(n => cell(row.cells, `Option${n} value`).toLowerCase()).join('\u0000');
        if (product.variants.has(key)) add(row.line, 'Option values', 'error', `This variant option combination already appears on row ${product.variants.get(key)} for the same handle. Make each combination unique.`, ISSUES);
        else product.variants.set(key, row.line);
      }
      for (const name of headers.filter(h => h === 'Price' || h === 'Compare-at price' || h === 'Cost per item' || /^Price \/ .+$|^Compare At Price \/ .+$|^Compare-at price \/ .+$/.test(h))) {
        const value = cell(row.cells, name);
        if (value && !/^\d+(?:\.\d+)?$/.test(value)) add(row.line, name, 'error', 'Enter a non-negative numeric price without a currency symbol or thousands separator.');
        if (name === 'Price' && !value && (hasVariant || title || VARIANT_FIELDS.some(h => h !== 'Price' && cell(row.cells, h)))) add(row.line, name, 'warning', 'Price is blank. Shopify may use 0.00 or report a blank-price error for this variant; check the intended price.', ISSUES);
      }
      for (const name of ['Published on online store', 'Charge tax', 'Requires shipping', 'Gift card', ...headers.filter(h => /^Included \/ .+$/.test(h))]) {
        const value = cell(row.cells, name);
        if (value && !/^(true|false)$/i.test(value)) add(row.line, name, 'error', 'Use true or false for this column.');
      }
      const status = cell(row.cells, 'Status');
      if (mode === 'update' && indexes.has('Status') && !status && title) add(row.line, 'Status', 'error', 'Status is included but blank on an update. Enter active, draft, or archived to avoid an unintended status change.');
      if (status && !/^(active|draft|archived)$/i.test(status)) add(row.line, 'Status', 'error', 'Use active, draft, or archived for Status.');
      const policy = cell(row.cells, 'Continue selling when out of stock');
      if (policy && !/^(deny|continue)$/i.test(policy)) add(row.line, 'Continue selling when out of stock', 'error', 'Use deny or continue for the inventory policy.', ISSUES);
      const quantity = cell(row.cells, 'Inventory quantity');
      if (quantity && !/^-?\d+$/.test(quantity)) add(row.line, 'Inventory quantity', 'error', 'Enter a whole-number inventory quantity.', ISSUES);
      if (cell(row.cells, 'Inventory tracker') && !quantity && indexes.has('Inventory quantity')) {
        add(row.line, 'Inventory quantity', 'warning', 'Inventory tracker is set but quantity is blank. Shopify documents a zero default and also a blank-quantity import error; verify this row in a test import.', ISSUES);
      }
      const fulfillment = cell(row.cells, 'Fulfillment service');
      if (fulfillment && !['manual', 'shipwire', 'webgistix', 'amazon_marketplace_web', 'gift_card'].includes(fulfillment.toLowerCase()) && !cell(row.cells, 'SKU')) {
        add(row.line, 'SKU', 'error', 'A custom fulfillment service requires a nonblank SKU.');
      }
      const weight = cell(row.cells, 'Weight value (grams)');
      if (weight && !/^\d+$/.test(weight)) add(row.line, 'Weight value (grams)', 'error', 'Enter whole grams without a unit or decimal.');
      const packed = PACKED_FIELDS.map(name => cell(row.cells, name));
      const ships = cell(row.cells, 'Requires shipping').toLowerCase() !== 'false';
      if (ships && packed.some(Boolean) && !packed.every(Boolean)) {
        add(row.line, 'Packed product dimensions', 'error', 'Fill all four packed product size fields, or leave all four blank.');
      }
      const packedUnit = cell(row.cells, 'Packed product dimension unit');
      if (ships && packedUnit && !/^(cm|in)$/i.test(packedUnit)) add(row.line, 'Packed product dimension unit', 'error', 'Use cm or in for the packed product dimension unit.');
      for (const name of headers.filter(h => /^Compare(?: At|-at) Price \/ .+$/.test(h))) {
        const market = name.slice(name.indexOf(' / '));
        if (cell(row.cells, name) && !cell(row.cells, `Price${market}`)) {
          add(row.line, name, 'error', 'A fixed market compare-at price needs a fixed price for the same market.');
        }
      }
      for (const name of ['Product image URL', 'Variant image URL']) {
        const value = cell(row.cells, name);
        if (!value) continue;
        let url;
        try { url = new URL(value); } catch { /* Report below. */ }
        if (!url || !['https:', 'http:'].includes(url.protocol) || !url.hostname) {
          add(row.line, name, 'error', 'Enter a direct, publicly accessible image URL beginning with https://.');
        } else if (url.protocol === 'http:') {
          add(row.line, name, 'warning', 'Shopify recommends publicly accessible https:// image URLs. Verify this http:// image in a test import.');
        }
      }
      const imagePosition = cell(row.cells, 'Image position');
      if (imagePosition && (!/^\d+$/.test(imagePosition) || Number(imagePosition) < 1)) {
        add(row.line, 'Image position', 'error', 'Enter a positive whole number for image position.');
      }
      if (cell(row.cells, 'Collection').length > 255) add(row.line, 'Collection', 'error', 'Collection names can be at most 255 characters.');
    }
    if (!dataRows) add(2, 'Rows', 'error', 'No product rows found beneath the headers.');
    findings.sort((a, b) => a.row - b.row || (a.severity === b.severity ? 0 : a.severity === 'error' ? -1 : 1));
    return { findings, rowsChecked: dataRows };
  }

  const api = { analyze, parseCsv, FORMAT, ISSUES, CSV };
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
  root.ShopifyCsvChecker = api;
})(typeof window !== 'undefined' ? window : globalThis);
app.js
(function () {
  'use strict';
  const form = document.getElementById('check-form');
  const fileInput = document.getElementById('csv-file');
  const fileNote = document.getElementById('file-note');
  const results = document.getElementById('results');
  const summary = document.getElementById('summary');
  const list = document.getElementById('findings');
  const download = document.getElementById('download');
  let currentFindings = [];

  fileInput.addEventListener('change', () => {
    fileNote.textContent = fileInput.files[0] ? fileInput.files[0].name : 'CSV only · up to 15 MB · UTF-8';
  });

  form.addEventListener('submit', async event => {
    event.preventDefault();
    const file = fileInput.files[0];
    if (!file) return;
    results.hidden = true;
    list.replaceChildren();
    download.hidden = true;
    currentFindings = [];
    if (file.size > 15_000_000) {
      summary.textContent = 'This file is larger than Shopify’s 15 MB CSV limit. Split it into smaller files.';
      results.hidden = false;
      results.scrollIntoView({ behavior: 'smooth' });
      return;
    }
    let content;
    try {
      content = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer());
    } catch {
      summary.textContent = 'This file is not valid UTF-8. Export it as a UTF-8 CSV and try again.';
      results.hidden = false;
      results.scrollIntoView({ behavior: 'smooth' });
      return;
    }
    const mode = form.elements.mode.value;
    const report = window.ShopifyCsvChecker.analyze(content, mode);
    currentFindings = report.findings;
    const errors = currentFindings.filter(x => x.severity === 'error').length;
    const warnings = currentFindings.length - errors;
    summary.textContent = `${report.rowsChecked} product row${report.rowsChecked === 1 ? '' : 's'} checked · ${errors} likely issue${errors === 1 ? '' : 's'} · ${warnings} warning${warnings === 1 ? '' : 's'}.`;
    if (!currentFindings.length) {
      const item = document.createElement('li');
      item.className = 'all-clear';
      item.textContent = 'No likely problems found by these checks. Back up your store and test the import in Shopify.';
      list.append(item);
    }
    for (const finding of currentFindings) {
      const item = document.createElement('li');
      item.className = `finding ${finding.severity}`;
      const location = document.createElement('div');
      location.className = 'finding-location';
      location.textContent = `Row ${finding.row} · ${finding.column}`;
      const detail = document.createElement('p');
      detail.textContent = finding.message;
      const link = document.createElement('a');
      link.href = finding.help;
      link.target = '_blank';
      link.rel = 'noopener noreferrer';
      link.textContent = 'Shopify guidance ↗';
      item.append(location, detail, link);
      list.append(item);
    }
    download.hidden = !currentFindings.length;
    results.hidden = false;
    results.scrollIntoView({ behavior: 'smooth' });
  });

  download.addEventListener('click', () => {
    const quote = value => `"${String(value).replaceAll('"', '""')}"`;
    const rows = [['Row', 'Column', 'Severity', 'Finding', 'Shopify guidance'],
      ...currentFindings.map(f => [f.row, f.column, f.severity, f.message, f.help])];
    const blob = new Blob(['\uFEFF', rows.map(row => row.map(quote).join(',')).join('\r\n')], { type: 'text/csv;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = 'preflight-findings.csv';
    link.click();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  });
})();
feedback.js
(function () {
  'use strict';
  const storageKey = 'product-csv-preflight-feedback-v1';
  const form = document.getElementById('feedback-form');
  const status = document.getElementById('feedback-status');
  const count = document.getElementById('feedback-count');
  const download = document.getElementById('download-feedback');
  const clear = document.getElementById('clear-feedback');
  let responses = [];
  let storageAvailable = true;

  try {
    const saved = JSON.parse(localStorage.getItem(storageKey) || '[]');
    if (Array.isArray(saved)) {
      responses = saved.filter(response => response &&
        typeof response.recordedAt === 'string' &&
        typeof response.operatorRef === 'string' &&
        typeof response.actionableFinding === 'string' &&
        typeof response.unsupportedFailure === 'string' &&
        ['yes', 'no'].includes(response.nineDollarRequest));
    }
  } catch {
    storageAvailable = false;
  }

  function render() {
    count.textContent = `${responses.length} response${responses.length === 1 ? '' : 's'} recorded in this browser.`;
    download.hidden = clear.hidden = responses.length === 0;
  }

  function save() {
    if (!storageAvailable) return;
    try {
      localStorage.setItem(storageKey, JSON.stringify(responses));
    } catch {
      storageAvailable = false;
    }
  }

  function summaryText() {
    const lines = [
      'Product CSV preflight — voluntary operator feedback',
      `${responses.length} recorded response${responses.length === 1 ? '' : 's'}`,
      'Self-reported observations; not verified import results or evidence of a purchase.',
      ''
    ];
    responses.forEach((response, index) => {
      lines.push(`Response ${index + 1}`);
      lines.push(`Recorded (UTC): ${response.recordedAt}`);
      lines.push(`Operator reference: ${response.operatorRef || 'Not provided'}`);
      lines.push(`Actionable pre-import finding: ${response.actionableFinding || 'None reported'}`);
      lines.push(`Unsupported or missed failure: ${response.unsupportedFailure || 'None reported'}`);
      lines.push(`Would request a complete report for $9: ${response.nineDollarRequest === 'yes' ? 'Yes' : 'No'}`);
      lines.push('');
    });
    return lines.join('\n');
  }

  form.addEventListener('submit', event => {
    event.preventDefault();
    const data = new FormData(form);
    const nineDollarRequest = data.get('nineDollarRequest');
    if (nineDollarRequest !== 'yes' && nineDollarRequest !== 'no') return;
    responses.push({
      recordedAt: new Date().toISOString(),
      operatorRef: String(data.get('operatorRef') || '').trim(),
      actionableFinding: String(data.get('actionableFinding') || '').trim(),
      unsupportedFailure: String(data.get('unsupportedFailure') || '').trim(),
      nineDollarRequest
    });
    save();
    render();
    form.reset();
    status.textContent = storageAvailable
      ? 'Response recorded in this browser. Download the summary to share it.'
      : 'Response recorded for this visit only. Download the summary before leaving this page.';
  });

  download.addEventListener('click', () => {
    const blob = new Blob([summaryText()], { type: 'text/plain;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = 'preflight-feedback-summary.txt';
    link.click();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  });

  clear.addEventListener('click', () => {
    if (!window.confirm('Clear all feedback recorded in this browser? Download the summary first if you need it.')) return;
    responses = [];
    save();
    render();
    status.textContent = 'Recorded responses cleared from this browser.';
  });

  render();
  if (!storageAvailable) status.textContent = 'Browser storage is unavailable. Download the summary before leaving this page.';
})();