In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

AFL Player Game Logs

Skip to content

AFL > Player Game Logs

AFL · Player Game Logs · One Row Per Match

Which single-game performances should we be talking about?

Every player, every match, every box-score column — sortable. Use this when season totals hide the story: who had the 40-disposal day, who fronted up when their team needed a win, who quietly carried the chains week after week.

Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>After every round</strong></span>
  <span><a href="../blog/2026-04-24-understanding-torp/">Methodology &darr;</a></span>
  <span><a href="definitions.html">Definitions &nearr;</a></span>
  <span>&approx; 5 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
defs = window.aflStatDefs
predToFull = window.aflTeamMaps?.predToFull || {}
fullToPred = window.aflTeamMaps?.fullToPred || {}
shortName = window.aflTeamMaps?.canonicalToShort || {}

base = window.DATA_BASE_URL
Show code
gameLogs = {
  try {
    const rs = await window.fetchParquet(base + "afl/game-logs.parquet")
    return window.aflTeamMaps?.renameGameLogsColumns?.(rs) || rs
  } catch (e) {
    console.error("[game-logs] game-logs load failed:", e)
    return null
  }
}

// game-stats (2MB) is needed by any non-gameLogs category (source "gameStats" or
// "mixed"); the default "Value" tab is gameLogs-sourced. Defer the fetch as a
// shared window promise (same key as match.qmd / player.qmd) and only await it in
// the cell that aggregates a non-gameLogs category, so the default tab renders
// without blocking on it.
{ window._gameStatsPromise = window.fetchParquet(base + "afl/game-stats.parquet").catch(e => { console.warn("[game-logs] game-stats load failed:", e); return null }) }
Show code
playerDetails = {
  try {
    return await window.fetchParquet(base + "afl/player-details.parquet")
  } catch (e) {
    console.warn("[game-logs] player-details load failed:", e)
    return null
  }
}

ratings = {
  try {
    return await window.fetchParquet(base + "afl/ratings.parquet")
  } catch (e) {
    console.warn("[game-logs] ratings load failed:", e)
    return null
  }
}

predictions = {
  try {
    return await window.fetchParquet(base + "afl/predictions.parquet")
  } catch (e) {
    console.warn("[game-logs] predictions load failed:", e)
    return null
  }
}

fixturesHistory = {
  try {
    return await window.fetchParquet(base + "afl/fixtures-history.parquet")
  } catch (e) {
    console.warn("[game-logs] fixtures-history load failed:", e)
    return null
  }
}
Show code
nameMap = {
  const m = new Map()
  if (playerDetails) { for (const d of playerDetails) m.set(d.player_id, d.player_name) }
  if (ratings) { for (const r of ratings) m.set(r.player_id, r.player_name) }
  return m
}

posMap = {
  const m = new Map()
  if (ratings) {
    const sorted = [...ratings].sort((a, b) => (a.season - b.season) || (a.round - b.round))
    for (const r of sorted) m.set(r.player_id, r.position_group)
  }
  return m
}

// Home/Away lookup from predictions
homeAwayMap = {
  const m = new Map()
  if (predictions) {
    for (const p of predictions) {
      const h = predToFull[p.home_team] || p.home_team
      const a = predToFull[p.away_team] || p.away_team
      m.set(`${p.season}-${p.round}-${h}`, "home")
      m.set(`${p.season}-${p.round}-${a}`, "away")
    }
  }
  return m
}
Show code
categories = Object.entries(defs)
  .filter(([k, v]) => v.source !== "ratings" && k !== "custom")
  .map(([k, v]) => ({ key: k, label: v.label }))

