diff --git a/docs/userGuide/contentAuthoring.md b/docs/userGuide/contentAuthoring.md index 2be73223d3..52b8bde1e0 100644 --- a/docs/userGuide/contentAuthoring.md +++ b/docs/userGuide/contentAuthoring.md @@ -607,4 +607,50 @@ MarkBind automatically generates heading anchors for your page. When the reader hovers over a heading in your page, a small anchor icon will become visible next to the heading. Clicking this icon will redirect the page to that heading, producing the desired URL in the URL bar that the reader can share with someone else. Try it with the headings on this page! - \ No newline at end of file +### Layouts + +A layout is a set of styles that can be applied to a page, or glob of pages. Layouts allow you to quickly apply styles to a batch of pages at once. It consists of the following files: + +- `footer.md` : See [Using Footers](#using-footers) +- `head.md` : See [Inserting content into a page's head element](#inserting-content-into-a-pages-head-element) +- `navigation.md` : See [Site Navigation](#site-navigation) +- `styles.css` : Contains custom styles +- `scripts.js` : Contains custom javascript + +These files will be automatically appended to a page upon generation. + +Layouts can be found in `_markbind/layouts`. Markbind will generate a default layout with blank files in `_markbind/layouts/default`. The default layout is automatically applied to every single page. + +To make a new layout, simply copy and rename the `default` folder (e.g. `_markbind/layouts/myLayout`) and edit the files within. + +A page can be assigned a layout in two ways: + +- Using `site.json` +```js +// Layout A will be applied to all index.md files +{ + "glob": "**/index.md", + "layout": "LayoutA" +}, + +// Layout B will be applied to index2.md +{ + "src": "index2.md", + "layout": "LayoutB" +}, + +// No Layout - default layout will be applied +{ + "src": "index3.md" +}, +``` +- Using `` +```html + + layout: layoutA + head: myHead.md + +``` + +Note that the layout specified in the `` takes precedence over the one specified in `site.json`, and any files specified in `frontMatter` will take precedence over layout files (`myHead.md` will be used instead of the one in `layoutA`, in this case). + diff --git a/lib/Page.js b/lib/Page.js index 997b238c3b..2eea0c64cf 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -14,6 +14,11 @@ const md = require('./markbind/lib/markdown-it'); const FOOTERS_FOLDER_PATH = '_markbind/footers'; const HEAD_FOLDER_PATH = '_markbind/head'; +const LAYOUT_DEFAULT_NAME = 'default'; +const LAYOUT_FOLDER_PATH = '_markbind/layouts'; +const LAYOUT_FOOTER = 'footer.md'; +const LAYOUT_HEAD = 'head.md'; +const LAYOUT_NAVIGATION = 'navigation.md'; const NAVIGATION_FOLDER_PATH = '_markbind/navigation'; const CONTENT_WRAPPER_ID = 'content-wrapper'; @@ -47,6 +52,8 @@ function Page(pageConfig) { this.baseUrlMap = pageConfig.baseUrlMap; this.content = pageConfig.content || ''; this.faviconUrl = pageConfig.faviconUrl; + this.layout = pageConfig.layout; + this.layoutsAssetPath = pageConfig.layoutsAssetPath; this.rootPath = pageConfig.rootPath; this.searchable = pageConfig.searchable; this.src = pageConfig.src; @@ -365,11 +372,14 @@ Page.prototype.collectFrontMatter = function (includedPage) { this.frontMatter.src = this.src; // Title specified in site.json will override title specified in front matter this.frontMatter.title = (this.title || this.frontMatter.title || ''); + // Layout specified in front matter will override layout specified in site.json + this.frontMatter.layout = (this.frontMatter.layout || this.layout || ''); } else { // Page is addressable but no front matter specified this.frontMatter = { src: this.src, title: this.title || '', + layout: LAYOUT_DEFAULT_NAME, }; } this.title = this.frontMatter.title; @@ -392,11 +402,17 @@ Page.prototype.removeFrontMatter = function (includedPage) { */ Page.prototype.insertFooter = function (pageData) { const { footer } = this.frontMatter; - if (!footer) { + let footerFile; + if (footer) { + footerFile = path.join(FOOTERS_FOLDER_PATH, footer); + } else { + footerFile = path.join(LAYOUT_FOLDER_PATH, this.frontMatter.layout, LAYOUT_FOOTER); + } + const footerPath = path.join(this.rootPath, footerFile); + if (!fs.existsSync(footerPath)) { return pageData; } // Retrieve Markdown file contents - const footerPath = path.join(this.rootPath, FOOTERS_FOLDER_PATH, footer); const footerContent = fs.readFileSync(footerPath, 'utf8'); // Set footer file as an includedFile this.includedFiles[footerPath] = true; @@ -413,12 +429,21 @@ Page.prototype.insertFooter = function (pageData) { */ Page.prototype.insertSiteNav = function (pageData) { const { siteNav } = this.frontMatter; - if (!siteNav) { - return pageData; + let siteNavFile; + if (siteNav) { + siteNavFile = path.join(NAVIGATION_FOLDER_PATH, siteNav); + } else { + siteNavFile = path.join(LAYOUT_FOLDER_PATH, this.frontMatter.layout, LAYOUT_NAVIGATION); } // Retrieve Markdown file contents - const siteNavPath = path.join(this.rootPath, NAVIGATION_FOLDER_PATH, siteNav); + const siteNavPath = path.join(this.rootPath, siteNavFile); + if (!fs.existsSync(siteNavPath)) { + return pageData; + } const siteNavContent = fs.readFileSync(siteNavPath, 'utf8'); + if (siteNavContent === '') { + return pageData; + } // Set siteNav file as an includedFile this.includedFiles[siteNavPath] = true; // Map variables @@ -447,14 +472,19 @@ Page.prototype.insertSiteNav = function (pageData) { Page.prototype.collectHeadFiles = function (baseUrl, hostBaseUrl) { const { head } = this.frontMatter; - if (!head) { - return; - } - const headFiles = head.replace(/, */g, ',').split(','); + let headFiles; const collectedTopContent = []; const collectedBottomContent = []; + if (head) { + headFiles = head.replace(/, */g, ',').split(',').map(headFile => path.join(HEAD_FOLDER_PATH, headFile)); + } else { + headFiles = [path.join(LAYOUT_FOLDER_PATH, this.frontMatter.layout, LAYOUT_HEAD)]; + } headFiles.forEach((headFile) => { - const headFilePath = path.join(this.rootPath, HEAD_FOLDER_PATH, headFile); + const headFilePath = path.join(this.rootPath, headFile); + if (!fs.existsSync(headFilePath)) { + return; + } const headFileContent = fs.readFileSync(headFilePath, 'utf8'); // Set head file as an includedFile this.includedFiles[headFilePath] = true; @@ -513,6 +543,7 @@ Page.prototype.generate = function (builtFiles) { const baseUrl = newBaseUrl ? `${this.baseUrl}/${newBaseUrl}` : this.baseUrl; const hostBaseUrl = this.baseUrl; + this.addLayoutFiles(); this.collectHeadFiles(baseUrl, hostBaseUrl); this.content = nunjucks.renderString(this.content, { baseUrl, hostBaseUrl }); return fs.outputFileAsync(this.resultPath, this.template(this.prepareTemplateData())); @@ -537,6 +568,14 @@ Page.prototype.generate = function (builtFiles) { }); }; +/** + * Adds linked layout files to page assets + */ +Page.prototype.addLayoutFiles = function () { + this.asset.layoutScript = path.join(this.layoutsAssetPath, this.frontMatter.layout, 'scripts.js'); + this.asset.layoutStyle = path.join(this.layoutsAssetPath, this.frontMatter.layout, 'styles.css'); +}; + /** * Pre-render an external dynamic dependency * Does not pre-render if file is already pre-rendered by another page during site generation diff --git a/lib/Site.js b/lib/Site.js index 2a963200de..f18a1c1b40 100644 --- a/lib/Site.js +++ b/lib/Site.js @@ -42,12 +42,17 @@ const PAGE_TEMPLATE_NAME = 'page.ejs'; const SITE_CONFIG_NAME = 'site.json'; const SITE_DATA_NAME = 'siteData.json'; const SITE_NAV_PATH = '_markbind/navigation/site-nav.md'; +const LAYOUT_DEFAULT_NAME = 'default'; +const LAYOUT_FILES = ['navigation.md', 'head.md', 'footer.md', 'styles.css', 'scripts.js']; +const LAYOUT_FOLDER_PATH = '_markbind/layouts'; +const LAYOUT_SITE_FOLDER_NAME = 'layouts'; const USER_VARIABLES_PATH = '_markbind/variables.md'; const SITE_CONFIG_DEFAULT = { baseUrl: '', titlePrefix: '', ignore: [ + '_markbind/layouts/*', '_markbind/logs/*', '_site/*', 'site.json', @@ -176,6 +181,8 @@ Site.initSite = function (rootPath) { const headFolderPath = path.join(rootPath, HEAD_FOLDER_PATH); const indexPath = path.join(rootPath, INDEX_MARKDOWN_FILE); const siteNavPath = path.join(rootPath, SITE_NAV_PATH); + const siteLayoutPath = path.join(rootPath, LAYOUT_FOLDER_PATH); + const siteLayoutDefaultPath = path.join(siteLayoutPath, LAYOUT_DEFAULT_NAME); const userDefinedVariablesPath = path.join(rootPath, USER_VARIABLES_PATH); // TODO: log the generate info return new Promise((resolve, reject) => { @@ -228,6 +235,31 @@ Site.initSite = function (rootPath) { } return fs.outputFileAsync(siteNavPath, SITE_NAV_DEFAULT); }) + .then(() => fs.accessAsync(siteLayoutPath)) + .catch(() => { + if (fs.existsSync(siteLayoutPath)) { + return Promise.resolve(); + } + return fs.mkdirp(siteLayoutPath); + }) + .then(() => fs.accessAsync(siteLayoutDefaultPath)) + .catch(() => { + if (fs.existsSync(siteLayoutDefaultPath)) { + return Promise.resolve(); + } + return fs.mkdirp(siteLayoutDefaultPath); + }) + .then(() => { + LAYOUT_FILES.forEach((layoutFile) => { + const layoutFilePath = path.join(siteLayoutDefaultPath, layoutFile); + fs.accessAsync(layoutFilePath).catch(() => { + if (fs.existsSync(layoutFilePath)) { + return Promise.resolve(); + } + return fs.outputFileAsync(layoutFilePath, ''); + }); + }); + }) .then(resolve) .catch(reject); }); @@ -274,6 +306,9 @@ Site.prototype.createPage = function (config) { rootPath: this.rootPath, searchable: config.searchable, src: config.pageSrc, + layoutsAssetPath: path.relative(path.dirname(resultPath), + path.join(this.siteAssetsDestPath, LAYOUT_SITE_FOLDER_NAME)), + layout: config.layout, title: config.title || '', titlePrefix: this.siteConfig.titlePrefix, headingIndexingLevel: this.siteConfig.headingIndexingLevel || HEADING_INDEXING_LEVEL_DEFAULT, @@ -323,7 +358,11 @@ Site.prototype.collectAddressablePages = function () { directories: false, globs: [addressableGlob.glob], ignore: [BOILERPLATE_FOLDER_NAME], - }).map(globPath => ({ src: globPath, searchable: addressableGlob.searchable }))), []); + }).map(globPath => ({ + src: globPath, + searchable: addressableGlob.searchable, + layout: addressableGlob.layout, + }))), []); // Add pages collected by walkSync without duplication this.addressablePages = _.unionWith(this.addressablePages, globPaths, ((pageA, pageB) => pageA.src === pageB.src)); @@ -412,6 +451,7 @@ Site.prototype.generate = function (baseUrl) { .then(() => this.buildAssets()) .then(() => this.buildSourceFiles()) .then(() => this.copyMarkBindAsset()) + .then(() => this.copyLayouts()) .then(() => this.writeSiteData()) .then(() => { const endTime = new Date(); @@ -549,6 +589,7 @@ Site.prototype.generatePages = function () { faviconUrl, pageSrc: page.src, title: page.title, + layout: page.layout || LAYOUT_DEFAULT_NAME, searchable: page.searchable !== 'no', })); const progressBar = new ProgressBar(`[:bar] :current / ${this.pages.length} pages built`, @@ -619,6 +660,22 @@ Site.prototype.copyMarkBindAsset = function () { }); }; +/** + * Copies layouts to the assets folder + */ +Site.prototype.copyLayouts = function () { + const siteLayoutPath = path.join(this.rootPath, LAYOUT_FOLDER_PATH); + const layoutsDestPath = path.join(this.siteAssetsDestPath, LAYOUT_SITE_FOLDER_NAME); + if (!fs.existsSync(siteLayoutPath)) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + fs.copyAsync(siteLayoutPath, layoutsDestPath) + .then(resolve) + .catch(reject); + }); +}; + /** * Writes the site data to a file */ diff --git a/lib/template/page.ejs b/lib/template/page.ejs index 1e3f6480f2..1593eecb94 100644 --- a/lib/template/page.ejs +++ b/lib/template/page.ejs @@ -12,6 +12,7 @@ + <% if (siteNav) { %><% } %> <%- headFileBottomContent %> <% if (faviconUrl) { %><% } %> @@ -29,4 +30,5 @@ const baseUrl = '<%- baseUrl %>' + diff --git a/test/test_site/_markbind/head/overwriteLayoutHead.md b/test/test_site/_markbind/head/overwriteLayoutHead.md new file mode 100644 index 0000000000..7dec534e4c --- /dev/null +++ b/test/test_site/_markbind/head/overwriteLayoutHead.md @@ -0,0 +1 @@ + diff --git a/test/test_site/_markbind/layouts/default/footer.md b/test/test_site/_markbind/layouts/default/footer.md new file mode 100644 index 0000000000..459abad939 --- /dev/null +++ b/test/test_site/_markbind/layouts/default/footer.md @@ -0,0 +1,5 @@ +
+
+ Default footer +
+
diff --git a/test/test_site/_markbind/layouts/default/head.md b/test/test_site/_markbind/layouts/default/head.md new file mode 100644 index 0000000000..71c2cb42c3 --- /dev/null +++ b/test/test_site/_markbind/layouts/default/head.md @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/test/test_site/_markbind/layouts/default/navigation.md b/test/test_site/_markbind/layouts/default/navigation.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/test_site/_markbind/layouts/default/scripts.js b/test/test_site/_markbind/layouts/default/scripts.js new file mode 100644 index 0000000000..104b74301f --- /dev/null +++ b/test/test_site/_markbind/layouts/default/scripts.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-console +console.info('Default script'); diff --git a/test/test_site/_markbind/layouts/default/styles.css b/test/test_site/_markbind/layouts/default/styles.css new file mode 100644 index 0000000000..513ac0494f --- /dev/null +++ b/test/test_site/_markbind/layouts/default/styles.css @@ -0,0 +1,3 @@ +h1 { + color: red; +} diff --git a/test/test_site/_markbind/layouts/testLayout/footer.md b/test/test_site/_markbind/layouts/testLayout/footer.md new file mode 100644 index 0000000000..1742d71366 --- /dev/null +++ b/test/test_site/_markbind/layouts/testLayout/footer.md @@ -0,0 +1,5 @@ +
+
+ Layout footer +
+
diff --git a/test/test_site/_markbind/layouts/testLayout/head.md b/test/test_site/_markbind/layouts/testLayout/head.md new file mode 100644 index 0000000000..1935a3c262 --- /dev/null +++ b/test/test_site/_markbind/layouts/testLayout/head.md @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/test/test_site/_markbind/layouts/testLayout/navigation.md b/test/test_site/_markbind/layouts/testLayout/navigation.md new file mode 100644 index 0000000000..258ebfbe49 --- /dev/null +++ b/test/test_site/_markbind/layouts/testLayout/navigation.md @@ -0,0 +1,3 @@ + +* [Layout Nav] + diff --git a/test/test_site/_markbind/layouts/testLayout/scripts.js b/test/test_site/_markbind/layouts/testLayout/scripts.js new file mode 100644 index 0000000000..cac1bd4265 --- /dev/null +++ b/test/test_site/_markbind/layouts/testLayout/scripts.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-console +console.info('Layout script'); diff --git a/test/test_site/_markbind/layouts/testLayout/styles.css b/test/test_site/_markbind/layouts/testLayout/styles.css new file mode 100644 index 0000000000..095646db96 --- /dev/null +++ b/test/test_site/_markbind/layouts/testLayout/styles.css @@ -0,0 +1,3 @@ +h1 { + color: blue; +} diff --git a/test/test_site/expected/bugs/index.html b/test/test_site/expected/bugs/index.html index 3afa0d69a0..dee1f89ab6 100644 --- a/test/test_site/expected/bugs/index.html +++ b/test/test_site/expected/bugs/index.html @@ -1,7 +1,7 @@ - + @@ -12,8 +12,9 @@ + - + @@ -72,6 +73,12 @@