11const NOTES_DIRECTORY = "docs_build/dev/admin-notes" ;
2- const DEFAULT_NOTE = "note" ;
2+ const DEFAULT_NOTE = "index" ;
3+ const LINK_CLASS = "btn btn--compact primary" ;
34const NOTE_NAME_PATTERN = / ^ [ A - Z a - z 0 - 9 _ - ] + $ / ;
5+ const STATUS_ICON_PATTERN = / ^ \[ ( [ x X . ! ? ] ) \] \s * ( .* ) $ / ;
46
57class AdminNotesViewer {
68 constructor ( documentRef = document , historyRef = window . history ) {
@@ -15,11 +17,15 @@ class AdminNotesViewer {
1517
1618 bindEvents ( ) {
1719 this . content ?. addEventListener ( "click" , ( event ) => {
18- const link = event . target . closest ( "[data-admin-note-link]" ) ;
19- if ( ! link ) {
20+ const link = event . target . closest ( "[data-admin-note-link], [data-admin-note-file] " ) ;
21+ if ( ! link || ! this . content . contains ( link ) ) {
2022 return ;
2123 }
2224 event . preventDefault ( ) ;
25+ if ( link . dataset . adminNoteFile ) {
26+ this . openFile ( link . dataset . adminNoteFile , true ) ;
27+ return ;
28+ }
2329 this . openNote ( link . dataset . adminNoteLink || DEFAULT_NOTE , true ) ;
2430 } ) ;
2531
@@ -30,30 +36,43 @@ class AdminNotesViewer {
3036 }
3137
3238 start ( ) {
33- this . openNote ( this . noteNameFromLocation ( window . location ) , false ) ;
39+ this . openFromLocation ( window . location , false ) ;
3440 }
3541
36- noteNameFromLocation ( locationRef ) {
42+ openFromLocation ( locationRef , pushHistory ) {
3743 const params = new URLSearchParams ( locationRef . search ) ;
38- return this . noteNameFromValue ( params . get ( "note" ) || DEFAULT_NOTE ) ;
44+ const filePath = params . get ( "file" ) ;
45+ if ( filePath ) {
46+ this . openFile ( filePath , pushHistory ) ;
47+ return ;
48+ }
49+ this . openNote ( this . noteNameFromValue ( params . get ( "note" ) || DEFAULT_NOTE ) , pushHistory ) ;
3950 }
4051
4152 noteNameFromValue ( value ) {
42- return String ( value || DEFAULT_NOTE ) . replace ( / \. t x t $ / i, "" ) ;
53+ const normalized = String ( value || DEFAULT_NOTE )
54+ . trim ( )
55+ . replace ( / \\ / g, "/" )
56+ . replace ( / \/ i n d e x (?: \. t x t ) ? $ / i, "" )
57+ . replace ( / \. t x t $ / i, "" ) ;
58+ return normalized || DEFAULT_NOTE ;
4359 }
4460
4561 validNoteName ( noteName ) {
4662 return NOTE_NAME_PATTERN . test ( noteName ) ;
4763 }
4864
4965 notePath ( noteName ) {
50- return `${ NOTES_DIRECTORY } /${ noteName } .txt` ;
66+ return noteName === DEFAULT_NOTE
67+ ? `${ NOTES_DIRECTORY } /index.txt`
68+ : `${ NOTES_DIRECTORY } /${ noteName } /index.txt` ;
5169 }
5270
5371 async openNote ( noteName , pushHistory ) {
5472 const normalizedNoteName = this . noteNameFromValue ( noteName ) ;
5573 this . clearContent ( ) ;
56- this . setTitle ( `${ normalizedNoteName || DEFAULT_NOTE } .txt` ) ;
74+ const title = normalizedNoteName === DEFAULT_NOTE ? "index.txt" : `${ normalizedNoteName } /index.txt` ;
75+ this . setTitle ( title ) ;
5776 this . setReturnVisible ( normalizedNoteName !== DEFAULT_NOTE ) ;
5877
5978 if ( ! this . validNoteName ( normalizedNoteName ) ) {
@@ -64,27 +83,79 @@ class AdminNotesViewer {
6483 }
6584
6685 const notePath = this . notePath ( normalizedNoteName ) ;
86+ await this . loadTextFile ( {
87+ filePath : notePath ,
88+ title,
89+ missingMessage : `Missing note file ${ notePath } . Add this file under ${ NOTES_DIRECTORY } / or return to index.txt.` ,
90+ errorMessage : `Unable to load ${ notePath } . Check the note file and reload the page.` ,
91+ pushHistory,
92+ pushUrl : this . hrefForNote ( normalizedNoteName )
93+ } ) ;
94+ }
95+
96+ async openFile ( filePath , pushHistory ) {
97+ const normalizedFilePath = this . rootRelativeTextPathFromValue ( filePath ) ;
98+ this . clearContent ( ) ;
99+ this . setTitle ( normalizedFilePath || "linked file" ) ;
100+ this . setReturnVisible ( true ) ;
101+
102+ if ( ! normalizedFilePath ) {
103+ this . renderError (
104+ `Rejected linked file path "${ filePath } ". Use a repository-root text file path without traversal.`
105+ ) ;
106+ return ;
107+ }
108+
109+ await this . loadTextFile ( {
110+ filePath : normalizedFilePath ,
111+ title : normalizedFilePath ,
112+ missingMessage : `Missing linked file ${ normalizedFilePath } . Add this text file under the repository root or return to index.txt.` ,
113+ errorMessage : `Unable to load ${ normalizedFilePath } . Check the linked text file and reload the page.` ,
114+ pushHistory,
115+ pushUrl : this . hrefForFile ( normalizedFilePath )
116+ } ) ;
117+ }
118+
119+ async loadTextFile ( { filePath, title, missingMessage, errorMessage, pushHistory, pushUrl } ) {
67120 try {
68- const response = await fetch ( notePath , { cache : "no-store" } ) ;
121+ const response = await fetch ( filePath , { cache : "no-store" } ) ;
69122 if ( ! response . ok ) {
70- this . renderError ( `Missing note file ${ notePath } . Add this file under ${ NOTES_DIRECTORY } / or return to note.txt.` ) ;
123+ this . renderError ( missingMessage ) ;
71124 return ;
72125 }
73126 const text = await response . text ( ) ;
74- this . renderNote ( text , normalizedNoteName ) ;
75- this . setStatus ( `Loaded ${ notePath } .` ) ;
127+ this . renderNote ( text , title ) ;
128+ this . setStatus ( `Loaded ${ filePath } .` ) ;
76129 if ( pushHistory ) {
77- this . historyRef . pushState ( { } , "" , this . hrefForNote ( normalizedNoteName ) ) ;
130+ this . historyRef . pushState ( { } , "" , pushUrl ) ;
78131 }
79132 } catch {
80- this . renderError ( `Unable to load ${ notePath } . Check the note file and reload the page.` ) ;
133+ this . renderError ( errorMessage ) ;
81134 }
82135 }
83136
84137 hrefForNote ( noteName ) {
85138 return noteName === DEFAULT_NOTE ? "admin/notes.html" : `admin/notes.html?note=${ encodeURIComponent ( noteName ) } ` ;
86139 }
87140
141+ hrefForFile ( filePath ) {
142+ return `admin/notes.html?file=${ encodeURIComponent ( filePath ) } ` ;
143+ }
144+
145+ rootRelativeTextPathFromValue ( value ) {
146+ const rawValue = String ( value || "" ) . trim ( ) ;
147+ if ( / ^ [ A - Z a - z ] : / . test ( rawValue ) || / ^ [ A - Z a - z ] [ A - Z a - z 0 - 9 + . - ] * : / . test ( rawValue ) ) {
148+ return null ;
149+ }
150+ const normalizedPath = rawValue . replace ( / \\ / g, "/" ) . replace ( / ^ \/ + / , "" ) ;
151+ const segments = normalizedPath . split ( "/" ) ;
152+ const invalidSegments = segments . some ( ( segment ) => ! segment || segment === "." || segment === ".." ) ;
153+ if ( ! normalizedPath || invalidSegments || ! normalizedPath . toLowerCase ( ) . endsWith ( ".txt" ) ) {
154+ return null ;
155+ }
156+ return normalizedPath ;
157+ }
158+
88159 clearContent ( ) {
89160 this . content ?. replaceChildren ( ) ;
90161 }
@@ -116,26 +187,104 @@ class AdminNotesViewer {
116187 this . content ?. append ( errorMessage ) ;
117188 }
118189
119- renderNote ( text , noteName ) {
120- this . setTitle ( ` ${ noteName } .txt` ) ;
190+ renderNote ( text , title ) {
191+ this . setTitle ( title ) ;
121192 const lines = text . split ( / \r ? \n / ) ;
193+ let currentList = null ;
194+ let lastTopLevelItem = null ;
195+ let nestedList = null ;
196+
122197 lines . forEach ( ( line ) => {
123198 const trimmed = line . trim ( ) ;
124199 if ( ! trimmed ) {
200+ currentList = null ;
201+ lastTopLevelItem = null ;
202+ nestedList = null ;
203+ return ;
204+ }
205+ const bullet = line . match ( / ^ ( \s * ) - \s + ( .* ) $ / ) ;
206+ if ( bullet ) {
207+ const level = bullet [ 1 ] . length >= 2 ? 1 : 0 ;
208+ if ( level === 0 ) {
209+ currentList = currentList || this . documentRef . createElement ( "ul" ) ;
210+ if ( ! currentList . parentNode ) {
211+ this . content ?. append ( currentList ) ;
212+ }
213+ const item = this . documentRef . createElement ( "li" ) ;
214+ this . appendRichText ( item , bullet [ 2 ] . trim ( ) ) ;
215+ currentList . append ( item ) ;
216+ lastTopLevelItem = item ;
217+ nestedList = null ;
218+ return ;
219+ }
220+ if ( ! lastTopLevelItem ) {
221+ currentList = currentList || this . documentRef . createElement ( "ul" ) ;
222+ if ( ! currentList . parentNode ) {
223+ this . content ?. append ( currentList ) ;
224+ }
225+ lastTopLevelItem = this . documentRef . createElement ( "li" ) ;
226+ currentList . append ( lastTopLevelItem ) ;
227+ }
228+ nestedList = nestedList || this . documentRef . createElement ( "ul" ) ;
229+ if ( ! nestedList . parentNode ) {
230+ lastTopLevelItem . append ( nestedList ) ;
231+ }
232+ const item = this . documentRef . createElement ( "li" ) ;
233+ this . appendRichText ( item , bullet [ 2 ] . trim ( ) ) ;
234+ nestedList . append ( item ) ;
125235 return ;
126236 }
237+
127238 const node = this . sectionHeading ( trimmed )
128239 ? this . documentRef . createElement ( "h3" )
129240 : this . documentRef . createElement ( "p" ) ;
130- this . appendLinkedText ( node , trimmed ) ;
241+ this . appendRichText ( node , trimmed ) ;
131242 this . content ?. append ( node ) ;
243+ currentList = null ;
244+ lastTopLevelItem = null ;
245+ nestedList = null ;
132246 } ) ;
133247 }
134248
135249 sectionHeading ( text ) {
136250 return [ "Ideas" , "Things to Fix" , "Undecided Questions" ] . includes ( text ) ;
137251 }
138252
253+ appendRichText ( parent , text ) {
254+ const statusMatch = text . match ( STATUS_ICON_PATTERN ) ;
255+ if ( statusMatch ) {
256+ parent . append ( this . statusIcon ( statusMatch [ 1 ] ) ) ;
257+ parent . append ( this . documentRef . createTextNode ( " " ) ) ;
258+ this . appendLinkedText ( parent , statusMatch [ 2 ] ) ;
259+ return ;
260+ }
261+ this . appendLinkedText ( parent , text ) ;
262+ }
263+
264+ statusIcon ( rawStatus ) {
265+ const icon = this . documentRef . createElement ( "strong" ) ;
266+ icon . dataset . adminNotesStatusIcon = this . statusIconName ( rawStatus ) ;
267+ icon . textContent = `[${ rawStatus } ]` ;
268+ return icon ;
269+ }
270+
271+ statusIconName ( rawStatus ) {
272+ const normalizedStatus = rawStatus . toLowerCase ( ) ;
273+ if ( normalizedStatus === "x" ) {
274+ return "done" ;
275+ }
276+ if ( normalizedStatus === "." ) {
277+ return "active" ;
278+ }
279+ if ( normalizedStatus === "!" ) {
280+ return "blocked" ;
281+ }
282+ if ( normalizedStatus === "?" ) {
283+ return "question" ;
284+ }
285+ return "open" ;
286+ }
287+
139288 appendLinkedText ( parent , text ) {
140289 const bracketPattern = / \[ ( [ ^ \] ] + ) \] / g;
141290 let cursor = 0 ;
@@ -149,15 +298,31 @@ class AdminNotesViewer {
149298 parent . append ( this . documentRef . createTextNode ( text . slice ( cursor ) ) ) ;
150299 }
151300
152- linkOrText ( noteName ) {
153- const normalizedNoteName = this . noteNameFromValue ( noteName ) ;
301+ linkOrText ( linkText ) {
302+ const commaIndex = linkText . indexOf ( "," ) ;
303+ if ( commaIndex !== - 1 ) {
304+ const label = linkText . slice ( 0 , commaIndex ) . trim ( ) ;
305+ const filePath = this . rootRelativeTextPathFromValue ( linkText . slice ( commaIndex + 1 ) ) ;
306+ if ( ! label || ! filePath ) {
307+ return this . documentRef . createTextNode ( `[${ linkText } ]` ) ;
308+ }
309+ const link = this . documentRef . createElement ( "a" ) ;
310+ link . href = this . hrefForFile ( filePath ) ;
311+ link . className = LINK_CLASS ;
312+ link . dataset . adminNoteFile = filePath ;
313+ link . textContent = label ;
314+ return link ;
315+ }
316+
317+ const normalizedNoteName = this . noteNameFromValue ( linkText ) ;
154318 if ( ! this . validNoteName ( normalizedNoteName ) ) {
155- return this . documentRef . createTextNode ( `[${ noteName } ]` ) ;
319+ return this . documentRef . createTextNode ( `[${ linkText } ]` ) ;
156320 }
157321 const link = this . documentRef . createElement ( "a" ) ;
158322 link . href = this . hrefForNote ( normalizedNoteName ) ;
323+ link . className = LINK_CLASS ;
159324 link . dataset . adminNoteLink = normalizedNoteName ;
160- link . textContent = `[${ noteName } ]` ;
325+ link . textContent = `[${ linkText } ]` ;
161326 return link ;
162327 }
163328}
0 commit comments