1- // Vanilla JS — fetch the static index, render rule cards, wire search +
2- // the copy-install button. No framework, no bundler. The page must
3- // render correctly on plain GitHub Pages with no build step.
1+ // Vanilla JS — fetch the static index, render rule cards, wire search,
2+ // theme toggle, sort, and pagination. No framework, no bundler.
43//
54// Rendering uses createElement + textContent throughout so user-supplied
65// rule data (names, descriptions, tags) never reaches an HTML parser.
76// Treat every field on a rule as untrusted — the registry is a public
87// PR target.
98
10- // The CLI auto-registers `openagentlock-rules` as the upstream id, so the
11- // install command can stay short — no need to namespace `<registry>:<id>`
12- // for the default catalog.
139const INSTALL_CMD = ( id ) => `agentlock rules install ${ id } ` ;
10+ const SEVERITY_ORDER = { critical : 0 , high : 1 , medium : 2 , low : 3 , info : 4 } ;
11+ const THEME_KEY = "oal-rules-theme" ;
1412
1513const $q = document . getElementById ( "q" ) ;
1614const $results = document . getElementById ( "results" ) ;
15+ const $pagination = document . getElementById ( "pagination" ) ;
1716const $generated = document . getElementById ( "generated-at" ) ;
1817const $count = document . getElementById ( "count" ) ;
1918const $metaCount = document . getElementById ( "meta-count" ) ;
19+ const $sort = document . getElementById ( "sort" ) ;
20+ const $pageSize = document . getElementById ( "page-size" ) ;
21+ const $themeToggle = document . getElementById ( "theme-toggle" ) ;
2022const $filters = Array . from ( document . querySelectorAll ( '.filters input[type="checkbox"]' ) ) ;
2123
2224let RULES = [ ] ;
25+ let page = 1 ;
26+
27+ // ---------- theme ----------
28+
29+ function readTheme ( ) {
30+ try {
31+ return localStorage . getItem ( THEME_KEY ) ?? "system" ;
32+ } catch {
33+ return "system" ;
34+ }
35+ }
36+ function applyTheme ( theme ) {
37+ if ( theme === "light" || theme === "dark" ) {
38+ document . documentElement . setAttribute ( "data-theme" , theme ) ;
39+ } else {
40+ document . documentElement . removeAttribute ( "data-theme" ) ;
41+ }
42+ if ( $themeToggle ) {
43+ for ( const btn of $themeToggle . querySelectorAll ( "button[data-theme]" ) ) {
44+ btn . setAttribute ( "aria-pressed" , btn . dataset . theme === theme ? "true" : "false" ) ;
45+ }
46+ }
47+ }
48+ function setTheme ( theme ) {
49+ try {
50+ if ( theme === "system" ) localStorage . removeItem ( THEME_KEY ) ;
51+ else localStorage . setItem ( THEME_KEY , theme ) ;
52+ } catch { /* ignore — private mode etc. */ }
53+ applyTheme ( theme ) ;
54+ }
55+ applyTheme ( readTheme ( ) ) ;
56+ if ( $themeToggle ) {
57+ $themeToggle . addEventListener ( "click" , ( e ) => {
58+ const target = e . target ;
59+ if ( target instanceof HTMLButtonElement && target . dataset . theme ) {
60+ setTheme ( target . dataset . theme ) ;
61+ }
62+ } ) ;
63+ }
64+
65+ // ---------- helpers ----------
2366
2467function severityClass ( sev ) {
25- return [ "critical" , "high" , "medium" , "low" , "info" ] . includes ( sev )
26- ? sev
27- : "info" ;
68+ return [ "critical" , "high" , "medium" , "low" , "info" ] . includes ( sev ) ? sev : "info" ;
2869}
2970
3071function activeSeverities ( ) {
31- return new Set (
32- $filters . filter ( ( c ) => c . checked ) . map ( ( c ) => c . dataset . severity ) ,
33- ) ;
72+ return new Set ( $filters . filter ( ( c ) => c . checked ) . map ( ( c ) => c . dataset . severity ) ) ;
3473}
3574
3675function matches ( rule , query , sevSet ) {
@@ -46,6 +85,18 @@ function matches(rule, query, sevSet) {
4685 ) ;
4786}
4887
88+ function sortBy ( sortKey ) {
89+ return ( a , b ) => {
90+ if ( sortKey === "severity" ) {
91+ const da = SEVERITY_ORDER [ a . severity ] ?? 99 ;
92+ const db = SEVERITY_ORDER [ b . severity ] ?? 99 ;
93+ if ( da !== db ) return da - db ;
94+ }
95+ if ( sortKey === "name" ) return a . name . localeCompare ( b . name ) ;
96+ return a . id . localeCompare ( b . id ) ;
97+ } ;
98+ }
99+
49100function el ( tag , attrs , children ) {
50101 const node = document . createElement ( tag ) ;
51102 if ( attrs ) {
@@ -78,10 +129,7 @@ function ruleCard(r) {
78129 meta . appendChild ( el ( "span" , null , [ `action: ` , el ( "code" , null , r . action ) ] ) ) ;
79130 if ( r . compatible_agentlock ) {
80131 meta . appendChild (
81- el ( "span" , null , [
82- "agentlock " ,
83- el ( "code" , null , r . compatible_agentlock ) ,
84- ] ) ,
132+ el ( "span" , null , [ "agentlock " , el ( "code" , null , r . compatible_agentlock ) ] ) ,
85133 ) ;
86134 }
87135 for ( const t of r . tags || [ ] ) {
@@ -120,29 +168,93 @@ function ruleCard(r) {
120168 return el ( "article" , { class : "rule" } , [ left , right ] ) ;
121169}
122170
171+ // ---------- pagination ----------
172+
173+ function pageSize ( ) {
174+ const v = $pageSize ?. value ?? "10" ;
175+ return v === "all" ? Infinity : Math . max ( 1 , parseInt ( v , 10 ) ) ;
176+ }
177+
178+ function renderPagination ( totalPages ) {
179+ if ( ! $pagination ) return ;
180+ $pagination . replaceChildren ( ) ;
181+ if ( totalPages <= 1 ) return ;
182+
183+ const prev = el ( "button" , { type : "button" } , "‹ Prev" ) ;
184+ prev . disabled = page <= 1 ;
185+ prev . addEventListener ( "click" , ( ) => goTo ( page - 1 ) ) ;
186+ $pagination . appendChild ( prev ) ;
187+
188+ // Compact numeric paginator: 1 ... (page-1) page (page+1) ... last.
189+ const slots = new Set ( [ 1 , totalPages , page , page - 1 , page + 1 ] ) ;
190+ const ordered = [ ...slots ] . filter ( ( n ) => n >= 1 && n <= totalPages ) . sort ( ( a , b ) => a - b ) ;
191+
192+ let last = 0 ;
193+ for ( const n of ordered ) {
194+ if ( last && n - last > 1 ) {
195+ $pagination . appendChild ( el ( "span" , { class : "ellipsis" } , "…" ) ) ;
196+ }
197+ const btn = el ( "button" , { type : "button" } , String ( n ) ) ;
198+ if ( n === page ) btn . setAttribute ( "aria-current" , "page" ) ;
199+ btn . addEventListener ( "click" , ( ) => goTo ( n ) ) ;
200+ $pagination . appendChild ( btn ) ;
201+ last = n ;
202+ }
203+
204+ const next = el ( "button" , { type : "button" } , "Next ›" ) ;
205+ next . disabled = page >= totalPages ;
206+ next . addEventListener ( "click" , ( ) => goTo ( page + 1 ) ) ;
207+ $pagination . appendChild ( next ) ;
208+ }
209+
210+ function goTo ( n ) {
211+ page = Math . max ( 1 , n ) ;
212+ render ( ) ;
213+ // Scroll the result list into view so paging on mobile doesn't strand
214+ // the user at the bottom of the previous page.
215+ document . getElementById ( "results" ) ?. scrollIntoView ( { behavior : "smooth" , block : "start" } ) ;
216+ }
217+
218+ // ---------- main render ----------
219+
123220function render ( ) {
124221 const q = $q . value . trim ( ) ;
125222 const sevSet = activeSeverities ( ) ;
126- const visible = RULES . filter ( ( r ) => matches ( r , q , sevSet ) ) ;
223+ const sortKey = $sort ?. value ?? "severity" ;
224+
225+ const filtered = RULES . filter ( ( r ) => matches ( r , q , sevSet ) ) . sort ( sortBy ( sortKey ) ) ;
226+ const total = filtered . length ;
227+ const sz = pageSize ( ) ;
228+ const totalPages = sz === Infinity ? 1 : Math . max ( 1 , Math . ceil ( total / sz ) ) ;
229+ if ( page > totalPages ) page = totalPages ;
230+
231+ const start = ( page - 1 ) * ( sz === Infinity ? 0 : sz ) ;
232+ const pageRules = sz === Infinity ? filtered : filtered . slice ( start , start + sz ) ;
127233
128234 $results . replaceChildren ( ) ;
129235 if ( $count ) {
130- const n = visible . length ;
131- $count . textContent = n === RULES . length ? `${ n } rule${ n === 1 ? "" : "s" } ` : `${ n } of ${ RULES . length } ` ;
236+ if ( total === 0 ) {
237+ $count . textContent = "0 rules" ;
238+ } else if ( sz === Infinity || total <= sz ) {
239+ $count . textContent = `${ total } rule${ total === 1 ? "" : "s" } ` ;
240+ } else {
241+ $count . textContent = `${ start + 1 } –${ Math . min ( start + sz , total ) } of ${ total } ` ;
242+ }
132243 }
133244
134- if ( visible . length === 0 ) {
245+ if ( pageRules . length === 0 ) {
135246 $results . appendChild (
136247 el ( "div" , { class : "empty" } , "No rules match. Try a different query." ) ,
137248 ) ;
138- return ;
249+ } else {
250+ for ( const r of pageRules ) $results . appendChild ( ruleCard ( r ) ) ;
139251 }
140252
141- for ( const r of visible ) {
142- $results . appendChild ( ruleCard ( r ) ) ;
143- }
253+ renderPagination ( totalPages ) ;
144254}
145255
256+ // ---------- bootstrap ----------
257+
146258async function main ( ) {
147259 try {
148260 const res = await fetch ( "data/index.json" , { cache : "no-cache" } ) ;
@@ -167,8 +279,14 @@ async function main() {
167279 }
168280
169281 render ( ) ;
170- $q . addEventListener ( "input" , render ) ;
171- for ( const c of $filters ) c . addEventListener ( "change" , render ) ;
282+ const onChange = ( ) => {
283+ page = 1 ;
284+ render ( ) ;
285+ } ;
286+ $q . addEventListener ( "input" , onChange ) ;
287+ for ( const c of $filters ) c . addEventListener ( "change" , onChange ) ;
288+ $sort ?. addEventListener ( "change" , onChange ) ;
289+ $pageSize ?. addEventListener ( "change" , onChange ) ;
172290}
173291
174292main ( ) ;
0 commit comments