SEO / Developer

SITEMAP GENERATOR

Build XML sitemaps with full standard support — image, video, news, hreflang, sitemap index. Manual entry, bulk import, or site scanner.

URLs: 0
No URLs yet. Click + Add URL or use Bulk Import / Scanner.
⚠ Scanner uses a public CORS proxy (api.allorigins.win) to fetch pages. Your requested URLs are routed through this third-party service. Only scan sites you own or have permission to scan.
Ready. Configure options and click Scan Site.

GENERATE SITEMAP

Output format
XML extensions (namespaces)
Output

SITEMAP STANDARDS

All supported tags, their allowed values, and links to the authoritative specification. Click any row to expand.

<loc> — Page URL
sitemaps.org · Required

The full URL of the page. Must start with http:// or https://. Must be URL-encoded. Maximum 2,048 characters. Must exactly match the canonical URL Google would index.

<loc>https://www.example.com/page/</loc>
↗ sitemaps.org — <loc> spec
<lastmod> — Last Modified Date
sitemaps.org · Optional

Date the URL was last modified. Use W3C Datetime format. Providing inaccurate dates may cause Google to ignore the field entirely. Use YYYY-MM-DD for date-only, or YYYY-MM-DDThh:mm:ss+TZ for full datetime.

2024-01-15 2024-01-15T08:30:00+05:30 2024-01-15T00:00:00Z
↗ sitemaps.org — <lastmod> spec
<changefreq> — Change Frequency
sitemaps.org · Optional · Hint only

How frequently the page is likely to change. Used as a hint — crawlers may ignore it. always is for pages that change on every access. never is for archived content.

alwayshourlydailyweeklymonthlyyearlynever
↗ sitemaps.org — <changefreq> spec
<priority> — Relative Priority
sitemaps.org · Optional · Hint only

Priority of this URL relative to other URLs on your site. Range 0.01.0. Default is 0.5. Does not affect ranking in search results — only tells the crawler which pages to crawl first. Google has stated it largely ignores this value.

1.0 — Homepage0.8 — Important0.5 — Default0.3 — Low
↗ sitemaps.org — <priority> spec
<image:image> — Image Extension
Google · Optional · Up to 1,000 per URL

Provides Google with information about images on your page. Helps images appear in Google Image Search. Add the xmlns:image namespace to the <urlset> tag. Up to 1,000 images per URL.

<image:image> <image:loc>https://example.com/img/photo.jpg</image:loc> <image:caption>A photo of our product</image:caption> <image:title>Product Photo</image:title> <image:license>https://creativecommons.org/licenses/by/4.0/</image:license> <image:geo_location>Mumbai, India</image:geo_location> </image:image>
↗ Google — Image Sitemap docs
<video:video> — Video Extension
Google · Optional

Provides metadata about videos on your page for Google Video Search. Requires either video:content_loc or video:player_loc. video:thumbnail_loc, video:title, and video:description are required.

<video:video> <video:thumbnail_loc>https://example.com/thumb.jpg</video:thumbnail_loc> <video:title>My Video</video:title> <video:description>A description of the video.</video:description> <video:content_loc>https://example.com/video.mp4</video:content_loc> <video:duration>360</video:duration> <video:publication_date>2024-01-15</video:publication_date> <video:family_friendly>yes</video:family_friendly> <video:live>no</video:live> <video:tag>tutorial</video:tag> </video:video>
↗ Google — Video Sitemap docs
<news:news> — News Extension
Google News · Optional · Articles published within 2 days

Used to submit news articles to Google News. Only articles published within the last 48 hours will be crawled. Requires Google News publisher approval. news:publication_date must be in W3C datetime format.

<news:news> <news:publication> <news:name>The Example Times</news:name> <news:language>en</news:language> </news:publication> <news:publication_date>2024-01-15T09:00:00+00:00</news:publication_date> <news:title>Article headline here</news:title> <news:keywords>business, tech, AI</news:keywords> </news:news>
↗ Google — News Sitemap docs
<xhtml:link> — hreflang / Language Alternates
W3C / Google · Optional · Internationalization

Tells search engines about localized versions of your pages. Each URL that has alternates must list all alternate versions including itself. Use hreflang="x-default" for the fallback version shown when no other locale matches.

<!-- In every localized URL entry --> <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/"/> <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/"/> <xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/"/> <xhtml:link rel="alternate" hreflang="ja" href="https://example.com/ja/"/>

Valid hreflang values follow BCP 47 language tags: en, en-US, en-GB, fr, fr-CA, zh-Hans, zh-Hant, x-default

↗ Google — hreflang docs    ↗ W3C — BCP 47 language tags
<sitemapindex> — Sitemap Index
sitemaps.org · For sites with 50,000+ URLs

A sitemap index file lists multiple sitemap files. Each individual sitemap can have at most 50,000 URLs and must be under 50 MB (uncompressed). The index itself can reference up to 50,000 sitemaps. Submit the index URL to Google Search Console.

<?xml version="1.0" encoding="UTF-8"?> <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <sitemap> <loc>https://www.example.com/sitemap1.xml</loc> <lastmod>2024-01-15</lastmod> </sitemap> <sitemap> <loc>https://www.example.com/sitemap2.xml</loc> <lastmod>2024-01-10</lastmod> </sitemap> </sitemapindex>
↗ sitemaps.org — Sitemap Index spec    ↗ Google — Large Sitemaps docs
Submission — Google, Bing, Yandex
Search engine indexing

Google Search Console: Go to Sitemaps → Enter sitemap URL → Submit. Or ping: https://www.google.com/ping?sitemap=YOUR_SITEMAP_URL

Bing/Microsoft: Bing Webmaster Tools → Sitemaps → Submit. Or ping: https://www.bing.com/ping?sitemap=YOUR_SITEMAP_URL

Yandex: Yandex Webmaster → Site Map → Add. Or add to robots.txt: Sitemap: https://example.com/sitemap.xml

Yahoo: Yahoo uses Bing's index — submit to Bing Webmaster Tools.

Best practice: Reference your sitemap in robots.txt so all crawlers discover it automatically: Sitemap: https://example.com/sitemap.xml

↗ Google Search Console    ↗ Bing Webmaster Tools
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

// ── State ──────────────────────────────────────────────────────────────────
let urls = [];
let editingId = null;
let outputFmt = 'xml';
let scanAbort = false;
let idCounter = 0;

function uid() { return 'u' + (++idCounter); }

// ── Tab switching ──────────────────────────────────────────────────────────
function switchTab(name) {
  document.querySelectorAll('.tab').forEach((t, i) => {
    const names = ['builder', 'bulk', 'scanner'];
    t.classList.toggle('active', names[i] === name);
  });
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
  document.getElementById('tab-' + name).classList.add('active');
}

// ── URL Table ──────────────────────────────────────────────────────────────
function renderTable() {
  const tbody = document.getElementById('urlTbody');
  const table = document.getElementById('urlTable');
  const empty = document.getElementById('emptyState');
  const countEl = document.getElementById('urlCount');
  const warnEl = document.getElementById('urlWarn');

  countEl.textContent = urls.length;
  warnEl.classList.toggle('hidden', urls.length <= 50000);

  if (urls.length === 0) {
    table.classList.add('hidden');
    empty.classList.remove('hidden');
    tbody.innerHTML = '';
    return;
  }
  table.classList.remove('hidden');
  empty.classList.add('hidden');

  tbody.innerHTML = urls.map((u, i) => {
    const badges = [
      u.images && u.images.length ? `<span class="ext-badge eb-img">img×${u.images.length}</span>` : '',
      u.videos && u.videos.length ? `<span class="ext-badge eb-vid">vid×${u.videos.length}</span>` : '',
      u.news && u.news.title ? '<span class="ext-badge eb-news">news</span>' : '',
      u.alternates && u.alternates.length ? `<span class="ext-badge eb-lang">lang×${u.alternates.length}</span>` : ''
    ].filter(Boolean).join('');

    return `<tr>
      <td style="color:var(--muted);font-size:11px">${i + 1}</td>
      <td class="td-loc" title="${esc(u.loc)}">${esc(u.loc) || '<span style="color:var(--muted)">—</span>'}</td>
      <td style="font-size:11px;color:var(--muted)">${u.lastmod || '—'}</td>
      <td class="td-freq">${u.changefreq || '—'}</td>
      <td class="td-pri">${u.priority || '—'}</td>
      <td class="td-ext">${badges || '<span style="color:var(--border);font-size:11px">—</span>'}</td>
      <td class="td-actions">
        <button class="btn-ghost" style="padding:4px 10px;font-size:10px" onclick="openModal('${u.id}')">edit</button>
        <button class="btn-danger" onclick="deleteUrl('${u.id}')">✕</button>
      </td>
    </tr>`;
  }).join('');
}