viewof category = {
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || categories[0].key
  const _default = categories.some(c => c.key === _saved) ? _saved : categories[0].key
  const wrap = document.createElement("div")
  wrap.className = "epv-toggle"
  wrap.value = _default
  for (const cat of categories) {
    const btn = document.createElement("button")
    btn.className = "epv-toggle-btn" + (cat.key === _default ? " active" : "")
    btn.textContent = cat.label
    btn.addEventListener("click", () => {
      wrap.querySelectorAll(".epv-toggle-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = cat.key
      window[_key] = cat.key
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    wrap.appendChild(btn)
  }
  return wrap
}
Show code
seasonOptions = {
  // Filter metadata comes from gameLogs (always loaded, same season/team/round
  // coverage as game-stats) so the filter bar doesn't block on the deferred
  // game-stats fetch.
  const src = gameLogs
  if (!src) return ["All Seasons"]
  const seasons = [...new Set(src.map(d => d.season))].sort((a, b) => b - a)
  return ["All Seasons", ...seasons.map(String)]
}

teamOptions = {
  const src = gameLogs
  if (!src) return ["All Teams"]
  const teams = [...new Set(src.map(d => predToFull[d.team] || d.team))].filter(Boolean).sort()
  return ["All Teams", ...teams]
}

oppOptions = {
  // gameLogs carries the same opponent set (as `opp`); avoid blocking on the
  // deferred game-stats fetch here.
  const opps = new Set()
  if (gameLogs) gameLogs.forEach(d => { if (d.opp) opps.add(d.opp) })
  return ["All Opponents", ...[...opps].sort()]
}

// Venue + day-of-week lookup from fixtures-history (keyed by season-round-team)
fixtureVenueMap = {
  const m = new Map()
  if (fixturesHistory) {
    for (const f of fixturesHistory) {
      m.set(`${f.season}-${f.round}-${f.home_team}`, { venue: f.venue, start_time: f.start_time })
      m.set(`${f.season}-${f.round}-${f.away_team}`, { venue: f.venue, start_time: f.start_time })
    }
  }
  return m
}

venueOptions = {
  if (!fixturesHistory) return ["All Venues"]
  const venues = [...new Set(fixturesHistory.map(f => f.venue).filter(Boolean))].sort()
  return ["All Venues", ...venues]
}

dayOptions = ["All Days", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

// Position pills mapping (mirrors player-stats.qmd)
posAbbrLookup = {
  const m = window.aflTeamMaps?.posColors || {}
  return Object.fromEntries(Object.entries(m).map(([k, v]) => [k, v.a]))
}
Show code
viewof filters = {
  const defaultSeason = seasonOptions[1] || "All Seasons"

  function getRoundRange(season) {
    const src = gameLogs
    if (!src) return { min: 0, max: 30 }
    const allGames = season === "All Seasons" ? src : src.filter(d => String(d.season) === season)
    let rMin = Infinity, rMax = -Infinity
    for (const d of allGames) { if (d.round != null) { if (d.round < rMin) rMin = d.round; if (d.round > rMax) rMax = d.round } }
    return { min: rMin === Infinity ? 0 : rMin, max: rMax === -Infinity ? 30 : rMax }
  }

  let roundBounds = getRoundRange(defaultSeason)

  function makeSelect(options, defaultVal, label) {
    const wrap = document.createElement("div")
    wrap.className = "filter-select-wrap"
    const lbl = document.createElement("span")
    lbl.className = "filter-label"
    lbl.textContent = label
    const sel = document.createElement("select")
    sel.className = "filter-select"
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt; o.textContent = opt
      if (opt === defaultVal) o.selected = true
      sel.appendChild(o)
    }
    wrap.appendChild(lbl); wrap.appendChild(sel)
    return { wrap, sel }
  }

  const container = document.createElement("div")
  container.className = "player-filter-bar"
  const row = document.createElement("div")
  row.className = "filter-row"

  const season = makeSelect(seasonOptions, defaultSeason, "Season")
  const team = makeSelect(teamOptions, "All Teams", "Team")
  const opp = makeSelect(oppOptions, "All Opponents", "Vs")
  const haSelect = makeSelect(["All", "Home", "Away"], "All", "H/A")
  const seasonType = makeSelect(["All", "Regular", "Finals"], "All", "Type")
  const venueSelect = makeSelect(venueOptions, "All Venues", "Venue")
  const daySelect = makeSelect(dayOptions, "All Days", "Day")

  row.appendChild(season.wrap)
  row.appendChild(team.wrap)
  row.appendChild(opp.wrap)
  row.appendChild(haSelect.wrap)
  row.appendChild(seasonType.wrap)
  row.appendChild(venueSelect.wrap)
  row.appendChild(daySelect.wrap)

  // Round range
  const roundWrap = document.createElement("div")
  roundWrap.className = "filter-round-wrap"
  const rLbl = document.createElement("span")
  rLbl.className = "filter-label"
  rLbl.textContent = "Rounds"
  const rMin = document.createElement("input")
  rMin.type = "number"; rMin.className = "round-input"
  rMin.min = roundBounds.min; rMin.max = roundBounds.max; rMin.value = roundBounds.min
  const rSep = document.createElement("span")
  rSep.className = "round-sep"; rSep.textContent = "–"
  const rMax = document.createElement("input")
  rMax.type = "number"; rMax.className = "round-input"
  rMax.min = roundBounds.min; rMax.max = roundBounds.max; rMax.value = roundBounds.max
  roundWrap.appendChild(rLbl); roundWrap.appendChild(rMin); roundWrap.appendChild(rSep); roundWrap.appendChild(rMax)
  row.appendChild(roundWrap)

  container.appendChild(row)

  container.value = {
    season: defaultSeason,
    team: "All Teams",
    opponent: "All Opponents",
    homeAway: "All",
    seasonType: "All",
    venue: "All Venues",
    day: "All Days",
    roundMin: +rMin.value,
    roundMax: +rMax.value
  }

  function emit() { container.dispatchEvent(new Event("input", { bubbles: true })) }

  season.sel.addEventListener("change", () => {
    roundBounds = getRoundRange(season.sel.value)
    rMin.min = roundBounds.min; rMin.max = roundBounds.max; rMin.value = roundBounds.min
    rMax.min = roundBounds.min; rMax.max = roundBounds.max; rMax.value = roundBounds.max
    container.value = { ...container.value, season: season.sel.value, roundMin: roundBounds.min, roundMax: roundBounds.max }
    emit()
  })
  team.sel.addEventListener("change", () => { container.value = { ...container.value, team: team.sel.value }; emit() })
  opp.sel.addEventListener("change", () => { container.value = { ...container.value, opponent: opp.sel.value }; emit() })
  haSelect.sel.addEventListener("change", () => { container.value = { ...container.value, homeAway: haSelect.sel.value }; emit() })
  seasonType.sel.addEventListener("change", () => { container.value = { ...container.value, seasonType: seasonType.sel.value }; emit() })
  venueSelect.sel.addEventListener("change", () => { container.value = { ...container.value, venue: venueSelect.sel.value }; emit() })
  daySelect.sel.addEventListener("change", () => { container.value = { ...container.value, day: daySelect.sel.value }; emit() })

  let roundTimer
  function clampRound(input) {
    if (input.value === "") return
    const v = +input.value
    if (v < roundBounds.min) input.value = roundBounds.min
    if (v > roundBounds.max) input.value = roundBounds.max
  }
  function updateRound() {
    clearTimeout(roundTimer)
    roundTimer = setTimeout(() => {
      clampRound(rMin); clampRound(rMax)
      container.value = { ...container.value, roundMin: rMin.value === "" ? null : +rMin.value, roundMax: rMax.value === "" ? null : +rMax.value }
      emit()
    }, 500)
  }
  rMin.addEventListener("input", updateRound); rMax.addEventListener("input", updateRound)
  rMin.addEventListener("blur", () => { clampRound(rMin); updateRound() })
  rMax.addEventListener("blur", () => { clampRound(rMax); updateRound() })

  return container
}

seasonFilter = filters.season
teamFilter = filters.team
oppFilter = filters.opponent
homeAwayFilter = filters.homeAway
seasonTypeFilter = filters.seasonType
venueFilter = filters.venue
dayFilter = filters.day
roundRange = ({ min: filters.roundMin, max: filters.roundMax })
Show code
// Position pills (separate viewof — same pattern as player-stats.qmd)
viewof posFilter = {
  const positions = ["All", "KD", "MDEF", "MID", "MFWD", "KF", "RK"]
  const _key = "_posFilter_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "All"
  const container = html`<div class="pos-pills">
    ${positions.map(p => `<button class="pos-pill ${p === _saved ? 'active' : ''}" data-pos="${p}">${p}</button>`).join('')}
  </div>`
  container.value = _saved
  container.querySelectorAll('.pos-pill').forEach(btn => {
    btn.addEventListener('click', () => {
      container.querySelectorAll('.pos-pill').forEach(b => b.classList.remove('active'))
      btn.classList.add('active')
      container.value = btn.dataset.pos
      window[_key] = container.value
      container.dispatchEvent(new Event('input', {bubbles: true}))
    })
  })
  return container
}
Show code
// ── Build game log table data ────────────────────────────────
tableData = {
  const catDef = defs[category]
  if (!catDef) return null

  const source = catDef.source
  // Any non-gameLogs category (gameStats or mixed) needs the deferred 2MB
  // game-stats parquet; await it here rather than at top level so gameLogs tabs
  // render without blocking on it.
  const rawData = source === "gameLogs" ? gameLogs : await window._gameStatsPromise
  if (!rawData) return null

  const effectiveSeason = seasonFilter === "All Seasons" ? null : Number(seasonFilter)

  let games = rawData

  // Season filter
  if (effectiveSeason) games = games.filter(d => d.season === effectiveSeason)

  // Round range filter
  if (roundRange.min != null) games = games.filter(d => d.round >= roundRange.min)
  if (roundRange.max != null) games = games.filter(d => d.round <= roundRange.max)

  // Season type filter
  if (seasonTypeFilter === "Regular") games = games.filter(d => !window.aflTeamMaps.isFinals(d))
  if (seasonTypeFilter === "Finals") games = games.filter(d => window.aflTeamMaps.isFinals(d))

  // Team filter
  if (teamFilter !== "All Teams") {
    const pred = fullToPred[teamFilter] || teamFilter
    games = games.filter(d => d.team === pred || (predToFull[d.team] || d.team) === teamFilter)
  }

  // Opponent filter (game-stats uses "opponent", game-logs uses "opp")
  if (oppFilter !== "All Opponents") {
    games = games.filter(d => (d.opponent || d.opp) === oppFilter)
  }

  // Home/Away filter
  if (homeAwayFilter !== "All") {
    const target = homeAwayFilter.toLowerCase()
    games = games.filter(d => {
      const teamFull = predToFull[d.team] || d.team
      return homeAwayMap.get(`${d.season}-${d.round}-${teamFull}`) === target
    })
  }

  // Venue filter (from fixtures-history)
  if (venueFilter !== "All Venues") {
    games = games.filter(d => {
      const teamFull = predToFull[d.team] || d.team
      const fix = fixtureVenueMap.get(`${d.season}-${d.round}-${teamFull}`)
      return fix && fix.venue === venueFilter
    })
  }

  // Day-of-week filter (from fixtures-history start_time)
  if (dayFilter !== "All Days") {
    const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
    games = games.filter(d => {
      const teamFull = predToFull[d.team] || d.team
      const fix = fixtureVenueMap.get(`${d.season}-${d.round}-${teamFull}`)
      if (!fix || !fix.start_time) return false
      return dayNames[new Date(fix.start_time).getDay()] === dayFilter
    })
  }

  // Position filter (use posMap from ratings)
  if (posFilter !== "All") {
    const fullKeys = Object.entries(posAbbrLookup).filter(([k, v]) => v === posFilter).map(([k]) => k)
    games = games.filter(d => fullKeys.includes(posMap.get(d.player_id)))
  }

  // Build rows — no aggregation, each game is its own row
  const statCols = catDef.columns
  const dp = catDef.format ? 3 : 1

  const posAbbr = {
    "Key Defender": "KD", "Medium Defender": "MDEF", "Midfielder": "MID",
    "Medium Forward": "MFWD", "Key Forward": "KF", "Ruck": "RK",
    "Wingman": "WNG",
    "KEY_DEFENDER": "KD", "MEDIUM_DEFENDER": "MDEF", "MIDFIELDER": "MID",
    "MEDIUM_FORWARD": "MFWD", "KEY_FORWARD": "KF", "RUCK": "RK",
    "MIDFIELDER_FORWARD": "MFWD", "WINGMAN": "WNG"
  }

  return games.map(g => {
    const name = g.player_name || nameMap.get(g.player_id) || g.player_id
    const teamFull = predToFull[g.team] || g.team
    const opp = g.opponent || g.opp || ""
    const rawPos = posMap.get(g.player_id) || g.position_group || ""
    const posShort = posAbbr[rawPos] || rawPos

    const row = {
      player_id: g.player_id,
      player_name: name,
      team: shortName[teamFull] || teamFull,
      position_group: posShort,
      round: g.round,
      date: g.date || "",
      opponent: shortName[opp] || opp,
      tog: g.time_on_ground_percentage != null ? Math.round(g.time_on_ground_percentage) : null
    }

    for (const col of statCols) {
      const v = Number(g[col])
      row[col] = isNaN(v) ? null : +(v.toFixed(dp))
    }

    return row
  })
}
Show code
viewof search = tableData == null
  ? html``
  : Inputs.search(tableData, { placeholder: "Search players..." })
Show code
// ── Render table ─────────────────────────────────────────────
{
  if (tableData == null || tableData.length === 0)
    return html`<p class="text-muted">No data available for this category and filter combination.</p>`

  const catDef = defs[category]
  const statCols = catDef.columns
  const posColors = window.aflTeamMaps?.posColors || {}
  const renderPlayerCell = window.aflTeamMaps?.renderPlayerCell
  const renderTeamCell = window.aflTeamMaps?.renderTeamCell

  // Identity columns + stat columns
  const columns = ["player_name", "position_group", "team", "round", "date", "opponent", "tog", ...statCols]

  const header = {
    player_name: "Player", position_group: "POS", team: "Team",
    round: "Rnd", date: "Date", opponent: "Vs", tog: "TOG",
    ...catDef.header
  }

  const format = {}
  for (const col of statCols) {
    const fmt = catDef.format?.[col]
    if (typeof fmt === "function") {
      format[col] = fmt
    } else {
      const dp = fmt ?? 1
      format[col] = v => v != null ? Number(v).toFixed(dp) : ""
    }
  }

  const heatmap = {}
  for (const col of statCols) {
    if (catDef.heatmap?.[col]) heatmap[col] = catDef.heatmap[col]
  }

  format.date = v => {
    if (!v) return ""
    const d = new Date(v + "T00:00:00")
    return d.toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })
  }

  // Abbreviation → color lookup from posColors
  const abbrColorMap = {}
  for (const [k, v] of Object.entries(posColors)) abbrColorMap[v.a] = v.c

  const teamAbbrevMap = window.aflTeamMaps?.teamAbbrevMap || {}

  const render = {}
  if (renderPlayerCell) {
    render.player_name = (val, row) => renderPlayerCell(val, {
      player_id: row.player_id,
      team: row.team,
      position_group: row.position_group
    })
  }
  render.team = (val) => {
    const full = predToFull[val] || val
    const abbr = teamAbbrevMap[full] || teamAbbrevMap[val] || val
    const logo = window.aflTeamMaps?.teamLogo(val)
    const logoHtml = logo ? `<img src="${statsEsc(logo)}" alt="" style="width:18px;height:18px;object-fit:contain;vertical-align:middle" onerror="this.style.display='none'"> ` : ""
    return `<a href="team#team=${encodeURIComponent(full)}" class="player-link">${logoHtml}${statsEsc(abbr)}</a>`
  }
  render.opponent = render.team
  render.position_group = (val) => {
    if (!val) return ""
    const col = abbrColorMap[val] || "#888"
    return `<span class="pos-badge" style="background:${col}">${statsEsc(val)}</span>`
  }

  const groups = [
    { label: "", span: 7 },
    ...(catDef.groups || [{ label: catDef.label, span: statCols.length }])
  ]

  const sortCol = catDef.sortCol || statCols[0]

  return statsTable(search, {
    columns, header, groups, format, heatmap, render,
    sort: sortCol, reverse: true,
    pageSize: 25,
    filters: { round: "range" }
  })
}
Show code
// ── Source attribution row ──────────────────────────────────
{
  const src = document.createElement("div"); src.className = "table-source"
  const left = document.createElement("span")
  left.appendChild(document.createTextNode("Source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/torpdata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "torpdata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Pete Owen · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = "Refreshed after every round · One row per (player, match)"
  src.appendChild(left); src.appendChild(right)
  return src
}
Show code
// ── Editorial side rail ─────────────────────────────────────
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock, btnTile } = window.editorial

  // Last Updated
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = "After every round"
  upd.appendChild(stamp)
  const updP = document.createElement("p")
  updP.style.cssText = "font-family: 'Source Serif 4', Georgia, serif; font-size: 0.85rem; color: var(--site-muted-color); margin: 0.7rem 0 0; line-height: 1.55;"
  updP.appendChild(document.createTextNode("Game logs land in R2 via the daily "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "torpdata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline, typically by 02:00 AEST the morning after a game."))
  upd.appendChild(updP); inner.appendChild(upd)

  // How to use
  const how = railBlock("How to read this"); how.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("Each row is "))
  const s1 = document.createElement("strong"); s1.textContent = "one player in one match"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(". Sort any stat column to surface the most extreme single-game performances."))
  how.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("Heatmap colours are "))
  const s2 = document.createElement("strong"); s2.textContent = "relative within-column"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" — green = high for that stat, red = low. Filter by round to narrow further."))
  how.appendChild(p2)
  inner.appendChild(how)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "player-stats.html", title: "Player Stats", meta: "Aggregated by season" },
    { href: "player-ratings.html", title: "Player Ratings", meta: "Predictive TORP / EPR / PSR" },
    { href: "team-game-logs.html", title: "Team Game Logs", meta: "Per-match team box scores" },
    { href: "compare.html", title: "Player Comparison", meta: "Side-by-side tool" }
  ]
  for (const l of links) {
    const li = document.createElement("li")
    const ax = document.createElement("a")
    ax.href = l.href; ax.textContent = l.title
    const meta = document.createElement("span"); meta.className = "rail-meta"; meta.textContent = l.meta
    ax.appendChild(meta); li.appendChild(ax); ul.appendChild(li)
  }
  read.appendChild(ul); inner.appendChild(read)

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer