@@ -18,6 +18,93 @@ const webgpuTypesPath = path.join(__dirname, 'node_modules', '@webgpu', 'types')
1818const dataDir = require ( './build/appdata' ) ( 'servez-cli' ) ;
1919const Servez = require ( 'servez-lib' ) ;
2020
21+ // Seems hacky, should probably register something in lesson-builder
22+ const Handlebars = require ( 'handlebars' ) ;
23+ const hanson = require ( 'hanson' ) ;
24+ Handlebars . registerHelper ( 'toc-steps' , function ( options ) {
25+ if ( ! options || ! options . hash || ! options . hash . list ) {
26+ return '' ;
27+ }
28+
29+ const listFilename = path . basename ( options . hash . list ) ;
30+ const lessonsDir = path . resolve ( path . join ( process . cwd ( ) , 'webgpu/lessons' ) ) ;
31+ const expectedPrefix = lessonsDir + path . sep ;
32+ const resolvedListPath = path . resolve ( lessonsDir , listFilename ) ;
33+
34+ if ( ! resolvedListPath . startsWith ( expectedPrefix ) ) {
35+ throw new Error ( `Security Error: Path traversal detected for ${ resolvedListPath } ` ) ;
36+ }
37+
38+ if ( ! fs . existsSync ( resolvedListPath ) ) {
39+ throw new Error ( `List file not found: ${ resolvedListPath } ` ) ;
40+ }
41+
42+ const listContent = fs . readFileSync ( resolvedListPath , 'utf-8' ) ;
43+ const articleFilenames = hanson . parse ( listContent ) ;
44+
45+ if ( ! Array . isArray ( articleFilenames ) ) {
46+ throw new Error ( `Expected array in hanson file: ${ resolvedListPath } ` ) ;
47+ }
48+
49+ const root = options . data && options . data . root ? options . data . root : this ;
50+ const currentLang = root . lang || 'en' ;
51+ const currentContentFileName = root . contentFileName || '' ;
52+ const currentBasename = path . basename ( currentContentFileName ) ;
53+ const hereText = root . here || '(here)' ;
54+
55+ const lis = articleFilenames . map ( ( rawFilename ) => {
56+ const sanitizedFilename = path . basename ( rawFilename ) ;
57+
58+ // Check for localized file first, then fallback to English
59+ let mdPath = path . resolve ( lessonsDir , currentLang === 'en' ? '' : currentLang , sanitizedFilename ) ;
60+ const expectedMdPrefix = path . resolve ( lessonsDir , currentLang === 'en' ? '' : currentLang ) + path . sep ;
61+
62+ if ( ! mdPath . startsWith ( expectedMdPrefix ) ) {
63+ throw new Error ( `Security Error: Path traversal detected for ${ mdPath } ` ) ;
64+ }
65+
66+ if ( ! fs . existsSync ( mdPath ) ) {
67+ mdPath = path . resolve ( lessonsDir , sanitizedFilename ) ;
68+ if ( ! mdPath . startsWith ( expectedPrefix ) ) {
69+ throw new Error ( `Security Error: Path traversal detected for ${ mdPath } ` ) ;
70+ }
71+ }
72+
73+ let title = sanitizedFilename ;
74+ const htmlFilename = sanitizedFilename . replace ( / \. m d $ / , '.html' ) ;
75+ let href = `${ ( currentLang === 'en' ? '' : '../' ) } ${ htmlFilename } ` ;
76+ if ( fs . existsSync ( mdPath ) ) {
77+ href = htmlFilename ;
78+ const content = fs . readFileSync ( mdPath , 'utf-8' ) ;
79+ const lines = content . split ( '\n' ) ;
80+ let headerTitle = '' ;
81+ let headerToc = '' ;
82+ for ( const rawLine of lines ) {
83+ const line = rawLine . trim ( ) ;
84+ const m = / ( [ A - Z 0 - 9 _ - ] + ) : ( .* ?) $ / i. exec ( line ) ;
85+ if ( ! m ) {
86+ break ;
87+ }
88+ const key = m [ 1 ] . toLowerCase ( ) ;
89+ if ( key === 'title' ) {
90+ headerTitle = m [ 2 ] ;
91+ } else if ( key === 'toc' ) {
92+ headerToc = m [ 2 ] ;
93+ }
94+ }
95+ title = headerToc || headerTitle || title ;
96+ }
97+
98+ const escapedTitle = Handlebars . escapeExpression ( title ) ;
99+ const isCurrent = sanitizedFilename === currentBasename ;
100+ const suffix = isCurrent ? ` ⬅ ${ Handlebars . escapeExpression ( hereText ) } ` : '' ;
101+
102+ return ` <li><a href="${ href } ">${ escapedTitle } </a>${ suffix } </li>` ;
103+ } ) ;
104+
105+ return new Handlebars . SafeString ( `<ol>\n${ lis . join ( '\n' ) } \n</ol>` ) ;
106+ } ) ;
107+
21108module . exports = function ( grunt ) {
22109
23110 require ( 'load-grunt-tasks' ) ( grunt ) ;
0 commit comments