@@ -19,12 +19,94 @@ use crate::{
1919 utils:: config,
2020} ;
2121
22+ use serde:: { Deserialize , Serialize } ;
23+
2224lazy_static ! {
2325static refBLOG : Collection =Collection :: new( "Blog" , true ) ;
2426static refCAREERS : Collection =Collection :: new( "Careers" , true ) ;
2527static refDOCS : Collection =Collection :: new( "Docs" , false ) ;
2628}
2729
30+ #[ derive( Debug , Serialize , Deserialize ) ]
31+ pub struct Document {
32+ /// The absolute path on disk
33+ pub path : PathBuf ,
34+ pub description : Option < String > ,
35+ pub image : Option < String > ,
36+ pub title : String ,
37+ pub toc_links : Vec < TocLink > ,
38+ pub html : String ,
39+ }
40+
41+ impl Document {
42+ pub async fn from_path ( path : & PathBuf ) -> anyhow:: Result < Document > {
43+ let contents = tokio:: fs:: read_to_string ( & path) . await ?;
44+
45+ let parts = contents. split ( "---" ) . collect :: < Vec < & str > > ( ) ;
46+
47+ let ( description, contents) =if parts. len ( ) >1 {
48+ match YamlLoader :: load_from_str ( parts[ 1 ] ) {
49+ Ok ( meta) =>{
50+ if meta. len ( ) ==0 || meta[ 0 ] . as_hash ( ) . is_none ( ) {
51+ ( None , contents)
52+ } else {
53+ let description: Option < String > =match meta[ 0 ] [ "description" ] . is_badvalue ( )
54+ {
55+ true =>None ,
56+ false =>Some ( meta[ 0 ] [ "description" ] . as_str ( ) . unwrap ( ) . to_string ( ) ) ,
57+ } ;
58+ ( description, parts[ 2 ..] . join ( "---" ) . to_string ( ) )
59+ }
60+ }
61+ Err ( _) =>( None , contents) ,
62+ }
63+ } else {
64+ ( None , contents)
65+ } ;
66+
67+ // Parse Markdown
68+ let arena =Arena :: new ( ) ;
69+ let spaced_contents =crate :: utils:: markdown:: gitbook_preprocess ( & contents) ;
70+ let root =parse_document ( & arena, & spaced_contents, & crate :: utils:: markdown:: options ( ) ) ;
71+
72+ // Title of the document is the first (and typically only) <h1>
73+ let title =crate :: utils:: markdown:: get_title ( root) . unwrap ( ) ;
74+ let toc_links =crate :: utils:: markdown:: get_toc ( root) . unwrap ( ) ;
75+ let image =crate :: utils:: markdown:: get_image ( root) ;
76+ crate :: utils:: markdown:: wrap_tables ( root, & arena) . unwrap ( ) ;
77+
78+ // MkDocs, gitbook syntax support, e.g. tabs, notes, alerts, etc.
79+ crate :: utils:: markdown:: mkdocs ( root, & arena) . unwrap ( ) ;
80+
81+ // Style headings like we like them
82+ let mut plugins =ComrakPlugins :: default ( ) ;
83+ let headings =crate :: utils:: markdown:: MarkdownHeadings :: new ( ) ;
84+ plugins. render . heading_adapter =Some ( & headings) ;
85+ plugins. render . codefence_syntax_highlighter =
86+ Some ( & crate :: utils:: markdown:: SyntaxHighlighter { } ) ;
87+
88+ let mut html =vec ! [ ] ;
89+ format_html_with_plugins (
90+ root,
91+ & crate :: utils:: markdown:: options ( ) ,
92+ & mut html,
93+ & plugins,
94+ )
95+ . unwrap ( ) ;
96+ let html =String :: from_utf8 ( html) . unwrap ( ) ;
97+
98+ let document =Document {
99+ path : path. to_owned ( ) ,
100+ description,
101+ image,
102+ title,
103+ toc_links,
104+ html,
105+ } ;
106+ Ok ( document)
107+ }
108+ }
109+
28110/// A Gitbook collection of documents
29111#[ derive( Default ) ]
30112struct Collection {
@@ -62,6 +144,7 @@ impl Collection {
62144
63145pub async fn get_asset ( & self , path : & str ) ->Option < NamedFile > {
64146info ! ( "get_asset: {} {path}" , self . name) ;
147+
65148NamedFile :: open ( self . asset_dir . join ( path) ) . await . ok ( )
66149}
67150
@@ -79,7 +162,7 @@ impl Collection {
79162
80163let path =self . root_dir . join ( format ! ( "{}.md" , path. to_string_lossy( ) ) ) ;
81164
82- self . render ( & path, cluster, self ) . await
165+ self . render ( & path, cluster) . await
83166}
84167
85168/// Create an index of the Collection based on the SUMMARY.md from Gitbook.
@@ -173,109 +256,35 @@ impl Collection {
173256Ok ( links)
174257}
175258
176- async fn render < ' a > (
177- & self ,
178- path : & ' a PathBuf ,
179- cluster : & Cluster ,
180- collection : & Collection ,
181- ) ->Result < ResponseOk , Status > {
182- // Read to string0
183- let contents =match tokio:: fs:: read_to_string ( & path) . await {
184- Ok ( contents) =>{
185- info ! ( "loading markdown file: '{:?}" , path) ;
186- contents
187- }
188- Err ( err) =>{
189- warn ! ( "Error parsing markdown file: '{:?}' {:?}" , path, err) ;
190- return Err ( Status :: NotFound ) ;
191- }
192- } ;
193- let parts = contents. split ( "---" ) . collect :: < Vec < & str > > ( ) ;
194- let ( description, contents) =if parts. len ( ) >1 {
195- match YamlLoader :: load_from_str ( parts[ 1 ] ) {
196- Ok ( meta) =>{
197- if !meta. is_empty ( ) {
198- let meta = meta[ 0 ] . clone ( ) ;
199- if meta. as_hash ( ) . is_none ( ) {
200- ( None , contents. to_string ( ) )
201- } else {
202- let description: Option < String > =match meta[ "description" ]
203- . is_badvalue ( )
204- {
205- true =>None ,
206- false =>Some ( meta[ "description" ] . as_str ( ) . unwrap ( ) . to_string ( ) ) ,
207- } ;
208-
209- ( description, parts[ 2 ..] . join ( "---" ) . to_string ( ) )
210- }
211- } else {
212- ( None , contents. to_string ( ) )
213- }
214- }
215- Err ( _) =>( None , contents. to_string ( ) ) ,
216- }
217- } else {
218- ( None , contents. to_string ( ) )
219- } ;
220-
221- // Parse Markdown
222- let arena =Arena :: new ( ) ;
223- let root =parse_document ( & arena, & contents, & crate :: utils:: markdown:: options ( ) ) ;
224-
225- // Title of the document is the first (and typically only) <h1>
226- let title =crate :: utils:: markdown:: get_title ( root) . unwrap ( ) ;
227- let toc_links =crate :: utils:: markdown:: get_toc ( root) . unwrap ( ) ;
228- let image =crate :: utils:: markdown:: get_image ( root) ;
229- crate :: utils:: markdown:: wrap_tables ( root, & arena) . unwrap ( ) ;
230-
231- // MkDocs syntax support, e.g. tabs, notes, alerts, etc.
232- crate :: utils:: markdown:: mkdocs ( root, & arena) . unwrap ( ) ;
233-
234- // Style headings like we like them
235- let mut plugins =ComrakPlugins :: default ( ) ;
236- let headings =crate :: utils:: markdown:: MarkdownHeadings :: new ( ) ;
237- plugins. render . heading_adapter =Some ( & headings) ;
238- plugins. render . codefence_syntax_highlighter =
239- Some ( & crate :: utils:: markdown:: SyntaxHighlighter { } ) ;
240-
241- // Render
242- let mut html =vec ! [ ] ;
243- format_html_with_plugins (
244- root,
245- & crate :: utils:: markdown:: options ( ) ,
246- & mut html,
247- & plugins,
248- )
249- . unwrap ( ) ;
250- let html =String :: from_utf8 ( html) . unwrap ( ) ;
251-
252- // Handle navigation
253- // TODO organize this functionality in the collection to cleanup
254- let index: Vec < IndexLink > =self
255- . index
259+ // Sets specified index as currently viewed.
260+ fn open_index ( & self , path : PathBuf ) ->Vec < IndexLink > {
261+ self . index
256262. clone ( )
257263. iter_mut ( )
258264. map ( |nav_link|{
259265let mut nav_link = nav_link. clone ( ) ;
260- nav_link. should_open ( path) ;
266+ nav_link. should_open ( & path) ;
261267 nav_link
262268} )
263- . collect ( ) ;
269+ . collect ( )
270+ }
271+
272+ // renders document in layout
273+ async fn render < ' a > ( & self , path : & ' a PathBuf , cluster : & Cluster ) ->Result < ResponseOk , Status > {
274+ let doc =Document :: from_path ( & path) . await . unwrap ( ) ;
275+ let index =self . open_index ( doc. path ) ;
264276
265277let user =if cluster. context . user . is_anonymous ( ) {
266278None
267279} else {
268280Some ( cluster. context . user . clone ( ) )
269281} ;
270282
271- let mut layout =crate :: templates:: Layout :: new ( & title, Some ( cluster) ) ;
272- if let Some ( image) = image{
273- // translate relative url into absolute for head social sharing
274- let parts = image. split ( ".gitbook/assets/" ) . collect :: < Vec < & str > > ( ) ;
275- let image_path = collection. url_root . join ( ".gitbook/assets" ) . join ( parts[ 1 ] ) ;
276- layout. image ( config:: asset_url ( image_path. to_string_lossy ( ) ) . as_ref ( ) ) ;
283+ let mut layout =crate :: templates:: Layout :: new ( & doc. title , Some ( cluster) ) ;
284+ if let Some ( image) = doc. image {
285+ layout. image ( & config:: asset_url ( image. into ( ) ) ) ;
277286}
278- if let Some ( description) =& description{
287+ if let Some ( description) =& doc . description {
279288 layout. description ( description) ;
280289}
281290if let Some ( user) =& user{
@@ -285,11 +294,11 @@ impl Collection {
285294let layout = layout
286295. nav_title ( & self . name )
287296. nav_links ( & index)
288- . toc_links ( & toc_links)
297+ . toc_links ( & doc . toc_links )
289298. footer ( cluster. context . marketing_footer . to_string ( ) ) ;
290299
291300Ok ( ResponseOk (
292- layout. render ( crate :: templates:: Article { content : html} ) ,
301+ layout. render ( crate :: templates:: Article { content : doc . html } ) ,
293302) )
294303}
295304}
@@ -365,6 +374,10 @@ pub fn routes() -> Vec<Route> {
365374mod test{
366375use super :: * ;
367376use crate :: utils:: markdown:: { options, MarkdownHeadings , SyntaxHighlighter } ;
377+ use regex:: Regex ;
378+ use rocket:: http:: { ContentType , Cookie , Status } ;
379+ use rocket:: local:: asynchronous:: Client ;
380+ use rocket:: { Build , Rocket } ;
368381
369382#[ test]
370383fn test_syntax_highlighting ( ) {
@@ -452,4 +465,73 @@ This is the end of the markdown
452465 !html. contains( r#"<div class="overflow-auto w-100">"# ) || !html. contains( r#"</div>"# )
453466) ;
454467}
468+
469+ async fn rocket ( ) ->Rocket < Build > {
470+ dotenv:: dotenv ( ) . ok ( ) ;
471+ rocket:: build ( )
472+ . manage ( crate :: utils:: markdown:: SearchIndex :: open ( ) . unwrap ( ) )
473+ . mount ( "/" , crate :: api:: cms:: routes ( ) )
474+ }
475+
476+ fn gitbook_test ( html : String ) ->Option < String > {
477+ // all gitbook expresions should be removed, this catches {% %} nonsupported expressions.
478+ let re =Regex :: new ( r"[{][%][^{]*[%][}]" ) . unwrap ( ) ;
479+ let rsp = re. find ( & html) ;
480+ if rsp. is_some ( ) {
481+ return Some ( rsp. unwrap ( ) . as_str ( ) . to_string ( ) ) ;
482+ }
483+
484+ // gitbook TeX block not supported yet
485+ let re =Regex :: new ( r"(\$\$).*(\$\$)" ) . unwrap ( ) ;
486+ let rsp = re. find ( & html) ;
487+ if rsp. is_some ( ) {
488+ return Some ( rsp. unwrap ( ) . as_str ( ) . to_string ( ) ) ;
489+ }
490+
491+ None
492+ }
493+
494+ // Ensure blogs render and there are no unparsed gitbook components.
495+ #[ sqlx:: test]
496+ async fn render_blogs_test ( ) {
497+ let client =Client :: tracked ( rocket ( ) . await ) . await . unwrap ( ) ;
498+ let blog: Collection =Collection :: new ( "Blog" , true ) ;
499+
500+ for pathin blog. index {
501+ let req = client. get ( path. clone ( ) . href ) ;
502+ let rsp = req. dispatch ( ) . await ;
503+ let body = rsp. into_string ( ) . await . unwrap ( ) ;
504+
505+ let test =gitbook_test ( body) ;
506+
507+ assert ! (
508+ test. is_none( ) ,
509+ "bad html parse in {:?}. This feature is not supported {:?}" ,
510+ path. href,
511+ test. unwrap( )
512+ )
513+ }
514+ }
515+
516+ // Ensure Docs render and ther are no unparsed gitbook compnents.
517+ #[ sqlx:: test]
518+ async fn render_guides_test ( ) {
519+ let client =Client :: tracked ( rocket ( ) . await ) . await . unwrap ( ) ;
520+ let docs: Collection =Collection :: new ( "Docs" , true ) ;
521+
522+ for pathin docs. index {
523+ let req = client. get ( path. clone ( ) . href ) ;
524+ let rsp = req. dispatch ( ) . await ;
525+ let body = rsp. into_string ( ) . await . unwrap ( ) ;
526+
527+ let test =gitbook_test ( body) ;
528+
529+ assert ! (
530+ test. is_none( ) ,
531+ "bad html parse in {:?}. This feature is not supported {:?}" ,
532+ path. href,
533+ test. unwrap( )
534+ )
535+ }
536+ }
455537}