Analytics: unique visitors this week + probed-URLs list; Scripture Index: most-studied strip + print; admin warning for unparseable passage references; v2.44.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-15 11:38:26 -04:00
1 parent 6a5d525d6e
commit e9d9dd0d50
13 files changed
+143 -9

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.43.1", "version": "2.44.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.43.1", "version": "2.44.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.43.1", "version": "2.44.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -146,7 +146,7 @@ app.use((req, res, next) => {
const ua = sanitizeUserAgent(req.get('user-agent')) const ua = sanitizeUserAgent(req.get('user-agent'))
const knownPath = isKnownSitePath(req.path) const knownPath = isKnownSitePath(req.path)
const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' } const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' }
recordHit(knownPath ? req.path : '(unknown)', botDetection.isBot, botDetection.reason) recordHit(req.path, botDetection.isBot, botDetection.reason)
queueHitStatsWrite() queueHitStatsWrite()
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) { if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
try { try {
+1
View File
@@ -172,6 +172,7 @@ export const EMPTY_HIT_STATS = {
byPath: {}, byPath: {},
byPathReal: {}, byPathReal: {},
byPathBot: {}, byPathBot: {},
byPathUnknown: {},
byDay: {}, byDay: {},
byDayReal: {}, byDayReal: {},
byDayBot: {}, byDayBot: {},
+2
View File
@@ -167,6 +167,7 @@ export function loadHitStatsFromDisk() {
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {}, byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {}, byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {}, byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
byPathUnknown: parsed?.byPathUnknown && typeof parsed.byPathUnknown === 'object' ? parsed.byPathUnknown : {},
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {}, byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {}, byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
@@ -1250,6 +1251,7 @@ function sanitizeLoadedHitStats(value) {
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {}, byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {}, byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {}, byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
byPathUnknown: value?.byPathUnknown && typeof value.byPathUnknown === 'object' ? value.byPathUnknown : {},
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {}, byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {}, byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
+7
View File
@@ -393,6 +393,13 @@ export function register(app) {
firstHitAt: state.hitStats.firstHitAt, firstHitAt: state.hitStats.firstHitAt,
lastHitAt: state.hitStats.lastHitAt, lastHitAt: state.hitStats.lastHitAt,
topPaths, topPathsReal, topPathsBot, topPaths, topPathsReal, topPathsBot,
topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })),
uniqueVisitors7d: new Set(
state.visitorStats.recentVisits
.filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000)
.map(v => v.visitorId),
).size,
last7Days, last7DaysReal, last7DaysBot, last7Days, last7DaysReal, last7DaysBot,
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal, last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
botReasons, botReasons,
+17
View File
@@ -484,6 +484,8 @@ const KNOWN_SITE_SEGMENTS = new Set([
'privacy', 'terms', 'salvation', 'qr', 'privacy', 'terms', 'salvation', 'qr',
]) ])
const MAX_UNKNOWN_PATHS = 300
export function isKnownSitePath(pathname) { export function isKnownSitePath(pathname) {
const first = String(pathname ?? '/').split('/')[1] ?? '' const first = String(pathname ?? '/').split('/')[1] ?? ''
return first === '' || KNOWN_SITE_SEGMENTS.has(first) return first === '' || KNOWN_SITE_SEGMENTS.has(first)
@@ -593,6 +595,21 @@ export function recordHit(pathname, isBot = false, botReason = null) {
state.hitStats.lastHitAt = nowIso state.hitStats.lastHitAt = nowIso
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
if (isBot && botReason === 'unknown-path') {
// Probe/scanner URLs: keep a bounded list for the admin view instead of one stats key per URL
const unknown = (state.hitStats.byPathUnknown ??= {})
const key = safePath.slice(0, 120)
if (key in unknown || Object.keys(unknown).length < MAX_UNKNOWN_PATHS) unknown[key] = (unknown[key] ?? 0) + 1
else unknown['(other)'] = (unknown['(other)'] ?? 0) + 1
state.hitStats.botHits += 1
state.hitStats.byPathBot['(unknown)'] = (state.hitStats.byPathBot['(unknown)'] ?? 0) + 1
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
state.hitStats.byPath['(unknown)'] = (state.hitStats.byPath['(unknown)'] ?? 0) + 1
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
return
}
if (isBot) { if (isBot) {
state.hitStats.botHits += 1 state.hitStats.botHits += 1
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1 state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
+32 -1
View File
@@ -67,7 +67,7 @@ export const BIBLE_BOOKS = [
{ id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] }, { id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] },
{ id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] }, { id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] },
{ id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] }, { id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] },
{ id: 'TIT', name: 'Titus' }, { id: 'TIT', name: 'Titus', aliases: ['tit'] },
{ id: 'PHM', name: 'Philemon', aliases: ['philem'] }, { id: 'PHM', name: 'Philemon', aliases: ['philem'] },
{ id: 'HEB', name: 'Hebrews', aliases: ['heb'] }, { id: 'HEB', name: 'Hebrews', aliases: ['heb'] },
{ id: 'JAM', name: 'James', aliases: ['jas'] }, { id: 'JAM', name: 'James', aliases: ['jas'] },
@@ -85,3 +85,34 @@ export function bookNumberFor(usfmId) {
const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId) const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId)
return index === -1 ? null : index + 1 return index === -1 ? null : index + 1
} }
let referenceBookLookup = null
/**
* Checks a passage reference the way the Scripture Index and passage fetch read it.
* `bookHint` is the study's Bible Book field (or slug) and is used when the reference
* has no book of its own ("1:1-2"). Returns null when it will work, otherwise a short
* message explaining what to change.
*/
export function checkPassageReference(reference, bookHint) {
if (!referenceBookLookup) {
referenceBookLookup = new Set()
for (const b of BIBLE_BOOKS) {
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
referenceBookLookup.add(key)
referenceBookLookup.add(key.replace(/\s+/g, ''))
}
}
}
const clean = String(reference ?? '').replace(/[–—]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim()
if (!clean) return null
const m = clean.match(/^(?:(.*?)\s+)?(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
if (!m) return 'Use the form 1:1-2 (chapter:verse, optional range). It will be listed under "Other references" in the Scripture Index until fixed.'
const book = (m[1] ?? String(bookHint ?? '')).toLowerCase().replace(/\.\s*/g, ' ').trim()
if (!book || !referenceBookLookup.has(book)) {
return m[1]
? `"${m[1]}" is not a recognized Bible book name.`
: 'Set this study\'s "Bible Book" field (e.g. titus) so this reference can be matched to a book.'
}
return null
}
+8 -1
View File
@@ -1,3 +1,4 @@
import { checkPassageReference } from '../shared/bible-books.js'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
@@ -616,6 +617,8 @@ export interface AdminStats {
topPaths: Array<{ path: string; hits: number }> topPaths: Array<{ path: string; hits: number }>
topPathsReal: Array<{ path: string; hits: number }> topPathsReal: Array<{ path: string; hits: number }>
topPathsBot: Array<{ path: string; hits: number }> topPathsBot: Array<{ path: string; hits: number }>
topUnknownPaths?: Array<{ path: string; hits: number }>
uniqueVisitors7d?: number
last7Days: Array<{ day: string; hits: number }> last7Days: Array<{ day: string; hits: number }>
last7DaysReal: Array<{ day: string; hits: number }> last7DaysReal: Array<{ day: string; hits: number }>
last7DaysBot: Array<{ day: string; hits: number }> last7DaysBot: Array<{ day: string; hits: number }>
@@ -4137,7 +4140,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<div className="admin-dashboard-card"> <div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div> <div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
<div className="admin-dashboard-card-label">Real Visits (7 Days)</div> <div className="admin-dashboard-card-label">Real Visits (7 Days)</div>
<div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time</div> <div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.uniqueVisitors7d?.toLocaleString() ?? '—'} unique this week · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} all time (consented)</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button> <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button>
</div> </div>
<div className="admin-dashboard-card"> <div className="admin-dashboard-card">
@@ -6301,6 +6304,10 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`sl-reference-${activeLesson.id}`}>Passage Reference</label> <label htmlFor={`sl-reference-${activeLesson.id}`}>Passage Reference</label>
<input id={`sl-reference-${activeLesson.id}`} type="text" value={activeLesson.reference} placeholder="1:1-2" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'reference', e.target.value)} /> <input id={`sl-reference-${activeLesson.id}`} type="text" value={activeLesson.reference} placeholder="1:1-2" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'reference', e.target.value)} />
{(() => {
const refProblem = checkPassageReference(activeLesson.reference, activeStudy.bibleBook?.trim() || activeStudy.slug)
return refProblem ? <p className="admin-field-warning" role="alert" style={{ color: '#e0a030', fontSize: '0.85rem', margin: '0.35rem 0 0' }}>⚠ {refProblem}</p> : null
})()}
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`sl-title-${activeLesson.id}`}>Section Title</label> <label htmlFor={`sl-title-${activeLesson.id}`}>Section Title</label>
+21
View File
@@ -13718,6 +13718,27 @@ a.study-track-card:hover {
.si-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .si-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.si-verse-btn { margin-left: auto; font: inherit; font-size: 0.8rem; background: none; border: 0; padding: 0; cursor: pointer; color: var(--brand-muted); text-decoration: underline; } .si-verse-btn { margin-left: auto; font: inherit; font-size: 0.8rem; background: none; border: 0; padding: 0; cursor: pointer; color: var(--brand-muted); text-decoration: underline; }
.si-verse-btn:hover { color: var(--brand-gold); } .si-verse-btn:hover { color: var(--brand-gold); }
.si-top { margin-bottom: 1rem; padding: 0.9rem 1rem; background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 12px; }
.si-top-title { margin: 0 0 0.6rem; font-family: var(--brand-font-heading); font-size: 1.15rem; font-weight: 600; }
.si-top-list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.si-top-item { display: inline-flex; align-items: baseline; gap: 0.5rem; font: inherit; font-size: 0.88rem; padding: 0.35rem 0.8rem; border-radius: 999px; border: 1px solid var(--brand-border); background: transparent; color: var(--brand-warm-white); cursor: pointer; }
.si-top-item:hover { border-color: var(--brand-gold-dark); }
.si-top-ref { font-weight: 700; color: var(--brand-gold-light); }
.si-top-count { font-size: 0.75rem; color: var(--brand-muted); }
.si-print-title { display: none; }
@media print {
.site > *:not(.si-main) { display: none !important; }
.si-main { padding: 0; }
.si-main, .si-group, .si-top { background: #fff !important; color: #000 !important; }
.si-print-title { display: block; font-size: 1.6rem; margin: 0 0 1rem; color: #000; }
.si-toolbar, .si-books, .si-meta, .si-top, .si-verse-btn, .si-verse, .si-chev { display: none !important; }
.si-group { border: 0; border-bottom: 1px solid #999; break-inside: avoid-page; border-radius: 0; }
.si-group-head { padding: 0.5rem 0; color: #000; }
.si-ref { color: #000; }
.si-chip { border: 0; padding: 0; color: #000; }
.si-chip-kind { color: #444; }
.si-chip-label { white-space: normal; overflow: visible; }
}
.si-verse { margin: 0.5rem 0 0; padding-left: 0.9rem; border-left: 2px solid var(--brand-gold-dark); font-size: 0.95rem; line-height: 1.65; font-style: italic; color: var(--brand-muted); } .si-verse { margin: 0.5rem 0 0; padding-left: 0.9rem; border-left: 2px solid var(--brand-gold-dark); font-size: 0.95rem; line-height: 1.65; font-style: italic; color: var(--brand-muted); }
@media (max-width: 600px) { @media (max-width: 600px) {
+23 -2
View File
@@ -2735,7 +2735,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
// Merge entries that share the same reference (a study lesson and an episode can both // Merge entries that share the same reference (a study lesson and an episode can both
// cover the same passage) into one row, then group rows by book in canonical Bible order. // cover the same passage) into one row, then group rows by book in canonical Bible order.
const { bookGroups, otherRows } = useMemo(() => { const { bookGroups, otherRows, topRows } = useMemo(() => {
const rowMap = new Map<string, ScriptureRow>() const rowMap = new Map<string, ScriptureRow>()
const order: string[] = [] const order: string[] = []
for (const entry of entries) { for (const entry of entries) {
@@ -2789,7 +2789,12 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart), rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart),
})) }))
other.sort((a, b) => a.displayRef.localeCompare(b.displayRef)) other.sort((a, b) => a.displayRef.localeCompare(b.displayRef))
return { bookGroups: groups, otherRows: other } // Passages covered by the most lessons/episodes (needs at least two sources to be worth highlighting)
const top = [...rowMap.values()]
.filter(r => r.bookId && r.sources.length >= 2)
.sort((a, b) => b.sources.length - a.sources.length || order.indexOf(a.key) - order.indexOf(b.key))
.slice(0, 6)
return { bookGroups: groups, otherRows: other, topRows: top }
}, [entries]) }, [entries])
// Apply the search box and filters // Apply the search box and filters
@@ -2886,6 +2891,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
<PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse." /> <PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse." />
<main className="si-main"> <main className="si-main">
<div className="section-inner"> <div className="section-inner">
<h1 className="si-print-title">Scripture Index — {siteTitle}</h1>
{loading ? ( {loading ? (
<p className="si-note">Loading…</p> <p className="si-note">Loading…</p>
) : !hasContent ? ( ) : !hasContent ? (
@@ -2915,6 +2921,20 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
</div> </div>
</div> </div>
{!filtering && topRows.length > 0 && (
<div className="si-top" aria-label="Most-studied passages">
<h2 className="si-top-title">Most-studied passages</h2>
<div className="si-top-list">
{topRows.map(r => (
<button key={r.key} type="button" className="si-top-item" onClick={() => setQuery(r.displayRef)}>
<span className="si-top-ref">{r.displayRef}</span>
<span className="si-top-count">{r.sources.length} sources</span>
</button>
))}
</div>
</div>
)}
{bookGroups.length > 1 && ( {bookGroups.length > 1 && (
<div className="si-books" role="group" aria-label="Books covered"> <div className="si-books" role="group" aria-label="Books covered">
{bookGroups.map(g => ( {bookGroups.map(g => (
@@ -2938,6 +2958,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
)} )}
<button type="button" className="si-link" onClick={() => setAll(true)}>Expand all</button> <button type="button" className="si-link" onClick={() => setAll(true)}>Expand all</button>
<button type="button" className="si-link" onClick={() => setAll(false)}>Collapse all</button> <button type="button" className="si-link" onClick={() => setAll(false)}>Collapse all</button>
<button type="button" className="si-link" onClick={() => { setAll(true); window.setTimeout(() => window.print(), 150) }}>Print index</button>
</span> </span>
</div> </div>
+18 -1
View File
@@ -308,7 +308,7 @@ export function AnalyticsPanel({
<article> <article>
<h3>Unique Visitors</h3> <h3>Unique Visitors</h3>
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p> <p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
<small>Tracked with consent</small> <small>{stats.uniqueVisitors7d != null ? `${stats.uniqueVisitors7d.toLocaleString()} this week · ` : ''}Tracked with consent</small>
</article> </article>
<article> <article>
<h3>Returning Rate</h3> <h3>Returning Rate</h3>
@@ -399,6 +399,23 @@ export function AnalyticsPanel({
)} )}
</div> </div>
</div> </div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Probed URLs (not real pages)</h3>
{(stats.topUnknownPaths ?? []).length === 0 ? (
<p className="admin-stats-note">No probe traffic recorded yet. Requests for URLs that are not site pages (like /.env or /wp-login) are listed here and counted as bots.</p>
) : (
<ul className="admin-bot-reason-list">
{(stats.topUnknownPaths ?? []).map(row => (
<li key={row.path} className="admin-bot-reason-item">
<span style={{ wordBreak: 'break-all' }}>{row.path}</span>
<strong>{row.hits.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
</div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}> <div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide"> <div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3> <h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
+10
View File
@@ -13,3 +13,13 @@ test('scanner and probe paths are not known paths', () => {
assert.equal(isKnownSitePath(p), false, p) assert.equal(isKnownSitePath(p), false, p)
} }
}) })
import { checkPassageReference } from '../shared/bible-books.js'
test('passage reference check accepts what the index can read and flags what it cannot', () => {
assert.equal(checkPassageReference('Titus 1:1-2', ''), null)
assert.equal(checkPassageReference('1:10–13a', 'titus'), null)
assert.match(checkPassageReference('1:1-2', ''), /Bible Book/)
assert.match(checkPassageReference('Foo 1:1', 'titus'), /not a recognized/)
assert.match(checkPassageReference('chapter one', 'titus'), /Use the form/)
})