function esc(s) {
  if (!s) return '';
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

function deleteUrl(id) {
  urls = urls.filter(u => u.id !== id);
  renderTable();
}

function clearAll() {
  if (urls.length === 0) return;
  if (!confirm('Clear all ' + urls.length + ' URL(s)?')) return;
  urls = [];
  renderTable();
}

// ── Modal ──────────────────────────────────────────────────────────────────
function openModal(id) {
  editingId = id;
  const u = id ? urls.find(u => u.id === id) : null;
  document.getElementById('modalTitle').textContent = id ? 'EDIT URL' : 'ADD URL';

  // Reset modal tabs
  switchModalTab('basic');

  // Populate basic fields
  document.getElementById('f-loc').value = u ? u.loc : '';
  document.getElementById('f-lastmod').value = u ? u.lastmod : '';
  document.getElementById('f-changefreq').value = u ? u.changefreq : 'monthly';
  document.getElementById('f-priority').value = u ? u.priority : '0.8';

  // Images
  renderSubList('imagesList', u ? u.images : [], renderImageItem);
  // Videos
  renderSubList('videosList', u ? u.videos : [], renderVideoItem);
  // News
  const n = u && u.news ? u.news : {};
  document.getElementById('f-news-name').value = n.publication_name || '';
  document.getElementById('f-news-lang').value = n.publication_language || '';
  document.getElementById('f-news-date').value = n.publication_date ? n.publication_date.replace('Z','').substring(0,16) : '';
  document.getElementById('f-news-title').value = n.title || '';
  document.getElementById('f-news-keywords').value = n.keywords || '';
  // Alternates
  renderSubList('alternatesList', u ? u.alternates : [], renderAltItem);

  document.getElementById('modalOverlay').classList.remove('hidden');
  setTimeout(() => document.getElementById('f-loc').focus(), 50);
}

function closeModal() {
  document.getElementById('modalOverlay').classList.add('hidden');
  editingId = null;
}

function handleOverlayClick(e) {
  if (e.target === document.getElementById('modalOverlay')) closeModal();
}

function switchModalTab(name) {
  document.querySelectorAll('.modal-tab').forEach((t, i) => {
    const ns = ['basic','images','video','news','hreflang'];
    t.classList.toggle('active', ns[i] === name);
  });
  document.querySelectorAll('.modal-tab-panel').forEach(p => p.classList.remove('active'));
  const el = document.getElementById('mtp-' + name);
  if (el) el.classList.add('active');
}

function saveUrl() {
  const loc = document.getElementById('f-loc').value.trim();
  if (!loc) { document.getElementById('f-loc').focus(); return; }

  const u = editingId ? urls.find(u => u.id === editingId) : { id: uid(), images: [], videos: [], news: {}, alternates: [] };

  u.loc = loc;
  u.lastmod = document.getElementById('f-lastmod').value;
  u.changefreq = document.getElementById('f-changefreq').value;
  u.priority = document.getElementById('f-priority').value;
  u.images = collectSubItems('imagesList', collectImage);
  u.videos = collectSubItems('videosList', collectVideo);
  u.alternates = collectSubItems('alternatesList', collectAlt);
  u.news = {
    publication_name: document.getElementById('f-news-name').value.trim(),
    publication_language: document.getElementById('f-news-lang').value.trim(),
    publication_date: document.getElementById('f-news-date').value ? document.getElementById('f-news-date').value + ':00Z' : '',
    title: document.getElementById('f-news-title').value.trim(),
    keywords: document.getElementById('f-news-keywords').value.trim()
  };

  if (!editingId) urls.push(u);
  closeModal();
  renderTable();
}

// ── Sub-list helpers ────────────────────────────────────────────────────────
function renderSubList(containerId, items, renderFn) {
  const el = document.getElementById(containerId);
  el.innerHTML = '';
  (items || []).forEach((item, i) => el.appendChild(renderFn(item, i)));
}

function collectSubItems(containerId, collectFn) {
  const items = [];
  document.getElementById(containerId).querySelectorAll('.sub-item').forEach(el => {
    const item = collectFn(el);
    if (item) items.push(item);
  });
  return items;
}

// Images
function renderImageItem(data, idx) {
  const div = document.createElement('div');
  div.className = 'sub-item';
  div.innerHTML = `
    <div class="sub-item-hdr">
      <span>Image ${(idx||0)+1}</span>
      <button class="btn-danger" onclick="this.closest('.sub-item').remove()">✕ Remove</button>
    </div>
    <div class="field"><label>image:loc (URL) * <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-loc" target="_blank" rel="noopener">↗</a></label><input type="url" name="img-loc" value="${esc(data&&data.loc||'')}" placeholder="https://example.com/photo.jpg"/></div>
    <div class="grid-2">
      <div class="field"><label>image:caption <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-caption" target="_blank" rel="noopener">↗</a></label><input type="text" name="img-cap" value="${esc(data&&data.caption||'')}" placeholder="Caption text"/></div>
      <div class="field"><label>image:title <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-title" target="_blank" rel="noopener">↗</a></label><input type="text" name="img-title" value="${esc(data&&data.title||'')}" placeholder="Image title"/></div>
    </div>
    <div class="grid-2">
      <div class="field"><label>image:license <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-license" target="_blank" rel="noopener">↗</a></label><input type="url" name="img-lic" value="${esc(data&&data.license||'')}" placeholder="https://creativecommons.org/licenses/by/4.0/"/></div>
      <div class="field"><label>image:geo_location <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps#image-geo_location" target="_blank" rel="noopener">↗</a></label><input type="text" name="img-geo" value="${esc(data&&data.geo_location||'')}" placeholder="Appleton, Wisconsin, USA"/></div>
    </div>`;
  return div;
}

function addImage() {
  const list = document.getElementById('imagesList');
  list.appendChild(renderImageItem({}, list.children.length));
}

function collectImage(el) {
  const loc = el.querySelector('[name="img-loc"]').value.trim();
  if (!loc) return null;
  return { loc, caption: el.querySelector('[name="img-cap"]').value.trim(), title: el.querySelector('[name="img-title"]').value.trim(), license: el.querySelector('[name="img-lic"]').value.trim(), geo_location: el.querySelector('[name="img-geo"]').value.trim() };
}

// Videos
function renderVideoItem(data, idx) {
  const div = document.createElement('div');
  div.className = 'sub-item';
  div.innerHTML = `
    <div class="sub-item-hdr">
      <span>Video ${(idx||0)+1}</span>
      <button class="btn-danger" onclick="this.closest('.sub-item').remove()">✕ Remove</button>
    </div>
    <div class="field"><label>video:thumbnail_loc * <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#thumbnail_loc" target="_blank" rel="noopener">↗</a></label><input type="url" name="vid-thumb" value="${esc(data&&data.thumbnail_loc||'')}" placeholder="https://example.com/thumbnail.jpg"/></div>
    <div class="grid-2">
      <div class="field"><label>video:title * <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#title" target="_blank" rel="noopener">↗</a></label><input type="text" name="vid-title" value="${esc(data&&data.title||'')}" placeholder="Video title"/></div>
      <div class="field"><label>video:description * <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#description" target="_blank" rel="noopener">↗</a></label><input type="text" name="vid-desc" value="${esc(data&&data.description||'')}" placeholder="Video description"/></div>
    </div>
    <div class="grid-2">
      <div class="field"><label>video:content_loc <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#content_loc" target="_blank" rel="noopener">↗</a></label><input type="url" name="vid-src" value="${esc(data&&data.content_loc||'')}" placeholder="https://example.com/video.mp4"/></div>
      <div class="field"><label>video:player_loc <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#player_loc" target="_blank" rel="noopener">↗</a></label><input type="url" name="vid-player" value="${esc(data&&data.player_loc||'')}" placeholder="https://example.com/player?id=123"/></div>
    </div>
    <div class="grid-3">
      <div class="field"><label>video:duration (s) <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#duration" target="_blank" rel="noopener">↗</a></label><input type="number" name="vid-dur" value="${data&&data.duration||''}" min="1" max="28800" placeholder="360"/></div>
      <div class="field"><label>video:publication_date <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#publication_date" target="_blank" rel="noopener">↗</a></label><input type="date" name="vid-pubdate" value="${data&&data.publication_date||''}"/></div>
      <div class="field"><label>video:rating (0–5) <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#rating" target="_blank" rel="noopener">↗</a></label><input type="number" name="vid-rating" value="${data&&data.rating||''}" min="0" max="5" step="0.1" placeholder="4.5"/></div>
    </div>
    <div class="grid-3">
      <div class="field"><label>video:family_friendly <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#family_friendly" target="_blank" rel="noopener">↗</a></label><select name="vid-family"><option value="">— none —</option><option value="yes" ${data&&data.family_friendly==='yes'?'selected':''}>yes</option><option value="no" ${data&&data.family_friendly==='no'?'selected':''}>no</option></select></div>
      <div class="field"><label>video:live <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#live" target="_blank" rel="noopener">↗</a></label><select name="vid-live"><option value="">— none —</option><option value="yes" ${data&&data.live==='yes'?'selected':''}>yes</option><option value="no" ${data&&data.live==='no'?'selected':''}>no</option></select></div>
      <div class="field"><label>video:view_count <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#view_count" target="_blank" rel="noopener">↗</a></label><input type="number" name="vid-views" value="${data&&data.view_count||''}" placeholder="12345"/></div>
    </div>
    <div class="field"><label>video:tag (comma-separated, max 32) <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps#tag" target="_blank" rel="noopener">↗</a></label><input type="text" name="vid-tags" value="${esc(data&&data.tag||'')}" placeholder="tutorial, javascript, webdev"/></div>`;
  return div;
}

function addVideo() {
  const list = document.getElementById('videosList');
  list.appendChild(renderVideoItem({}, list.children.length));
}

function collectVideo(el) {
  const thumb = el.querySelector('[name="vid-thumb"]').value.trim();
  if (!thumb) return null;
  return {
    thumbnail_loc: thumb,
    title: el.querySelector('[name="vid-title"]').value.trim(),
    description: el.querySelector('[name="vid-desc"]').value.trim(),
    content_loc: el.querySelector('[name="vid-src"]').value.trim(),
    player_loc: el.querySelector('[name="vid-player"]').value.trim(),
    duration: el.querySelector('[name="vid-dur"]').value.trim(),
    publication_date: el.querySelector('[name="vid-pubdate"]').value.trim(),
    rating: el.querySelector('[name="vid-rating"]').value.trim(),
    view_count: el.querySelector('[name="vid-views"]').value.trim(),
    family_friendly: el.querySelector('[name="vid-family"]').value,
    live: el.querySelector('[name="vid-live"]').value,
    tag: el.querySelector('[name="vid-tags"]').value.trim()
  };
}

// Alternates (hreflang)
function renderAltItem(data, idx) {
  const div = document.createElement('div');
  div.className = 'sub-item';
  div.innerHTML = `
    <div class="sub-item-hdr">
      <span>Alternate ${(idx||0)+1}</span>
      <button class="btn-danger" onclick="this.closest('.sub-item').remove()">✕ Remove</button>
    </div>
    <div class="grid-2">
      <div class="field"><label>hreflang (BCP 47) <a href="https://www.w3.org/International/articles/language-tags/" target="_blank" rel="noopener">↗</a></label><input type="text" name="alt-lang" value="${esc(data&&data.hreflang||'')}" placeholder="en-US" list="langList"/></div>
      <div class="field"><label>href (alternate URL) <a href="https://developers.google.com/search/docs/specialty/international/localized-versions" target="_blank" rel="noopener">↗</a></label><input type="url" name="alt-href" value="${esc(data&&data.href||'')}" placeholder="https://example.com/en/"/></div>
    </div>`;
  return div;
}

function addAlternate() {
  const list = document.getElementById('alternatesList');
  list.appendChild(renderAltItem({}, list.children.length));
}

function collectAlt(el) {
  const lang = el.querySelector('[name="alt-lang"]').value.trim();
  const href = el.querySelector('[name="alt-href"]').value.trim();
  if (!lang || !href) return null;
  return { hreflang: lang, href };
}

// ── Bulk Import ─────────────────────────────────────────────────────────────
function importBulk() {
  const raw = document.getElementById('bulkInput').value.trim();
  if (!raw) return;
  const defFreq = document.getElementById('bulkFreq').value;
  const defPri = document.getElementById('bulkPriority').value;
  let added = 0;

  // Try XML sitemap
  if (raw.includes('<urlset') || raw.includes('<sitemapindex')) {
    try {
      const parser = new DOMParser();
      const doc = parser.parseFromString(raw, 'text/xml');
      const locs = doc.querySelectorAll('loc');
      locs.forEach(locEl => {
        const loc = locEl.textContent.trim();
        if (!loc || loc.endsWith('.xml')) return; // skip sitemap refs in index
        const urlEl = locEl.parentElement;
        const lastmod = urlEl.querySelector('lastmod') ? urlEl.querySelector('lastmod').textContent.trim() : '';
        const changefreq = urlEl.querySelector('changefreq') ? urlEl.querySelector('changefreq').textContent.trim() : defFreq;
        const priority = urlEl.querySelector('priority') ? urlEl.querySelector('priority').textContent.trim() : defPri;
        urls.push({ id: uid(), loc, lastmod, changefreq, priority, images: [], videos: [], news: {}, alternates: [] });
        added++;
      });
    } catch(e) {}
  } else {
    // CSV or plain URLs
    const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
    lines.forEach(line => {
      if (line.startsWith('#') || line.startsWith('url')) return;
      const parts = line.split(',').map(p => p.trim().replace(/^"|"$/g,''));
      const loc = parts[0];
      if (!loc || !loc.startsWith('http')) return;
      const lastmod = parts[1] || '';
      const changefreq = parts[2] || defFreq;
      const priority = parts[3] || defPri;
      urls.push({ id: uid(), loc, lastmod, changefreq, priority, images: [], videos: [], news: {}, alternates: [] });
      added++;
    });
  }

  const status = document.getElementById('bulkStatus');
  if (added > 0) {
    status.innerHTML = `<div class="alert" style="background:rgba(0,200,150,0.07);border:1px solid rgba(0,200,150,0.2);color:var(--success)">✓ Imported ${added} URL(s). Switch to Builder tab to review.</div>`;
    renderTable();
  } else {
    status.innerHTML = `<div class="alert alert-warn">No valid URLs found. Make sure each line starts with http/https, or paste a valid sitemap XML.</div>`;
  }
}

// ── Scanner ─────────────────────────────────────────────────────────────────
function logScan(msg, type) {
  const log = document.getElementById('scanLog');
  const div = document.createElement('div');
  div.className = 'log-line' + (type ? ' ' + type : '');
  div.textContent = msg;
  log.appendChild(div);
  log.scrollTop = log.scrollHeight;
}

async function fetchProxy(url) {
  const proxy = 'https://api.allorigins.win/get?url=' + encodeURIComponent(url);
  const r = await fetch(proxy, { signal: AbortSignal.timeout ? AbortSignal.timeout(10000) : undefined });
  if (!r.ok) throw new Error('HTTP ' + r.status);
  const data = await r.json();
  return data.contents || '';
}

function parseSitemapLocs(xml, base) {
  const found = [];
  try {
    const doc = new DOMParser().parseFromString(xml, 'text/xml');
    doc.querySelectorAll('loc').forEach(el => {
      const u = el.textContent.trim();
      if (u && !u.endsWith('.xml')) found.push(u);
    });
  } catch(e) {}
  return found;
}

function extractLinks(html, origin) {
  const links = new Set();
  try {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    doc.querySelectorAll('a[href]').forEach(a => {
      try {
        const href = new URL(a.getAttribute('href'), origin).href;
        if (href.startsWith(origin) && !/#/.test(href) && !/\.(jpg|jpeg|png|gif|pdf|zip|mp4|svg|ico|css|js|woff|ttf)(\?|$)/i.test(href)) {
          links.add(href.split('?')[0].replace(/([^/])$/, '$1'));
        }
      } catch(e) {}
    });
  } catch(e) {}
  return [...links];
}

let scanFoundUrls = [];

async function startScan() {
  const rawUrl = document.getElementById('scanUrl').value.trim();
  if (!rawUrl) { document.getElementById('scanUrl').focus(); return; }

  let base;
  try { base = new URL(rawUrl.startsWith('http') ? rawUrl : 'https://' + rawUrl); }
  catch(e) { logScan('Invalid URL', 'err'); return; }

  scanAbort = false;
  scanFoundUrls = [];
  document.getElementById('scanLog').innerHTML = '';
  document.getElementById('scanResultsWrap').classList.add('hidden');
  document.getElementById('scanBtn').disabled = true;
  document.getElementById('scanStopBtn').classList.remove('hidden');

  const mode = document.getElementById('scanMode').value;
  const maxDepth = parseInt(document.getElementById('scanDepth').value);
  const maxUrls = parseInt(document.getElementById('scanMaxUrls').value);
  const delay = parseInt(document.getElementById('scanDelay').value) || 0;

  const found = new Set();

  logScan('Starting scan: ' + base.origin, 'hi');
  logScan('CORS proxy: api.allorigins.win', 'hi');

  if (mode === 'sitemap') {
    // Try robots.txt first
    try {
      logScan('Fetching robots.txt...');
      const robots = await fetchProxy(base.origin + '/robots.txt');
      const sitemapUrls = [...robots.matchAll(/^Sitemap:\s*(.+)$/gim)].map(m => m[1].trim());
      if (sitemapUrls.length) {
        logScan('Found ' + sitemapUrls.length + ' sitemap(s) in robots.txt', 'ok');
        for (const su of sitemapUrls) {
          if (scanAbort) break;
          logScan('Parsing: ' + su);
          const xml = await fetchProxy(su);
          parseSitemapLocs(xml, base.origin).forEach(u => found.add(u));
        }
      } else {
        logScan('No Sitemap directive in robots.txt');
      }
    } catch(e) { logScan('robots.txt failed: ' + e.message, 'err'); }

    // Try default sitemap.xml
    if (found.size === 0 && !scanAbort) {
      try {
        logScan('Trying /sitemap.xml...');
        const xml = await fetchProxy(base.origin + '/sitemap.xml');
        parseSitemapLocs(xml, base.origin).forEach(u => found.add(u));
        logScan('Found ' + found.size + ' URLs in sitemap.xml', 'ok');
      } catch(e) { logScan('/sitemap.xml failed: ' + e.message, 'err'); }
    }

    // Also try /sitemap_index.xml
    if (found.size === 0 && !scanAbort) {
      try {
        logScan('Trying /sitemap_index.xml...');
        const xml = await fetchProxy(base.origin + '/sitemap_index.xml');
        const doc = new DOMParser().parseFromString(xml, 'text/xml');
        const childSitemaps = [...doc.querySelectorAll('loc')].map(l => l.textContent.trim());
        for (const cs of childSitemaps) {
          if (scanAbort) break;
          logScan('Parsing child sitemap: ' + cs);
          const xml2 = await fetchProxy(cs);
          parseSitemapLocs(xml2, base.origin).forEach(u => found.add(u));
        }
      } catch(e) { logScan('/sitemap_index.xml failed: ' + e.message, 'err'); }
    }

  } else {
    // BFS crawl
    const visited = new Set();
    const queue = [{ url: base.href, depth: 0 }];

    while (queue.length > 0 && found.size < maxUrls && !scanAbort) {
      const { url, depth } = queue.shift();
      const normalized = url.replace(/\/$/, '') + '/';
      if (visited.has(normalized)) continue;
      visited.add(normalized);

      logScan('[d' + depth + '] ' + url);
      try {
        const html = await fetchProxy(url);
        found.add(url);
        if (depth < maxDepth) {
          const links = extractLinks(html, base.origin);
          for (const link of links) {
            const n2 = link.replace(/\/$/, '') + '/';
            if (!visited.has(n2) && found.size < maxUrls) {
              queue.push({ url: link, depth: depth + 1 });
            }
          }
        }
        if (delay > 0) await new Promise(r => setTimeout(r, delay));
      } catch(e) {
        logScan('Failed: ' + url + ' — ' + e.message, 'err');
      }
    }
  }

  scanFoundUrls = [...found].slice(0, maxUrls);
  logScan('Done. Found ' + scanFoundUrls.length + ' URL(s).', 'ok');

  document.getElementById('scanBtn').disabled = false;
  document.getElementById('scanStopBtn').classList.add('hidden');
  displayScanResults(scanFoundUrls);
}

function stopScan() {
  scanAbort = true;
  logScan('Stopped by user.', 'err');
  document.getElementById('scanBtn').disabled = false;
  document.getElementById('scanStopBtn').classList.add('hidden');
}

function displayScanResults(list) {
  const wrap = document.getElementById('scanResultsWrap');
  const el = document.getElementById('scanResults');
  if (!list.length) { wrap.classList.add('hidden'); return; }
  el.innerHTML = list.map((u, i) =>
    `<div class="scan-res-item">
      <input type="checkbox" checked id="sr${i}" data-url="${esc(u)}">
      <label class="scan-res-url" for="sr${i}" title="${esc(u)}">${esc(u)}</label>
    </div>`
  ).join('');
  wrap.classList.remove('hidden');
}

function selectAllScanResults(val) {
  document.getElementById('scanResults').querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = val);
}

function importScanResults() {
  const checked = [...document.getElementById('scanResults').querySelectorAll('input[type="checkbox"]:checked')];
  const today = new Date().toISOString().slice(0, 10);
  let added = 0;
  checked.forEach(cb => {
    const u = cb.dataset.url;
    if (!u) return;
    urls.push({ id: uid(), loc: u, lastmod: today, changefreq: 'monthly', priority: '0.8', images: [], videos: [], news: {}, alternates: [] });
    added++;
  });
  if (added) {
    switchTab('builder');
    renderTable();
    logScan('Added ' + added + ' URL(s) to builder.', 'ok');
  }
}

// ── Output format ──────────────────────────────────────────────────────────
function setFmt(fmt) {
  outputFmt = fmt;
  ['xml','index','txt','json'].forEach(f => document.getElementById('fmt-' + f).classList.toggle('active', f === fmt));
  regenerate();
}

function xmlEsc(s) {
  if (!s) return '';
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
}

function regenerate() {
  const out = document.getElementById('output');
  if (!urls.length) { out.textContent = ''; return; }

  const useImage = document.getElementById('ext-image').checked;
  const useVideo = document.getElementById('ext-video').checked;
  const useNews = document.getElementById('ext-news').checked;
  const useHreflang = document.getElementById('ext-hreflang').checked;

  if (outputFmt === 'txt') {
    out.textContent = urls.map(u => u.loc).join('\n');
    return;
  }

  if (outputFmt === 'json') {
    const data = urls.map(u => {
      const obj = { loc: u.loc };
      if (u.lastmod) obj.lastmod = u.lastmod;
      if (u.changefreq) obj.changefreq = u.changefreq;
      if (u.priority) obj.priority = parseFloat(u.priority);
      if (useImage && u.images && u.images.length) obj.images = u.images;
      if (useVideo && u.videos && u.videos.length) obj.videos = u.videos;
      if (useNews && u.news && u.news.title) obj.news = u.news;
      if (useHreflang && u.alternates && u.alternates.length) obj.alternates = u.alternates;
      return obj;
    });
    out.textContent = JSON.stringify(data, null, 2);
    return;
  }

  if (outputFmt === 'index') {
    let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
    xml += '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n\n';
    urls.forEach(u => {
      xml += '  <sitemap>\n';
      xml += `    <loc>${xmlEsc(u.loc)}</loc>\n`;
      if (u.lastmod) xml += `    <lastmod>${u.lastmod}</lastmod>\n`;
      xml += '  </sitemap>\n';
    });
    xml += '\n</sitemapindex>';
    out.textContent = xml;
    return;
  }

  // Standard XML
  let ns = 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
  if (useImage) ns += '\n  xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"';
  if (useVideo) ns += '\n  xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"';
  if (useNews) ns += '\n  xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"';
  if (useHreflang) ns += '\n  xmlns:xhtml="http://www.w3.org/1999/xhtml"';

  let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
  xml += `<urlset ${ns}>\n`;

  urls.forEach(u => {
    xml += '\n  <url>\n';
    xml += `    <loc>${xmlEsc(u.loc)}</loc>\n`;
    if (u.lastmod) xml += `    <lastmod>${u.lastmod}</lastmod>\n`;
    if (u.changefreq) xml += `    <changefreq>${u.changefreq}</changefreq>\n`;
    if (u.priority) xml += `    <priority>${u.priority}</priority>\n`;

    // hreflang
    if (useHreflang && u.alternates && u.alternates.length) {
      u.alternates.forEach(a => {
        xml += `    <xhtml:link rel="alternate" hreflang="${xmlEsc(a.hreflang)}" href="${xmlEsc(a.href)}"/>\n`;
      });
    }

    // images
    if (useImage && u.images && u.images.length) {
      u.images.forEach(img => {
        xml += '    <image:image>\n';
        xml += `      <image:loc>${xmlEsc(img.loc)}</image:loc>\n`;
        if (img.caption) xml += `      <image:caption>${xmlEsc(img.caption)}</image:caption>\n`;
        if (img.title) xml += `      <image:title>${xmlEsc(img.title)}</image:title>\n`;
        if (img.license) xml += `      <image:license>${xmlEsc(img.license)}</image:license>\n`;
        if (img.geo_location) xml += `      <image:geo_location>${xmlEsc(img.geo_location)}</image:geo_location>\n`;
        xml += '    </image:image>\n';
      });
    }

    // video
    if (useVideo && u.videos && u.videos.length) {
      u.videos.forEach(v => {
        xml += '    <video:video>\n';
        if (v.thumbnail_loc) xml += `      <video:thumbnail_loc>${xmlEsc(v.thumbnail_loc)}</video:thumbnail_loc>\n`;
        if (v.title) xml += `      <video:title>${xmlEsc(v.title)}</video:title>\n`;
        if (v.description) xml += `      <video:description>${xmlEsc(v.description)}</video:description>\n`;
        if (v.content_loc) xml += `      <video:content_loc>${xmlEsc(v.content_loc)}</video:content_loc>\n`;
        if (v.player_loc) xml += `      <video:player_loc>${xmlEsc(v.player_loc)}</video:player_loc>\n`;
        if (v.duration) xml += `      <video:duration>${v.duration}</video:duration>\n`;
        if (v.expiration_date) xml += `      <video:expiration_date>${v.expiration_date}</video:expiration_date>\n`;
        if (v.rating) xml += `      <video:rating>${v.rating}</video:rating>\n`;
        if (v.view_count) xml += `      <video:view_count>${v.view_count}</video:view_count>\n`;
        if (v.publication_date) xml += `      <video:publication_date>${v.publication_date}</video:publication_date>\n`;
        if (v.family_friendly) xml += `      <video:family_friendly>${v.family_friendly}</video:family_friendly>\n`;
        if (v.live) xml += `      <video:live>${v.live}</video:live>\n`;
        if (v.tag) {
          v.tag.split(',').forEach(t => { if (t.trim()) xml += `      <video:tag>${xmlEsc(t.trim())}</video:tag>\n`; });
        }
        xml += '    </video:video>\n';
      });
    }

    // news
    if (useNews && u.news && u.news.title) {
      const n = u.news;
      xml += '    <news:news>\n';
      xml += '      <news:publication>\n';
      xml += `        <news:name>${xmlEsc(n.publication_name)}</news:name>\n`;
      xml += `        <news:language>${xmlEsc(n.publication_language)}</news:language>\n`;
      xml += '      </news:publication>\n';
      if (n.publication_date) xml += `      <news:publication_date>${n.publication_date}</news:publication_date>\n`;
      xml += `      <news:title>${xmlEsc(n.title)}</news:title>\n`;
      if (n.keywords) xml += `      <news:keywords>${xmlEsc(n.keywords)}</news:keywords>\n`;
      xml += '    </news:news>\n';
    }

    xml += '  </url>';
  });

  xml += '\n\n</urlset>';
  out.textContent = xml;
}

// ── Copy / Download ─────────────────────────────────────────────────────────
function copyOutput() {
  const text = document.getElementById('output').textContent;
  if (!text) return;
  navigator.clipboard.writeText(text).catch(() => {});
}

function copyOutputBtn(btn) {
  const text = document.getElementById('output').textContent;
  if (!text) return;
  navigator.clipboard.writeText(text).catch(() => {});
  btn.textContent = 'copied!';
  btn.classList.add('copied');
  setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 2000);
}

function downloadOutput() {
  const text = document.getElementById('output').textContent;
  if (!text) return;
  const ext = { xml: 'xml', index: 'xml', txt: 'txt', json: 'json' }[outputFmt];
  const mime = { xml: 'application/xml', index: 'application/xml', txt: 'text/plain', json: 'application/json' }[outputFmt];
  const name = outputFmt === 'index' ? 'sitemap_index.' + ext : 'sitemap.' + ext;
  const blob = new Blob([text], { type: mime });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = name;
  a.click();
  URL.revokeObjectURL(a.href);
}

// ── Docs accordion ─────────────────────────────────────────────────────────
function toggleDocs(hdr) {
  const body = hdr.nextElementSibling;
  body.classList.toggle('open');
}

// ── Sample data ─────────────────────────────────────────────────────────────
function loadSample() {
  if (urls.length && !confirm('Replace existing URLs with sample data?')) return;
  const today = new Date().toISOString().slice(0,10);
  urls = [
    { id: uid(), loc: 'https://example.com/', lastmod: today, changefreq: 'daily', priority: '1.0', images: [], videos: [], news: {}, alternates: [{ hreflang: 'x-default', href: 'https://example.com/' }, { hreflang: 'en', href: 'https://example.com/en/' }] },
    { id: uid(), loc: 'https://example.com/about/', lastmod: today, changefreq: 'monthly', priority: '0.8', images: [{ loc: 'https://example.com/img/team.jpg', caption: 'Our team', title: 'Team Photo', license: '', geo_location: '' }], videos: [], news: {}, alternates: [] },
    { id: uid(), loc: 'https://example.com/blog/intro/', lastmod: today, changefreq: 'weekly', priority: '0.7', images: [], videos: [], news: { publication_name: 'Example Blog', publication_language: 'en', publication_date: today + 'T08:00:00Z', title: 'Getting Started with Sitemaps', keywords: 'SEO, sitemap, XML' }, alternates: [] },
    { id: uid(), loc: 'https://example.com/videos/demo/', lastmod: today, changefreq: 'monthly', priority: '0.7', images: [], videos: [{ thumbnail_loc: 'https://example.com/thumb.jpg', title: 'Product Demo', description: 'See our product in action.', content_loc: 'https://example.com/demo.mp4', player_loc: '', duration: '180', publication_date: today, rating: '', view_count: '', family_friendly: 'yes', live: 'no', tag: 'demo, product' }], news: {}, alternates: [] }
  ];
  renderTable();
  regenerate();
}

// ── Keyboard shortcuts ──────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
  if (e.key === 'Escape') closeModal();
});