From bbef2c1ab6243b949ae788b814b2617de89233e1 Mon Sep 17 00:00:00 2001 From: Chng-Zhi-Xuan Date: Mon, 14 Jan 2019 11:31:39 +0800 Subject: [PATCH 1/9] Asset: Add Bootstrap Javascript Utilities --- asset/js/bootstrap-utility.min.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 asset/js/bootstrap-utility.min.js diff --git a/asset/js/bootstrap-utility.min.js b/asset/js/bootstrap-utility.min.js new file mode 100644 index 0000000000..00c895f0f3 --- /dev/null +++ b/asset/js/bootstrap-utility.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v4.1.3 (https://getbootstrap.com/) + * Copyright 2011-2018 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("jquery"),require("popper.js")):"function"==typeof define&&define.amd?define(["exports","jquery","popper.js"],e):e(t.bootstrap={},t.jQuery,t.Popper)}(this,function(t,e,h){"use strict";function i(t,e){for(var n=0;nthis._items.length-1||t<0))if(this._isSliding)P(this._element).one(Q.SLID,function(){return e.to(t)});else{if(n===t)return this.pause(),void this.cycle();var i=ndocument.documentElement.clientHeight;!this._isBodyOverflowing&&t&&(this._element.style.paddingLeft=this._scrollbarWidth+"px"),this._isBodyOverflowing&&!t&&(this._element.style.paddingRight=this._scrollbarWidth+"px")},t._resetAdjustments=function(){this._element.style.paddingLeft="",this._element.style.paddingRight=""},t._checkScrollbar=function(){var t=document.body.getBoundingClientRect();this._isBodyOverflowing=t.left+t.right
',trigger:"hover focus",title:"",delay:0,html:!(Ie={AUTO:"auto",TOP:"top",RIGHT:"right",BOTTOM:"bottom",LEFT:"left"}),selector:!(Se={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(number|string)",container:"(string|element|boolean)",fallbackPlacement:"(string|array)",boundary:"(string|element)"}),placement:"top",offset:0,container:!1,fallbackPlacement:"flip",boundary:"scrollParent"},we="out",Ne={HIDE:"hide"+Ee,HIDDEN:"hidden"+Ee,SHOW:(De="show")+Ee,SHOWN:"shown"+Ee,INSERTED:"inserted"+Ee,CLICK:"click"+Ee,FOCUSIN:"focusin"+Ee,FOCUSOUT:"focusout"+Ee,MOUSEENTER:"mouseenter"+Ee,MOUSELEAVE:"mouseleave"+Ee},Oe="fade",ke="show",Pe=".tooltip-inner",je=".arrow",He="hover",Le="focus",Re="click",xe="manual",We=function(){function i(t,e){if("undefined"==typeof h)throw new TypeError("Bootstrap tooltips require Popper.js (https://popper.js.org)");this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this.element=t,this.config=this._getConfig(e),this.tip=null,this._setListeners()}var t=i.prototype;return t.enable=function(){this._isEnabled=!0},t.disable=function(){this._isEnabled=!1},t.toggleEnabled=function(){this._isEnabled=!this._isEnabled},t.toggle=function(t){if(this._isEnabled)if(t){var e=this.constructor.DATA_KEY,n=pe(t.currentTarget).data(e);n||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),pe(t.currentTarget).data(e,n)),n._activeTrigger.click=!n._activeTrigger.click,n._isWithActiveTrigger()?n._enter(null,n):n._leave(null,n)}else{if(pe(this.getTipElement()).hasClass(ke))return void this._leave(null,this);this._enter(null,this)}},t.dispose=function(){clearTimeout(this._timeout),pe.removeData(this.element,this.constructor.DATA_KEY),pe(this.element).off(this.constructor.EVENT_KEY),pe(this.element).closest(".modal").off("hide.bs.modal"),this.tip&&pe(this.tip).remove(),this._isEnabled=null,this._timeout=null,this._hoverState=null,(this._activeTrigger=null)!==this._popper&&this._popper.destroy(),this._popper=null,this.element=null,this.config=null,this.tip=null},t.show=function(){var e=this;if("none"===pe(this.element).css("display"))throw new Error("Please use show on visible elements");var t=pe.Event(this.constructor.Event.SHOW);if(this.isWithContent()&&this._isEnabled){pe(this.element).trigger(t);var n=pe.contains(this.element.ownerDocument.documentElement,this.element);if(t.isDefaultPrevented()||!n)return;var i=this.getTipElement(),r=Fn.getUID(this.constructor.NAME);i.setAttribute("id",r),this.element.setAttribute("aria-describedby",r),this.setContent(),this.config.animation&&pe(i).addClass(Oe);var o="function"==typeof this.config.placement?this.config.placement.call(this,i,this.element):this.config.placement,s=this._getAttachment(o);this.addAttachmentClass(s);var a=!1===this.config.container?document.body:pe(document).find(this.config.container);pe(i).data(this.constructor.DATA_KEY,this),pe.contains(this.element.ownerDocument.documentElement,this.tip)||pe(i).appendTo(a),pe(this.element).trigger(this.constructor.Event.INSERTED),this._popper=new h(this.element,i,{placement:s,modifiers:{offset:{offset:this.config.offset},flip:{behavior:this.config.fallbackPlacement},arrow:{element:je},preventOverflow:{boundariesElement:this.config.boundary}},onCreate:function(t){t.originalPlacement!==t.placement&&e._handlePopperPlacementChange(t)},onUpdate:function(t){e._handlePopperPlacementChange(t)}}),pe(i).addClass(ke),"ontouchstart"in document.documentElement&&pe(document.body).children().on("mouseover",null,pe.noop);var l=function(){e.config.animation&&e._fixTransition();var t=e._hoverState;e._hoverState=null,pe(e.element).trigger(e.constructor.Event.SHOWN),t===we&&e._leave(null,e)};if(pe(this.tip).hasClass(Oe)){var c=Fn.getTransitionDurationFromElement(this.tip);pe(this.tip).one(Fn.TRANSITION_END,l).emulateTransitionEnd(c)}else l()}},t.hide=function(t){var e=this,n=this.getTipElement(),i=pe.Event(this.constructor.Event.HIDE),r=function(){e._hoverState!==De&&n.parentNode&&n.parentNode.removeChild(n),e._cleanTipClass(),e.element.removeAttribute("aria-describedby"),pe(e.element).trigger(e.constructor.Event.HIDDEN),null!==e._popper&&e._popper.destroy(),t&&t()};if(pe(this.element).trigger(i),!i.isDefaultPrevented()){if(pe(n).removeClass(ke),"ontouchstart"in document.documentElement&&pe(document.body).children().off("mouseover",null,pe.noop),this._activeTrigger[Re]=!1,this._activeTrigger[Le]=!1,this._activeTrigger[He]=!1,pe(this.tip).hasClass(Oe)){var o=Fn.getTransitionDurationFromElement(n);pe(n).one(Fn.TRANSITION_END,r).emulateTransitionEnd(o)}else r();this._hoverState=""}},t.update=function(){null!==this._popper&&this._popper.scheduleUpdate()},t.isWithContent=function(){return Boolean(this.getTitle())},t.addAttachmentClass=function(t){pe(this.getTipElement()).addClass(Te+"-"+t)},t.getTipElement=function(){return this.tip=this.tip||pe(this.config.template)[0],this.tip},t.setContent=function(){var t=this.getTipElement();this.setElementContent(pe(t.querySelectorAll(Pe)),this.getTitle()),pe(t).removeClass(Oe+" "+ke)},t.setElementContent=function(t,e){var n=this.config.html;"object"==typeof e&&(e.nodeType||e.jquery)?n?pe(e).parent().is(t)||t.empty().append(e):t.text(pe(e).text()):t[n?"html":"text"](e)},t.getTitle=function(){var t=this.element.getAttribute("data-original-title");return t||(t="function"==typeof this.config.title?this.config.title.call(this.element):this.config.title),t},t._getAttachment=function(t){return Ie[t.toUpperCase()]},t._setListeners=function(){var i=this;this.config.trigger.split(" ").forEach(function(t){if("click"===t)pe(i.element).on(i.constructor.Event.CLICK,i.config.selector,function(t){return i.toggle(t)});else if(t!==xe){var e=t===He?i.constructor.Event.MOUSEENTER:i.constructor.Event.FOCUSIN,n=t===He?i.constructor.Event.MOUSELEAVE:i.constructor.Event.FOCUSOUT;pe(i.element).on(e,i.config.selector,function(t){return i._enter(t)}).on(n,i.config.selector,function(t){return i._leave(t)})}pe(i.element).closest(".modal").on("hide.bs.modal",function(){return i.hide()})}),this.config.selector?this.config=l({},this.config,{trigger:"manual",selector:""}):this._fixTitle()},t._fixTitle=function(){var t=typeof this.element.getAttribute("data-original-title");(this.element.getAttribute("title")||"string"!==t)&&(this.element.setAttribute("data-original-title",this.element.getAttribute("title")||""),this.element.setAttribute("title",""))},t._enter=function(t,e){var n=this.constructor.DATA_KEY;(e=e||pe(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),pe(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusin"===t.type?Le:He]=!0),pe(e.getTipElement()).hasClass(ke)||e._hoverState===De?e._hoverState=De:(clearTimeout(e._timeout),e._hoverState=De,e.config.delay&&e.config.delay.show?e._timeout=setTimeout(function(){e._hoverState===De&&e.show()},e.config.delay.show):e.show())},t._leave=function(t,e){var n=this.constructor.DATA_KEY;(e=e||pe(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),pe(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusout"===t.type?Le:He]=!1),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=we,e.config.delay&&e.config.delay.hide?e._timeout=setTimeout(function(){e._hoverState===we&&e.hide()},e.config.delay.hide):e.hide())},t._isWithActiveTrigger=function(){for(var t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1},t._getConfig=function(t){return"number"==typeof(t=l({},this.constructor.Default,pe(this.element).data(),"object"==typeof t&&t?t:{})).delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),Fn.typeCheckConfig(ve,t,this.constructor.DefaultType),t},t._getDelegateConfig=function(){var t={};if(this.config)for(var e in this.config)this.constructor.Default[e]!==this.config[e]&&(t[e]=this.config[e]);return t},t._cleanTipClass=function(){var t=pe(this.getTipElement()),e=t.attr("class").match(be);null!==e&&e.length&&t.removeClass(e.join(""))},t._handlePopperPlacementChange=function(t){var e=t.instance;this.tip=e.popper,this._cleanTipClass(),this.addAttachmentClass(this._getAttachment(t.placement))},t._fixTransition=function(){var t=this.getTipElement(),e=this.config.animation;null===t.getAttribute("x-placement")&&(pe(t).removeClass(Oe),this.config.animation=!1,this.hide(),this.show(),this.config.animation=e)},i._jQueryInterface=function(n){return this.each(function(){var t=pe(this).data(ye),e="object"==typeof n&&n;if((t||!/dispose|hide/.test(n))&&(t||(t=new i(this,e),pe(this).data(ye,t)),"string"==typeof n)){if("undefined"==typeof t[n])throw new TypeError('No method named "'+n+'"');t[n]()}})},s(i,null,[{key:"VERSION",get:function(){return"4.1.3"}},{key:"Default",get:function(){return Ae}},{key:"NAME",get:function(){return ve}},{key:"DATA_KEY",get:function(){return ye}},{key:"Event",get:function(){return Ne}},{key:"EVENT_KEY",get:function(){return Ee}},{key:"DefaultType",get:function(){return Se}}]),i}(),pe.fn[ve]=We._jQueryInterface,pe.fn[ve].Constructor=We,pe.fn[ve].noConflict=function(){return pe.fn[ve]=Ce,We._jQueryInterface},We),Jn=(qe="popover",Ke="."+(Fe="bs.popover"),Me=(Ue=e).fn[qe],Qe="bs-popover",Be=new RegExp("(^|\\s)"+Qe+"\\S+","g"),Ve=l({},zn.Default,{placement:"right",trigger:"click",content:"",template:''}),Ye=l({},zn.DefaultType,{content:"(string|element|function)"}),ze="fade",Ze=".popover-header",Ge=".popover-body",$e={HIDE:"hide"+Ke,HIDDEN:"hidden"+Ke,SHOW:(Je="show")+Ke,SHOWN:"shown"+Ke,INSERTED:"inserted"+Ke,CLICK:"click"+Ke,FOCUSIN:"focusin"+Ke,FOCUSOUT:"focusout"+Ke,MOUSEENTER:"mouseenter"+Ke,MOUSELEAVE:"mouseleave"+Ke},Xe=function(t){var e,n;function i(){return t.apply(this,arguments)||this}n=t,(e=i).prototype=Object.create(n.prototype),(e.prototype.constructor=e).__proto__=n;var r=i.prototype;return r.isWithContent=function(){return this.getTitle()||this._getContent()},r.addAttachmentClass=function(t){Ue(this.getTipElement()).addClass(Qe+"-"+t)},r.getTipElement=function(){return this.tip=this.tip||Ue(this.config.template)[0],this.tip},r.setContent=function(){var t=Ue(this.getTipElement());this.setElementContent(t.find(Ze),this.getTitle());var e=this._getContent();"function"==typeof e&&(e=e.call(this.element)),this.setElementContent(t.find(Ge),e),t.removeClass(ze+" "+Je)},r._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},r._cleanTipClass=function(){var t=Ue(this.getTipElement()),e=t.attr("class").match(Be);null!==e&&0=this._offsets[r]&&("undefined"==typeof this._offsets[r+1]||t Date: Mon, 14 Jan 2019 11:32:04 +0800 Subject: [PATCH 2/9] Asset: Add page navigation CSS file --- asset/css/page-nav.css | 50 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 asset/css/page-nav.css diff --git a/asset/css/page-nav.css b/asset/css/page-nav.css new file mode 100644 index 0000000000..09bbe18374 --- /dev/null +++ b/asset/css/page-nav.css @@ -0,0 +1,50 @@ +/* Page navigation */ + +#page-nav { + border-left: solid 1px lightgrey; + display: inline-block; + max-height: 80vh; + overflow: auto; + position: fixed; + right: 0; + transition: 0.4s; + width: 300px; + -webkit-transition: 0.4s; +} + +#page-nav a:link, +#page-nav a:visited { + color: #9b9b9b; + text-decoration: none; +} + +#page-nav a:hover{ + color: black; +} + +#page-nav a.active { + background-color: transparent; + color: black; +} + +#page-nav-content-wrapper { + margin-right: 300px; + transition: 0.4s; + -webkit-transition: 0.4s; +} + +/* Responsive site navigation */ + +/* Hides page navigation on screen widths smaller than 1300px */ +@media screen and (max-width: 1299.98px) { + + #page-nav { + overflow: hidden; + padding: 0; + width: 0px; + } + + #page-nav-content-wrapper { + margin-right: 0; + } +} From dec6c37db99596c999d7214644c3407f655ffd4e Mon Sep 17 00:00:00 2001 From: Chng-Zhi-Xuan Date: Mon, 14 Jan 2019 11:32:45 +0800 Subject: [PATCH 3/9] Asset: Add slim-scroll class --- asset/css/markbind.css | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/asset/css/markbind.css b/asset/css/markbind.css index 6bd738d242..643cad88ed 100644 --- a/asset/css/markbind.css +++ b/asset/css/markbind.css @@ -121,3 +121,33 @@ footer { #flex-div { flex: 1; } + +/* Scrollbar */ + +.slim-scroll::-webkit-scrollbar { + width: 5px; +} + +.slim-scroll::-webkit-scrollbar-thumb { + background: #808080; + border-radius: 20px; +} + +.slim-scroll::-webkit-scrollbar-track { + background: transparent; + border-radius: 20px; +} + +.slim-scroll-blue::-webkit-scrollbar { + width: 5px; +} + +.slim-scroll-blue::-webkit-scrollbar-thumb { + background: #00b0ef; + border-radius: 20px; +} + +.slim-scroll-blue::-webkit-scrollbar-track { + background: transparent; + border-radius: 20px; +} From 4b06cc1157dfc4ccaa7f28ff8bec157dc9011e09 Mon Sep 17 00:00:00 2001 From: Chng-Zhi-Xuan Date: Mon, 14 Jan 2019 11:33:19 +0800 Subject: [PATCH 4/9] Asset: Add auto scroll function to page nav --- asset/js/setup.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/asset/js/setup.js b/asset/js/setup.js index 6884e5a9ea..b7c9393a22 100644 --- a/asset/js/setup.js +++ b/asset/js/setup.js @@ -62,6 +62,12 @@ function setupSiteNav() { ); } +function setupPageNav() { + jQuery(window).on('activate.bs.scrollspy', (event, obj) => { + document.querySelectorAll(`a[href='${obj.relatedTarget}']`).item(0).scrollIntoView(false); + }); +} + function setup() { // eslint-disable-next-line no-unused-vars const vm = new Vue({ @@ -71,6 +77,7 @@ function setup() { }, }); setupSiteNav(); + setupPageNav(); } function setupWithSearch(siteData) { @@ -102,6 +109,7 @@ function setupWithSearch(siteData) { }, }); setupSiteNav(); + setupPageNav(); } jQuery.getJSON(`${baseUrl}/siteData.json`) From c2d2c6af0f99b71b401e4012d38fe11cbcaaef31 Mon Sep 17 00:00:00 2001 From: Chng-Zhi-Xuan Date: Mon, 14 Jan 2019 11:33:55 +0800 Subject: [PATCH 5/9] Add page navigation feature --- src/Page.js | 197 +++++++++++++++++++++++++++++++++++++++--- src/Site.js | 4 + src/template/page.ejs | 4 +- 3 files changed, 190 insertions(+), 15 deletions(-) diff --git a/src/Page.js b/src/Page.js index ace8bf19b0..307cd02d08 100644 --- a/src/Page.js +++ b/src/Page.js @@ -27,6 +27,7 @@ const FLEX_DIV_HTML = '
'; const FLEX_DIV_ID = 'flex-div'; const FRONT_MATTER_FENCE = '---'; const PAGE_CONTENT_ID = 'page-content'; +const PAGE_NAV_CONENT_WRAPPER_ID = 'page-nav-content-wrapper'; const SITE_NAV_ID = 'site-nav'; const TITLE_PREFIX_SEPARATOR = ' - '; @@ -74,8 +75,9 @@ function Page(pageConfig) { this.headFileTopContent = ''; this.headings = {}; this.headingIndexingLevel = pageConfig.headingIndexingLevel; - this.keywords = {}; this.includedFiles = {}; + this.keywords = {}; + this.navigableHeadings = {}; } /** @@ -181,6 +183,18 @@ function formatSiteNav(renderedSiteNav) { return $.html(); } +/** + * Generates a heading selector based on the indexing level + * @param headingIndexingLevel to generate + */ +function generateHeadingSelector(headingIndexingLevel) { + let headingsSelector = 'h1'; + for (let i = 2; i <= headingIndexingLevel; i += 1) { + headingsSelector += `, h${i}`; + } + return headingsSelector; +} + function unique(array) { return array.filter((item, pos, self) => self.indexOf(item) === pos); } @@ -197,6 +211,7 @@ Page.prototype.prepareTemplateData = function () { faviconUrl: this.faviconUrl, headFileBottomContent: this.headFileBottomContent, headFileTopContent: this.headFileTopContent, + pageNav: this.frontMatter.pageNav, siteNav: this.frontMatter.siteNav, title: prefixedTitle, }; @@ -227,25 +242,78 @@ function getClosestHeading($, headingsSelector, element) { } /** - * Records headings and keywords inside rendered page into this.headings and this.keywords respectively + * Checks if page.frontMatter has a valid page navigation specifier */ -Page.prototype.collectHeadingsAndKeywords = function () { - this.headings = {}; // clear any heading data from previous build - const $ = cheerio.load(fs.readFileSync(this.resultPath)); - this.collectHeadingsAndKeywordsInContent($(`#${CONTENT_WRAPPER_ID}`).html(), null, false); +Page.prototype.isPageNavigationSpecifierValid = function () { + const { pageNav } = this.frontMatter; + return pageNav && (pageNav === 'default' || Number.isInteger(pageNav)); }; /** - * Generates a heading selector based on the indexing level - * @param headingIndexingLevel to generate + * Generates element selector for page navigation, depending on specifier in front matter */ -function generateHeadingSelector(headingIndexingLevel) { - let headingsSelector = 'h1'; - for (let i = 2; i <= headingIndexingLevel; i += 1) { - headingsSelector += `, h${i}`; +Page.prototype.generateElementSelectorForPageNav = function (pageNav) { + if (pageNav === 'default') { + // Use specified navigation level or default in this.headingIndexingLevel + return `${generateHeadingSelector(this.headingIndexingLevel)}, panel`; + } else if (Number.isInteger(pageNav)) { + return `${generateHeadingSelector(parseInt(pageNav, 10))}, panel`; } - return headingsSelector; -} + // Not a valid specifier + return undefined; +}; + +/** + * Collect headings outside of models and panels + * @param content, html content of a page + */ +Page.prototype.collectNavigableHeadings = function (content) { + const { pageNav } = this.frontMatter; + const elementSelector = this.generateElementSelectorForPageNav(pageNav); + if (elementSelector === undefined) { + return; + } + const $ = cheerio.load(content); + $('modal').remove(); + $(elementSelector).each((i, elem) => { + // Check if heading or panel is already inside an unexpanded panel + let isInsideUnexpandedPanel = false; + $(elem).parents('panel').each((j, elemParent) => { + if (elemParent.attribs.expanded === undefined) { + isInsideUnexpandedPanel = true; + return false; + } + return true; + }); + if (isInsideUnexpandedPanel) { + return; + } + if (elem.name === 'panel') { + // Get heading from Panel header attribute + if (elem.attribs.header) { + this.collectNavigableHeadings(md.render(elem.attribs.header)); + } + } else if ($(elem).attr('id') !== undefined) { + // Headings already in content, with a valid ID + this.navigableHeadings[$(elem).attr('id')] = { + text: $(elem).text(), + level: elem.name.replace('h', ''), + }; + } + }); +}; + +/** + * Records headings and keywords inside rendered page into this.headings and this.keywords respectively + */ +Page.prototype.collectHeadingsAndKeywords = function () { + const $ = cheerio.load(fs.readFileSync(this.resultPath)); + // Re-initialise objects in the event of Site.regenerateAffectedPages + this.headings = {}; + this.keywords = {}; + // Collect headings and keywords + this.collectHeadingsAndKeywordsInContent($(`#${CONTENT_WRAPPER_ID}`).html(), null, false); +}; /** * Records headings and keywords inside content into this.headings and this.keywords respectively @@ -497,6 +565,105 @@ Page.prototype.insertSiteNav = function (pageData) { + ''; }; +/** + * Inserts wrapper for page nav contents CSS manipulation + */ +Page.prototype.insertPageNavWrapper = function (pageData) { + if (this.isPageNavigationSpecifierValid()) { + const wrappedPageData = `
\n` + + `${pageData}\n` + + '
\n'; + return wrappedPageData; + } + return pageData; +}; + +/** + * Generates page navigation's heading list HTML + * + * A stack is used to maintain proper indentation levels for the headings at different heading levels. + */ +Page.prototype.generatePageNavHeadingHtml = function () { + let headingHTML = ''; + const headingStack = []; + Object.keys(this.navigableHeadings).forEach((key) => { + const currentHeadingLevel = this.navigableHeadings[key].level; + const currentHeadingHTML = `` + + `${this.navigableHeadings[key].text}‎\n`; + const nestedHeadingHTML = '\n'; + headingStack.pop(); + topOfHeadingStack = headingStack[headingStack.length - 1]; + } + if (topOfHeadingStack < currentHeadingLevel) { + // Increase nesting level by 1 + headingHTML += nestedHeadingHTML; + } else { + headingHTML += currentHeadingHTML; + } + } + } + // Update heading level stack + if (headingStack.length === 0 || headingStack[headingStack.length - 1] !== currentHeadingLevel) { + headingStack.push(currentHeadingLevel); + } + }); + // Ensure proper closing for any nested lists towards the end + while (headingStack.length > 1 + && headingStack[headingStack.length - 1] > headingStack[headingStack.length - 2]) { + headingHTML += '\n'; + headingStack.pop(); + } + return headingHTML; +}; + +/** + * Generates page navigation's header if specified in this.frontMatter + * @returns string string + */ +Page.prototype.generatePageNavTitleHtml = function () { + const { pageNavTitle } = this.frontMatter; + return pageNavTitle + ? '' + + `${pageNavTitle.toString()}` + + '' + : ''; +}; + +/** + * Insert page navigation bar with headings up to headingIndexingLevel + */ +Page.prototype.insertPageNav = function () { + if (this.isPageNavigationSpecifierValid()) { + const $ = cheerio.load(this.content); + this.navigableHeadings = {}; + this.collectNavigableHeadings($(`#${CONTENT_WRAPPER_ID}`).html()); + const pageNavHeadingHTML = this.generatePageNavHeadingHtml(); + const pageNavTitleHtml = this.generatePageNavTitleHtml(); + const pageNavHtml = '\n'; + this.content = htmlBeautify(`${pageNavHtml}\n${this.content}`, { indent_size: 2 }); + } +}; + Page.prototype.collectHeadFiles = function (baseUrl, hostBaseUrl) { const { head } = this.frontMatter; let headFiles; @@ -556,6 +723,7 @@ Page.prototype.generate = function (builtFiles) { return this.removeFrontMatter(result); }) .then(result => addContentWrapper(result)) + .then(result => this.insertPageNavWrapper(result)) .then(result => this.insertSiteNav((result))) .then(result => this.insertFooter(result)) // Footer has to be inserted last to ensure proper formatting .then(result => formatFooter(result)) @@ -573,6 +741,7 @@ Page.prototype.generate = function (builtFiles) { this.addLayoutFiles(); this.collectHeadFiles(baseUrl, hostBaseUrl); this.content = nunjucks.renderString(this.content, { baseUrl, hostBaseUrl }); + this.insertPageNav(); return fs.outputFileAsync(this.resultPath, this.template(this.prepareTemplateData())); }) .then(() => { diff --git a/src/Site.js b/src/Site.js index daaad1d9de..5e74bcb789 100644 --- a/src/Site.js +++ b/src/Site.js @@ -338,8 +338,12 @@ Site.prototype.createPage = function (config) { path.join(this.siteAssetsDestPath, 'css', 'github.min.css')), markbind: path.relative(path.dirname(resultPath), path.join(this.siteAssetsDestPath, 'css', 'markbind.css')), + pageNavCss: path.relative(path.dirname(resultPath), + path.join(this.siteAssetsDestPath, 'css', 'page-nav.css')), siteNavCss: path.relative(path.dirname(resultPath), path.join(this.siteAssetsDestPath, 'css', 'site-nav.css')), + bootstrapUtilityJs: path.relative(path.dirname(resultPath), + path.join(this.siteAssetsDestPath, 'js', 'bootstrap-utility.min.js')), bootstrapVueJs: path.relative(path.dirname(resultPath), path.join(this.siteAssetsDestPath, 'js', 'bootstrap-vue.min.js')), polyfillJs: path.relative(path.dirname(resultPath), diff --git a/src/template/page.ejs b/src/template/page.ejs index 2ce2b536c7..4befdcc170 100644 --- a/src/template/page.ejs +++ b/src/template/page.ejs @@ -14,16 +14,18 @@ <% if (siteNav) { %><% } %> + <% if (pageNav) { %><% } %> <%- headFileBottomContent %> <% if (faviconUrl) { %><% } %> - +
<%- content %>
+ + @@ -27,9 +28,92 @@ - +
-
+ + +
@@ -311,6 +399,7 @@

Heading in footer should not be + + + + - +