├── .editorconfig ├── .eslintrc ├── .gitignore ├── .stylelintrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── images ├── color.png ├── component.png ├── font.png ├── guides.png └── icon.png ├── index.js ├── lib ├── demos.js ├── extensions │ ├── fonts.js │ ├── icons.js │ ├── index.js │ └── swatches.js ├── guide.js ├── markdown.js ├── pages.js ├── search.js ├── templates │ ├── font.js │ ├── icon.js │ ├── index.js │ └── swatch.js └── utils.js ├── package.json ├── public ├── css │ ├── always.less │ └── theme.less ├── images │ └── space.jpg └── js │ └── guides.js ├── test ├── docs │ └── README.md ├── extensions.js ├── index.js ├── markdown.js ├── pages.js ├── templates.js └── utils.js └── views ├── demo.html ├── guide.html ├── header.html ├── macros ├── example.html ├── navigation.html └── section.html └── search.html /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["apostrophe"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | package-lock.json 4 | npm-debug.log 5 | 6 | .nyc_output 7 | coverage 8 | 9 | # A special folder for generated demo html macros 10 | views/demos 11 | 12 | test/data 13 | test/locales 14 | test/public 15 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | 'plugins': [ 3 | 'stylelint-order', 4 | 'stylelint-declaration-strict-value' 5 | ], 6 | 'rules': { 7 | 'color-hex-length': 'short', 8 | 'color-named': 'never', 9 | 'color-no-invalid-hex': true, 10 | 'declaration-property-unit-whitelist': { 'line-height': [] }, 11 | 'font-family-no-duplicate-names': true, 12 | 'number-leading-zero': 'always', 13 | 'number-max-precision': 2, 14 | 'number-no-trailing-zeros': true, 15 | 'string-no-newline': true, 16 | 'length-zero-no-unit': true, 17 | 'unit-case': 'lower', 18 | 'unit-no-unknown': true, 19 | 'value-keyword-case': 'lower', 20 | 'value-list-comma-newline-after': 'always-multi-line', 21 | 'value-list-comma-space-after': 'always-single-line', 22 | 'value-list-comma-space-before': 'never', 23 | 'value-list-max-empty-lines': 0, 24 | 'shorthand-property-no-redundant-values': true, 25 | 'property-case': 'lower', 26 | 'property-no-unknown': true, 27 | 'property-no-vendor-prefix': true, 28 | 'declaration-no-important': true, 29 | 'declaration-colon-space-after': 'always-single-line', 30 | 'declaration-colon-space-before': 'never', 31 | 'declaration-block-no-duplicate-properties': true, 32 | 'declaration-block-no-shorthand-property-overrides': true, 33 | 'declaration-block-semicolon-newline-after': 'always', 34 | 'declaration-block-semicolon-newline-before': 'never-multi-line', 35 | 'declaration-block-single-line-max-declarations': 1, 36 | 'declaration-block-trailing-semicolon': 'always', 37 | 'block-closing-brace-empty-line-before': 'never', 38 | 'block-closing-brace-newline-after': [ 39 | 'always', 40 | { 'ignoreAtRules': ['if', 'else'] } 41 | ], 42 | 'block-closing-brace-newline-before': 'always-multi-line', 43 | 'block-no-empty': true, 44 | 'indentation': 2, 45 | 'max-empty-lines': 1, 46 | 'max-nesting-depth': 3, 47 | 'selector-list-comma-newline-after': 'always-multi-line', 48 | 'string-quotes': 'single', 49 | 'time-min-milliseconds': 200, 50 | 'order/order': [ 51 | 'declarations', 52 | 'rules' 53 | ], 54 | 'order/properties-order': [ 55 | 'z-index', 56 | 'position', 57 | 'top', 58 | 'right', 59 | 'bottom', 60 | 'left', 61 | 'display', 62 | 'overflow', 63 | 'width', 64 | 'height', 65 | 'margin', 66 | 'padding', 67 | 'border', 68 | 'color', 69 | 'background', 70 | 'font-family', 71 | 'font-size', 72 | 'text-align' 73 | ], 74 | 'scale-unlimited/declaration-strict-value': [ 75 | [ 76 | '/color/', 77 | 'font', 78 | 'font-family', 79 | 'font-size', 80 | 'z-index' 81 | ], 82 | { 83 | ignoreKeywords: [ 84 | 'currentColor', 85 | 'inherit', 86 | 'initial', 87 | 'transparent', 88 | 'auto' 89 | ] 90 | } 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.1 (2023-03-06) 4 | 5 | - Removes `apostrophe` as a peer dependency. 6 | 7 | ## 1.2.0 (2021-03-24) 8 | 9 | - Add options for permission settings and permission error messages. Thanks [Nora Granahan](https://github.com/ngranahan) of P'unk Avenue 10 | 11 | ## 1.1.0 (2020-12-02) 12 | 13 | - Update navigation items to support a site prefix. 14 | 15 | ## 1.0.1 (2020-09-17) 16 | 17 | - Switch LESS import to a link to fix a LESS compile error when assets are minified. 18 | - Remove ES6 features that Uglify was breaking on. 19 | - Set a base font size for the guide so that project level CSS doesn't affect the text sizing of the guide. 20 | 21 | ## 1.0.0 (2020-07-29) 22 | 23 | Initial Release 24 | 25 | [Unreleased]: https://github.com/apostrophecms/apostrophe-guides/compare/1.1.0...HEAD 26 | [1.1.0]: https://github.com/apostrophecms/apostrophe-guides/compare/1.0.0...1.1.0 27 | [1.0.0]: https://github.com/apostrophecms/apostrophe-guides/releases/tag/1.0.0 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Apostrophe Technologies, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apostrophe Guides 2 | 3 | > Build guides to document your Apostrophe site. 4 | 5 | 1. [Why](#why) 6 | 1. [Getting Started](#getting-started) 7 | 1. [Options](#options) 8 | 1. [Writing Documentation](#writing-documentation) 9 | 1. [Adding Images](#adding-images) 10 | 1. [Documenting Assets](#documenting-assets) 11 | 1. [Colors](#colors) 12 | 1. [Fonts](#fonts) 13 | 1. [Icons](#icons) 14 | 1. [Components](#components) 15 | 1. [Customizing your Guide](#customizing-your-guide) 16 | 17 | ![Apostrophe Guides Example](images/guides.png) 18 | 19 | ## Why 20 | 21 | So, why would you use this? Maybe you're developing an Apostrophe site and you want to provide documentation for other developers. Maybe you want to document your Design System to better collaborate with your team. Maybe you want to create a User Guide so editors have a reference on how to use the site. Maybe you want to do all three. 22 | 23 | This module allows you to write your documentation in static Markdown and serves them right alongside your Apostrophe site. And because this is an Apostrophe module, you can document your components same way you'd use them in you templates and they'll automatically be kept up to date with your living code. 24 | 25 | Create one guide or individual guides for each of your audiences. It's up to you. 26 | 27 | ## Getting Started 28 | 29 | ```sh 30 | npm install apostrophe-guides 31 | ``` 32 | 33 | After the module has installed, enable it in your `app.js`; 34 | 35 | ```js 36 | modules: { 37 | "apostrophe-guides": {} 38 | } 39 | ``` 40 | 41 | ## Options 42 | 43 | ### title 44 | 45 | - Required: `N` 46 | - Default: `Guide` 47 | 48 | The title of your guide. 49 | 50 | ### sections 51 | 52 | - Required: `Y` 53 | 54 | Your guide content. 55 | 56 | Each section is comprised of a `name` and an array of `docs`. Filenames and [globs](https://www.npmjs.com/package/glob#glob-primer) are allowed in docs. If the `.md` extension is omitted from the glob, we'll make sure only to look for Markdown files in the specified directory. 57 | 58 | Example: 59 | 60 | ```js 61 | 'apostrophe-guides': { 62 | sections: [ 63 | { 64 | name: "Overview", 65 | docs: [`${__dirname}/README.md`, `${__dirname}/Overview.md`] // Files 66 | }, 67 | { 68 | name: "Components", 69 | docs: ["lib/modules/components/views/**/*.md"] // A glob 70 | } 71 | ] 72 | } 73 | ``` 74 | 75 | ### path 76 | 77 | - Required: `N` 78 | - Default: `guide` 79 | 80 | The URL of your guide. If your site is running on `http://localhost:3000`, by default your guide will be available on `http://localhost:3000/guide`. If you set this option set to `styleguide`, your guide would be `http://localhost:3000/styleguide`. 81 | 82 | ### demoBodyClass 83 | 84 | - Required: `N` 85 | 86 | If you are including demos of your components in your guide and are using a css body class to scope your styles, this will add the specified class to the `body` element of `iframe`-d demo sandbox. More on those [later](#components). 87 | 88 | ### stylesheets 89 | 90 | - Required: `N` 91 | 92 | An array of stylesheets that should be included in your demo sandbox. 93 | 94 | ### scripts 95 | 96 | - Required: `N` 97 | 98 | An array of scripts that should be included in your demo sandbox. 99 | 100 | ### footer 101 | 102 | - Required: `N` 103 | 104 | Add text to the footer of all documentation pages. Useful if you'd like to provide contact information. This may be specified as a html string in the config as a path to a Nunjucks template. 105 | 106 | As a string: 107 | 108 | ``` 109 | 'apostrophe-guides': { 110 | ... 111 | footer: "Contact me for more information" 112 | } 113 | 114 | ``` 115 | 116 | As a template: 117 | 118 | ``` 119 | 'apostrophe-guides': { 120 | ... 121 | footer: "apostrophe-guides:guideFooter.html" 122 | } 123 | 124 | ``` 125 | 126 | ### permission 127 | 128 | - Required: `N` 129 | - Default: `guest` 130 | 131 | ### permissionErrorMessage 132 | 133 | - Required: `N` 134 | - Default: `You must be logged in to view this page` 135 | 136 | ## Writing Documentation 137 | 138 | All documentation is written in Markdown with some added bonuses. Check [Markdown Guide](https://www.markdownguide.org/extended-syntax/) for a reference on how to write Markdown. In addition to the basics, you may also include [tables](https://www.markdownguide.org/extended-syntax/#tables) and [code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks). 139 | 140 | ### Adding Images 141 | 142 | Use the standard Markdown syntax to add an image to your page. 143 | 144 | ``` 145 | ![My Image](/guides/demo.png) 146 | ``` 147 | 148 | With the above example, your images would be placed in the `/public/guides` folder of your project. 149 | 150 | ### Documenting Assets 151 | 152 | We've also created a few Markdown-like shortcuts to allow you to visualize some special assets like Color, Fonts, Icons, and Components. 153 | 154 | #### Colors 155 | 156 | To document a color, use the `swatch` block. 157 | 158 | ``` 159 | [swatch name="$red" hex="#F00"] 160 | ``` 161 | 162 | You may specifiy the color value as `hex` or `rgb` but you don't need to provide both. 163 | 164 | An example swatch: 165 | 166 | ![Apostrophe Guides Color](images/color.png) 167 | 168 | #### Fonts 169 | 170 | To add examples of the typefaces in your documentation, use the `font` block. 171 | 172 | ``` 173 | [font name="Helvetica" family="Helvetica, san-serif" weight="bold"] 174 | ``` 175 | 176 | If a `name` attribute is not provided the `family` will be used as the description of the typeface. You may also optionally include, a `weight`, `size`, and desired `text`. If no `text` is provided `text` will default to the `family`; 177 | 178 | An example font: 179 | 180 | ![Apostrophe Guides Font](images/font.png) 181 | 182 | _Note:_ In order to preview custom fonts, you might need to add a link to your font definition. 183 | 184 | Ex: 185 | 186 | ```html 187 | 188 | 189 | # Fonts 190 | 191 | [font family="Karla"] 192 | 193 | ``` 194 | 195 | #### Icons 196 | 197 | To add examples of your icons, use the `icon` block. 198 | 199 | ``` 200 | [icon name="Cart" src="/images/cart.svg"] 201 | ``` 202 | 203 | Minimally provide a path to the `src` of you icon. This currently supports any image that can be rendered in an `img` tag. 204 | 205 | An example icon: 206 | 207 | ![Apostrophe Guides Icon](images/icon.png) 208 | 209 | #### Components 210 | 211 | To document your components, you can use a slightly modified version of the standard Markdown `code` block. 212 | 213 | ````md 214 | ```html button-simple.html 215 | {% import "components:button/button.html" as button %} 216 | {{ button.render({ text: 'Click' }) }} 217 | ``` 218 | ```` 219 | 220 | This will do two things on your guide page. First, it will render a code block that shows how another developer exactly how they would implement your component. Secondly, it generates a html file that will be rendered in an iframe on the page. This provides a live working example of your component in a sandboxed environment right in your documentation. Just be sure to include the `.html` extension on your example file name. 221 | 222 | Your demo code could be a Nunjucks `macro`, a Nunjucks `include`, or even plain old static html. Be sure to configure the `stylesheets` and `scripts` options with the assets necessary to render your components. Additionally if you are scoping your css to a class on the body element, you can use the `demoBodyClass` option to include your class name on all component examples. 223 | 224 | An example of a component guide page: 225 | 226 | ![Apostrophe Guides Component](images/component.png) 227 | 228 | _Note:_ Demos are rendered at the end of your documentation page by default. This is currently not configurable. 229 | 230 | ## Customizing Your Guide 231 | 232 | This module follows the standard Apostrophe modular system so that means **everything** can be customized to your needs. If you're ok with the layout but maybe just want to maybe brand your guide, the styles can be overwritten by creating a local `always.css` file in your `apostrophe-guides/public/css` directory. A custom class is also applied to a guide based on the `title` option. This allows you to have multiple guides with multiple themes in a single apostrophe instance. 233 | 234 | Additionally, you can modify any of the guide templates by coping the template you wish to modify from `node_modules/apostrophe-guides/views` to your project's `lib/modules/apostrophe-guides/views` directory. 235 | 236 | Just note that you only need to copy the files you wish to modify to your local `apostrophe-guides` directory. You do not need to copy them all. 237 | 238 | Happy documenting! 239 | -------------------------------------------------------------------------------- /images/color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-guides/27be69898d5b0bdbbe331eb22733a793234e479a/images/color.png -------------------------------------------------------------------------------- /images/component.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-guides/27be69898d5b0bdbbe331eb22733a793234e479a/images/component.png -------------------------------------------------------------------------------- /images/font.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-guides/27be69898d5b0bdbbe331eb22733a793234e479a/images/font.png -------------------------------------------------------------------------------- /images/guides.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-guides/27be69898d5b0bdbbe331eb22733a793234e479a/images/guides.png -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-guides/27be69898d5b0bdbbe331eb22733a793234e479a/images/icon.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'apostrophe-guides', 3 | label: 'Guide', 4 | extend: 'apostrophe-module', 5 | 6 | afterConstruct: self => { 7 | self.addRoutes(); 8 | self.apos.on('csrfExceptions', self.addCsrfException); 9 | self.pushAsset('script', 'guides', { when: 'always' }); 10 | }, 11 | 12 | construct: (self, options) => { 13 | self.addCsrfException = exceptions => { 14 | exceptions.push(`/${options.path}/search`); 15 | }; 16 | 17 | require('./lib/guide')(self, options); 18 | 19 | self.pushAsset('stylesheet', 'always', { when: 'always' }); 20 | 21 | require('./lib/search')(self, options); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /lib/demos.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const { logError } = require('./utils'); 5 | 6 | // Only match html code blocks with a filename. 7 | const exampleRegex = /^(```html [\s\S]*.html)[\s\S]*(```\n)$/gm; 8 | const fileNameRegex = /(?<= ).*(.html)\n/g; 9 | 10 | const DEST = path.resolve(`${__dirname}/`, '../views/demos'); 11 | 12 | const findDemos = (doc, url) => { 13 | try { 14 | exportDemos(doc); 15 | const demos = doc.match(fileNameRegex); 16 | if (demos) { 17 | return demos.map(demo => path.join('/', url, demo.replace('\n', ''))); 18 | } else { 19 | return null; 20 | } 21 | } catch (err) { 22 | console.error(err); // eslint-disable-line no-console 23 | } 24 | }; 25 | 26 | function exportDemos(doc) { 27 | const examples = doc.match(exampleRegex); 28 | if (examples) { 29 | examples.forEach(example => { 30 | let filename = example.match(fileNameRegex); 31 | 32 | if (filename === null) { 33 | logError( 34 | `No filename found for example code. 35 | Please add a file name for: 36 | ${example} 37 | Ex: \`\`\`html demo.html` 38 | ); 39 | } else { 40 | filename = filename[0].replace('\n', ''); 41 | } 42 | 43 | if (!fs.existsSync(DEST)) { 44 | fs.mkdirSync(DEST); 45 | } 46 | saveDemo(filename, example); 47 | }); 48 | } 49 | } 50 | 51 | function saveDemo(filename, data) { 52 | const demoHTML = data 53 | .replace( 54 | `\`\`\`html ${filename}`, 55 | `{% extends 'apostrophe-guides:demo.html' %} 56 | {% block content %}` 57 | ) 58 | .replace('```', '{% endblock %}'); 59 | 60 | fs.writeFile(`${DEST}/${filename}`, demoHTML, err => { 61 | if (err) { 62 | console.error(err); // eslint-disable-line no-console 63 | } 64 | }); 65 | } 66 | 67 | module.exports = { findDemos }; 68 | -------------------------------------------------------------------------------- /lib/extensions/fonts.js: -------------------------------------------------------------------------------- 1 | const { addAttr, attrRegex } = require('../utils'); 2 | 3 | const fontRegex = /(\[font).*(\])/gm; 4 | 5 | const template = require('../templates').font; 6 | 7 | const replaceTag = str => { 8 | const fonts = str.match(fontRegex); 9 | 10 | if (!fonts) { 11 | return str; 12 | } 13 | 14 | fonts.forEach(font => { 15 | const attrs = font.match(attrRegex); 16 | const options = addAttr(attrs); 17 | 18 | str = str.replace(font, template(options)); 19 | }); 20 | 21 | return str; 22 | }; 23 | 24 | const findFonts = str => replaceTag(str); 25 | 26 | module.exports = { findFonts }; 27 | -------------------------------------------------------------------------------- /lib/extensions/icons.js: -------------------------------------------------------------------------------- 1 | const { addAttr, attrRegex } = require('../utils'); 2 | 3 | const iconRegex = /(\[icon).*(\])/gm; 4 | 5 | const template = require('../templates').icon; 6 | 7 | const replaceTag = str => { 8 | const icons = str.match(iconRegex); 9 | 10 | if (!icons) { 11 | return str; 12 | } 13 | 14 | icons.forEach(font => { 15 | const attrs = font.match(attrRegex); 16 | const options = addAttr(attrs); 17 | 18 | str = str.replace(font, template(options)); 19 | }); 20 | 21 | return str; 22 | }; 23 | 24 | const findIcons = str => replaceTag(str); 25 | 26 | module.exports = { findIcons }; 27 | -------------------------------------------------------------------------------- /lib/extensions/index.js: -------------------------------------------------------------------------------- 1 | const fonts = require('./fonts'); 2 | const icons = require('./icons'); 3 | const swatches = require('./swatches'); 4 | 5 | module.exports = { 6 | ...fonts, 7 | ...icons, 8 | ...swatches 9 | }; 10 | -------------------------------------------------------------------------------- /lib/extensions/swatches.js: -------------------------------------------------------------------------------- 1 | const convert = require('color-convert'); 2 | 3 | const { 4 | addAttr, attrRegex, logError 5 | } = require('../utils'); 6 | 7 | const template = require('../templates').swatch; 8 | 9 | const swatchRegex = /(\[swatch).*(\])/gm; 10 | 11 | const replaceTag = str => { 12 | const swatches = str.match(swatchRegex); 13 | 14 | if (!swatches) { 15 | return str; 16 | } 17 | 18 | swatches.forEach(swatch => { 19 | const attrs = swatch.match(attrRegex); 20 | const options = addAttr(attrs); 21 | 22 | if (!options.hex && !options.rgb) { 23 | logError(`Oops! No color values found for ${options.name}. 24 | Please add either a hex or an rgb value.`); 25 | } 26 | 27 | if (!options.hex) { 28 | const digits = /(\d{1,3})/g; 29 | const values = options.rgb.match(digits); 30 | options.hex = `#${convert.rgb.hex(values)}`; 31 | } 32 | 33 | if (!options.rgb) { 34 | options.rgb = `rgb(${convert.hex.rgb(options.hex)})`; 35 | } 36 | 37 | str = str.replace(swatch, template(options)); 38 | }); 39 | 40 | return str; 41 | }; 42 | 43 | const findSwatches = str => replaceTag(str); 44 | 45 | module.exports = { findSwatches }; 46 | -------------------------------------------------------------------------------- /lib/guide.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const getPages = require('./pages'); 5 | const { findDemos } = require('./demos'); 6 | const { renderDoc } = require('./markdown'); 7 | const { logError, setActive } = require('./utils'); 8 | 9 | const TEMPLATE = 'guide'; 10 | 11 | module.exports = (self, options) => { 12 | if (!options.sections) { 13 | logError( 14 | `Oops! Looks like you forgot to include any docs!. 15 | In your module, be sure to include a sections key.` 16 | ); 17 | } 18 | 19 | const { NODE_ENV } = process.env; 20 | 21 | options.path = options.path || 'guide'; 22 | options.public = options.public || false; 23 | options.permission = options.permission || 'guest'; 24 | options.permissionErrorMessage = options.permissionErrorMessage || 'You must be logged in to view this page'; 25 | 26 | const stylesheets = options.stylesheets; 27 | const scripts = options.scripts; 28 | 29 | const footer = options.footer || null; 30 | let footerTemplate = null; 31 | if (footer && footer.endsWith('.html')) { 32 | footerTemplate = footer; 33 | } 34 | 35 | self.data = { 36 | title: options.title || 'Guide', 37 | sections: getPages(self, options), 38 | path: options.path 39 | }; 40 | 41 | self.addRoutes = () => { 42 | self.apos.app.get(`/${options.path}`, (req, res) => { 43 | if (!canView(req)) { 44 | res.statusCode = 403; 45 | return res.send(options.permissionErrorMessage); 46 | } 47 | 48 | const sections = setActive( 49 | self.data.sections, 50 | self.data.sections[0].docs[0] 51 | ); 52 | 53 | const pageData = getPage(self.data.sections[0].docs[0]); 54 | 55 | return self.sendPage(req, TEMPLATE, { 56 | guide: { 57 | ...self.data, 58 | sections, 59 | page: { 60 | ...pageData, 61 | footer, 62 | footerTemplate 63 | } 64 | } 65 | }); 66 | }); 67 | 68 | self.data.sections.forEach(section => { 69 | const { docs } = section; 70 | docs.forEach(doc => addRoute(doc)); 71 | }); 72 | }; 73 | 74 | function isDev(env) { 75 | return env === 'development' || env === 'dev'; 76 | } 77 | 78 | function getPage(page) { 79 | if (isDev(NODE_ENV)) { 80 | const file = fs.readFileSync(page.filepath, 'utf8'); 81 | 82 | page.demos = findDemos(file, options.path); 83 | page.doc = renderDoc(file); 84 | 85 | return page; 86 | } 87 | return page; 88 | } 89 | 90 | function canView(req) { 91 | return ( 92 | options.public === true || 93 | (options.public === false && self.apos.permissions.can(req, options.permission)) 94 | ); 95 | } 96 | 97 | function addDemoRoutes(demos, data) { 98 | demos.forEach(demo => { 99 | self.apos.app.get(demo, (req, res) => { 100 | if (!canView(req)) { 101 | res.statusCode = 403; 102 | return res.send(options.permissionErrorMessage); 103 | } 104 | const template = path.basename(demo); 105 | return self.sendPage(req, `demos/${template}`, { ...data }); 106 | }); 107 | }); 108 | } 109 | 110 | function addRoute(page) { 111 | const { url, demos } = page; 112 | 113 | if (demos) { 114 | addDemoRoutes(demos, { 115 | demo: { 116 | demoBodyClass: options.demoBodyClass || null, 117 | stylesheets, 118 | scripts 119 | } 120 | }); 121 | } 122 | 123 | self.apos.app.get(`${url}`, (req, res) => { 124 | if (!canView(req)) { 125 | res.statusCode = 403; 126 | return res.send(options.permissionErrorMessage); 127 | } 128 | 129 | const sections = setActive(self.data.sections, page); 130 | const pageData = getPage(page); 131 | 132 | return self.sendPage(req, TEMPLATE, { 133 | guide: { 134 | ...self.data, 135 | sections, 136 | page: { 137 | ...pageData, 138 | footer, 139 | footerTemplate 140 | } 141 | } 142 | }); 143 | }); 144 | } 145 | }; 146 | -------------------------------------------------------------------------------- /lib/markdown.js: -------------------------------------------------------------------------------- 1 | const marked = require('marked'); 2 | const hljs = require('highlight.js'); 3 | 4 | const { 5 | findFonts, findIcons, findSwatches 6 | } = require('./extensions'); 7 | 8 | const highlight = (code, language) => { 9 | const validLanguage = hljs.getLanguage(language) ? language : 'plaintext'; 10 | return hljs.highlight(validLanguage, code).value; 11 | }; 12 | 13 | const renderer = { 14 | // add links to headings 15 | heading(text, level) { 16 | const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-'); 17 | 18 | return ` 19 | ${text} 20 | `; 21 | } 22 | }; 23 | 24 | const renderDoc = doc => { 25 | // Probably would be better to extend markedjs and include 26 | // findSwatches, findFonts, and findIcons 27 | let page = findSwatches(doc); 28 | page = findFonts(page); 29 | page = findIcons(page); 30 | 31 | return renderMarkdown(page); 32 | }; 33 | 34 | function renderMarkdown(doc) { 35 | const options = { 36 | highlight 37 | }; 38 | 39 | marked.setOptions(options); 40 | marked.use({ renderer }); 41 | 42 | try { 43 | return marked(doc); 44 | } catch (err) { 45 | console.error(err); // eslint-disable-line no-console 46 | } 47 | } 48 | 49 | module.exports = { renderDoc }; 50 | -------------------------------------------------------------------------------- /lib/pages.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const _ = require('lodash'); 5 | const glob = require('glob'); 6 | 7 | const { logError, stringify } = require('./utils'); 8 | 9 | const { findDemos } = require('./demos'); 10 | const { renderDoc } = require('./markdown'); 11 | 12 | const MD = '.md'; // We probably also should support `.markdown`, but maybe not now since not sure how common that is 13 | 14 | module.exports = (self, options) => { 15 | const buildSection = ({ 16 | sectionName, docs, index 17 | }) => { 18 | return docs.map(doc => { 19 | const name = path.basename(doc, MD); 20 | 21 | const data = { 22 | name: _.startCase(name), 23 | filepath: doc, 24 | url: path.join( 25 | '/', 26 | options.path, 27 | self.apos.utils.slugify(sectionName), 28 | _.kebabCase(name) 29 | ) 30 | }; 31 | 32 | const file = fs.readFileSync(doc, 'utf8'); 33 | 34 | data.demos = findDemos(file, options.path); 35 | data.doc = renderDoc(file); 36 | 37 | return data; 38 | }); 39 | }; 40 | 41 | const buildData = sections => 42 | sections.map((section, index) => { 43 | const { docs, name } = section; 44 | 45 | if (!name) { 46 | logError( 47 | `Oops! Looks like you forgot to include a name for your section. 48 | Add a name key to: 49 | ${stringify(section)}` 50 | ); 51 | } 52 | 53 | if (!docs) { 54 | logError( 55 | `Oops! Looks like you forgot to include a path to your docs. 56 | Add a docs key to: 57 | ${stringify(section)}` 58 | ); 59 | } 60 | 61 | const sectionData = { 62 | name, 63 | docs: [] 64 | }; 65 | 66 | // We only care about markdown files, so make sure that's the only file we're looking for 67 | const paths = docs.map(src => (src.endsWith(MD) ? src : `${src}${MD}`)); 68 | 69 | const globs = []; 70 | 71 | // Since we accept an array of files and globs, combine them into a single array 72 | paths.forEach(src => { 73 | const files = glob.sync(src); 74 | 75 | if (files.length === 0) { 76 | logError( 77 | `Oops! Couldn't find ${src}. 78 | Are you sure that's the correct path?` 79 | ); 80 | } 81 | 82 | globs.push(files); 83 | }); 84 | 85 | const allDocs = globs.flat(); 86 | 87 | sectionData.docs = buildSection({ 88 | sectionName: name, 89 | docs: allDocs, 90 | index 91 | }); 92 | 93 | return sectionData; 94 | }); 95 | 96 | return buildData(options.sections); 97 | }; 98 | -------------------------------------------------------------------------------- /lib/search.js: -------------------------------------------------------------------------------- 1 | const Fuse = require('fuse.js'); 2 | const sanitizeHtml = require('sanitize-html'); 3 | 4 | const { highlight } = require('./utils'); 5 | 6 | const MAX_SAFE_INTEGER = 9007199254740991; 7 | 8 | module.exports = (self, options) => { 9 | const docs = self.data.sections.map(({ docs }) => docs).flat(); 10 | 11 | const pages = docs.map(item => { 12 | const text = sanitizeHtml(item.doc, { 13 | transformTags: { 14 | h1: (tagName, attribs) => { 15 | return { 16 | text: '' 17 | }; 18 | } 19 | }, 20 | allowedTags: [], 21 | allowedAttributes: [] 22 | }) 23 | .replace(/(\n)+/g, ' ') 24 | .trim(); 25 | 26 | return { 27 | ...item, 28 | doc: text 29 | }; 30 | }); 31 | 32 | const searchOptions = { 33 | includeScore: true, 34 | includeMatches: true, 35 | threshold: 0.1, 36 | distance: MAX_SAFE_INTEGER, 37 | keys: ['name', 'doc'] 38 | }; 39 | 40 | const index = Fuse.createIndex(searchOptions.keys, pages); 41 | 42 | const collection = new Fuse(pages, searchOptions, index); 43 | 44 | self.apos.app.post(`/${options.path}/search`, (req, res) => { 45 | const { query } = req.body; 46 | 47 | const results = collection.search(query); 48 | 49 | // Find the total number of times the term was found in all the docs. 50 | const totalResults = results.reduce((total, result) => { 51 | result.matches.forEach(({ indices }) => (total += indices.length)); 52 | return total; 53 | }, 0); 54 | 55 | const highlightedResults = results.map(result => { 56 | const { item } = result; 57 | 58 | const updatedResult = {}; 59 | 60 | result.matches.forEach(({ 61 | indices, value, key 62 | }) => { 63 | return (updatedResult[key] = highlight(indices, value, query.length)); 64 | }); 65 | 66 | return { 67 | name: item.name, 68 | url: item.url, 69 | doc: item.doc, 70 | ...updatedResult 71 | }; 72 | }); 73 | 74 | const data = { 75 | query: { term: query }, 76 | guide: self.data, 77 | totalResults, 78 | totalDocuments: highlightedResults.length, 79 | results: highlightedResults 80 | }; 81 | 82 | return self.sendPage(req, self.renderer('search'), data); 83 | }); 84 | }; 85 | -------------------------------------------------------------------------------- /lib/templates/font.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const template = ({ 4 | name, family, weight, size, text 5 | }) => { 6 | const fontFamily = `font-family: ${family};`; 7 | const fontWeight = weight ? `font-weight: ${weight};` : null; 8 | const fontSize = size ? `font-size: ${size};` : null; 9 | 10 | const styles = [fontFamily, fontWeight, fontSize].join('').trim(); 11 | 12 | const txt = name || family; 13 | const _weight = weight ? _.startCase(weight) : null; 14 | const title = [txt, _weight].join(' ').trim(); 15 | 16 | return `
17 |
${text || family}
18 |
19 |

${title}

20 |
21 |
`; 22 | }; 23 | 24 | module.exports = template; 25 | -------------------------------------------------------------------------------- /lib/templates/icon.js: -------------------------------------------------------------------------------- 1 | const template = options => { 2 | const nameTmpl = ` 3 |
4 |

${options.name}

5 |
`; 6 | 7 | const meta = options.name ? nameTmpl : ''; 8 | 9 | return `
10 |
11 | 12 |
${meta} 13 |
`; 14 | }; 15 | 16 | module.exports = template; 17 | -------------------------------------------------------------------------------- /lib/templates/index.js: -------------------------------------------------------------------------------- 1 | const font = require('./font'); 2 | const icon = require('./icon'); 3 | const swatch = require('./swatch'); 4 | 5 | module.exports = { 6 | font, 7 | icon, 8 | swatch 9 | }; 10 | -------------------------------------------------------------------------------- /lib/templates/swatch.js: -------------------------------------------------------------------------------- 1 | const template = options => `
2 |
3 |
4 |

${options.name}

5 | ${options.hex} 6 | ${options.rgb} 7 |
8 |
`; 9 | 10 | module.exports = template; 11 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const stringify = str => JSON.stringify(str, null, 4); 2 | 3 | const highlight = (indices, value, length) => { 4 | const text = value.split(''); 5 | 6 | indices.forEach(positions => { 7 | // Only highlight words that match 8 | const space = positions[1] - positions[0] + 1; 9 | 10 | if (space === length) { 11 | text[positions[0]] = `${ 12 | text[positions[0]] 13 | }`; 14 | text[positions[1]] = `${text[positions[1]]}`; 15 | } 16 | }); 17 | 18 | return text.join(''); 19 | }; 20 | 21 | const attrRegex = /(\w*)="([^"]*)"/g; 22 | 23 | const addAttr = attrs => { 24 | const options = {}; 25 | attrs.forEach(attr => { 26 | const keyVal = attr.split('='); 27 | options[keyVal[0]] = keyVal[1].replace(/"/g, ''); 28 | }); 29 | return options; 30 | }; 31 | 32 | const logError = string => { 33 | console.error(`Error: 'apostrophe-guides' \n${string}\n`); // eslint-disable-line no-console 34 | }; 35 | 36 | const setActive = (sections, page) => { 37 | const updatedSections = []; 38 | 39 | sections.forEach(section => { 40 | const updated = section.docs.map(doc => 41 | doc.name === page.name 42 | ? { 43 | ...doc, 44 | active: true 45 | } 46 | : { 47 | ...doc, 48 | active: false 49 | } 50 | ); 51 | 52 | updatedSections.push({ 53 | ...section, 54 | docs: updated 55 | }); 56 | }); 57 | 58 | return updatedSections; 59 | }; 60 | 61 | module.exports = { 62 | attrRegex, 63 | addAttr, 64 | highlight, 65 | logError, 66 | setActive, 67 | stringify 68 | }; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apostrophe-guides", 3 | "version": "1.2.1", 4 | "description": "Build guides to document your Apostrophe site.", 5 | "main": "index.js", 6 | "directories": { 7 | "lib": "lib" 8 | }, 9 | "scripts": { 10 | "lint": "npm run eslint && npm run stylelint", 11 | "eslint": "eslint .", 12 | "stylelint": "stylelint 'public/**/*.less'", 13 | "test": "nyc --reporter=html npx mocha" 14 | }, 15 | "keywords": [ 16 | "apostrophecms", 17 | "apostrophe" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/apostrophecms/apostrophe-guides" 22 | }, 23 | "author": "Apostrophe Technologies", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "apostrophe": "^2.116.1", 27 | "eslint": "^7.4.0", 28 | "eslint-config-apostrophe": "^3.2.0", 29 | "eslint-config-standard": "^14.1.1", 30 | "eslint-plugin-import": "^2.22.0", 31 | "eslint-plugin-node": "^11.1.0", 32 | "eslint-plugin-promise": "^4.2.1", 33 | "eslint-plugin-standard": "^4.0.1", 34 | "husky": "^4.2.5", 35 | "mocha": "^8.0.1", 36 | "nyc": "^15.1.0", 37 | "sinon": "^9.0.2", 38 | "stylelint": "^13.6.1", 39 | "stylelint-declaration-strict-value": "^1.5.0", 40 | "stylelint-order": "^4.1.0" 41 | }, 42 | "dependencies": { 43 | "color-convert": "^2.0.1", 44 | "fuse.js": "^6.4.0", 45 | "glob": "^7.1.6", 46 | "highlight.js": "^10.1.1", 47 | "lodash": "^4.17.15", 48 | "marked": "^1.1.0", 49 | "sanitize-html": "^1.27.0" 50 | }, 51 | "husky": { 52 | "hooks": { 53 | "pre-commit": "npm run lint && npm test" 54 | } 55 | }, 56 | "nyc": { 57 | "include": [ 58 | "lib/**/*.js" 59 | ], 60 | "exclude": [ 61 | "**/node_modules/**", 62 | "**/test/**", 63 | "**/coverage/**" 64 | ], 65 | "all": true 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /public/css/always.less: -------------------------------------------------------------------------------- 1 | @import '//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.0/styles/github.min.css'; 2 | @import './theme'; 3 | 4 | @blue: #3884ff; 5 | @purple: #6616dd; 6 | 7 | @lemon-chiffon: #fffbcc; 8 | 9 | @black: #000; 10 | @shuttle-gray: #5c6b77; 11 | @gray: #aaa; 12 | @light-gray: #ddd; 13 | @white-smoke: #f5f7f9; 14 | @white: #fff; 15 | 16 | @font-family: 'Karla', sans-serif; 17 | @font-monospace: monospace; 18 | 19 | @font-size-reset: 1rem; 20 | @font-size-base: 1.6rem; 21 | 22 | @font-size-heading: 2.2rem; 23 | @font-size-subheading: 2rem; 24 | 25 | @font-size-monospace: 1.1rem; 26 | @line-height-monospace: 2rem; 27 | 28 | @font-size-typography: 5rem; 29 | 30 | @z-index-base: 0; 31 | @z-index-over: 1; 32 | @z-index-header: 10; 33 | 34 | html, 35 | body { 36 | margin: 0; 37 | padding: 0; 38 | } 39 | 40 | .apos-guide { 41 | min-height: 100vh; 42 | background: @white; 43 | font-family: @font-family; 44 | font-size: 16px; //stylelint-disable-line 45 | 46 | &-header { 47 | z-index: @z-index-header; 48 | position: sticky; 49 | top: 0; 50 | display: flex; 51 | justify-content: space-between; 52 | align-items: center; 53 | padding: 20px; 54 | color: @white; 55 | background: @header-background; 56 | font-size: @font-size-base; 57 | line-height: 1; 58 | font-weight: 400; 59 | } 60 | 61 | &-search-input { 62 | border-width: 1px; 63 | border-style: solid; 64 | border-color: @light-gray; 65 | border-radius: 4px; 66 | padding: 8px 17px; 67 | min-width: 300px; 68 | box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.11); 69 | 70 | &:focus { 71 | border-color: @blue; 72 | outline: none; 73 | } 74 | } 75 | 76 | &-search-label { 77 | position: absolute; 78 | overflow: hidden; 79 | width: 1px; 80 | height: 1px; 81 | margin: -1px; 82 | padding: 0; 83 | clip: rect(0, 0, 0, 0); 84 | border: 0; 85 | } 86 | 87 | &-title { 88 | margin: 0; 89 | padding: 0; 90 | color: @white; 91 | font-size: inherit; 92 | font-weight: 400; 93 | text-decoration: none; 94 | } 95 | 96 | &-name, 97 | &-search-query, 98 | &-swatch-name { 99 | font-weight: 600; 100 | } 101 | 102 | &-navigation { 103 | z-index: @z-index-over; 104 | position: fixed; 105 | top: 73px; 106 | bottom: 0; 107 | width: 15vw; 108 | padding: 20px 0; 109 | background-color: @white-smoke; 110 | border-right: 1px solid darken(@white-smoke, 5%); 111 | overflow-y: scroll; 112 | } 113 | 114 | &-navigation-list, 115 | &-section-navigation { 116 | list-style-type: none; 117 | padding: 0; 118 | margin-top: 0; 119 | } 120 | 121 | &-navigation-list { 122 | margin-bottom: 0; 123 | } 124 | 125 | &-section-navigation { 126 | margin-bottom: 25px; 127 | } 128 | 129 | &-section-name, 130 | &-swatch-hex, 131 | &-swatch-rgb, 132 | &-font-name, 133 | &-icon-name { 134 | color: @gray; 135 | } 136 | 137 | &-section-name { 138 | display: block; 139 | padding: 5px 25px; 140 | margin-bottom: 5px; 141 | font-size: @font-size-reset; 142 | text-transform: uppercase; 143 | letter-spacing: 0.75px; 144 | } 145 | 146 | &-section-item { 147 | padding-left: 0; 148 | line-height: 2; 149 | } 150 | 151 | &-link { 152 | display: block; 153 | padding: 0 20px; 154 | color: @blue; 155 | border-left-width: 5px; 156 | border-left-style: solid; 157 | border-left-color: transparent; 158 | text-decoration: none; 159 | 160 | &.apos-guide-is-active { 161 | border-left-color: @blue; 162 | } 163 | 164 | &:hover { 165 | color: darken(@blue, 5%); 166 | background-color: darken(@white-smoke, 5%); 167 | } 168 | } 169 | 170 | &-content { 171 | position: relative; 172 | } 173 | 174 | &-section-footer { 175 | margin-top: 50px; 176 | padding-top: 50px; 177 | border-top: 1px solid @light-gray; 178 | font-size: @font-size-reset; 179 | text-align: center; 180 | } 181 | 182 | &-heading { 183 | &:before { 184 | display: block; 185 | content: ' '; 186 | height: 100px; 187 | margin-top: -100px; 188 | visibility: hidden; 189 | } 190 | } 191 | 192 | &-docs { 193 | width: 60vw; 194 | line-height: 2; 195 | padding-top: 60px; 196 | padding-left: 5vw; 197 | padding-bottom: 60px; 198 | margin-right: 20vw; 199 | margin-left: 15vw; 200 | 201 | h1, 202 | h2, 203 | h3, 204 | h4, 205 | h5, 206 | p, 207 | table, 208 | ul, 209 | ol, 210 | pre, 211 | hr { 212 | margin-bottom: 20px; 213 | } 214 | 215 | h1, 216 | h2, 217 | h3, 218 | h4, 219 | h5 { 220 | font-weight: bold; 221 | } 222 | 223 | h1 { 224 | font-size: @font-size-heading; 225 | } 226 | 227 | h2, 228 | h3, 229 | h4, 230 | h5 { 231 | font-size: @font-size-subheading; 232 | } 233 | 234 | hr { 235 | border: 1px solid @white-smoke; 236 | } 237 | 238 | table { 239 | width: 100%; 240 | border-collapse: collapse; 241 | } 242 | 243 | thead th { 244 | color: @shuttle-gray; 245 | font-size: @font-size-reset; 246 | } 247 | 248 | th { 249 | text-align: left; 250 | } 251 | 252 | th, 253 | td { 254 | padding: 5px 10px; 255 | &:first-of-type { 256 | padding-left: 0; 257 | } 258 | &:last-of-type { 259 | padding-right: 0; 260 | } 261 | } 262 | 263 | tr { 264 | th, 265 | td { 266 | border-bottom: 1px solid @light-gray; 267 | } 268 | &:last-of-type { 269 | // stylelint-disable-next-line 270 | td { 271 | border-bottom: none; 272 | } 273 | } 274 | } 275 | 276 | pre, 277 | table code { 278 | border: 1px solid @light-gray; 279 | background-color: @white-smoke; 280 | border-radius: 3px; 281 | font-family: @font-monospace; 282 | } 283 | 284 | table code { 285 | padding: 5px; 286 | font-size: @font-size-reset; 287 | line-height: 1; 288 | } 289 | 290 | pre { 291 | padding: 15px 20px; 292 | font-size: @font-size-monospace; 293 | line-height: @line-height-monospace; 294 | overflow-x: scroll; 295 | } 296 | 297 | img { 298 | max-width: 100%; 299 | } 300 | 301 | a { 302 | color: @blue; 303 | text-decoration: none; 304 | &:hover { 305 | text-decoration: underline; 306 | } 307 | } 308 | 309 | .apos-guide-swatch-name { 310 | margin-bottom: 0; 311 | } 312 | 313 | .apos-guide-heading-link { 314 | position: relative; 315 | color: @black; 316 | vertical-align: baseline; 317 | 318 | &:focus { 319 | outline: none; 320 | } 321 | 322 | &:hover:before { 323 | content: '#'; 324 | position: absolute; 325 | left: -25px; 326 | 327 | display: block; 328 | color: @light-gray; 329 | } 330 | } 331 | } 332 | 333 | &-search-results { 334 | .apos-guide-search-title, 335 | .apos-guide-search-result { 336 | margin-bottom: 40px; 337 | } 338 | 339 | .apos-guide-search-title { 340 | padding-bottom: 20px; 341 | border-bottom: 1px solid @light-gray; 342 | font-size: @font-size-reset; 343 | font-weight: 400; 344 | line-height: 1.3; 345 | } 346 | 347 | .apos-text-highlight { 348 | background-color: @lemon-chiffon; 349 | } 350 | 351 | .apos-guide-search-result-title { 352 | margin-bottom: 0; 353 | line-height: 1; 354 | } 355 | 356 | .apos-guide-search-result-link { 357 | &:active { 358 | color: shade(@blue, 15%); 359 | } 360 | } 361 | 362 | .apos-guide-search-result-excerpt { 363 | white-space: nowrap; 364 | text-overflow: ellipsis; 365 | overflow: hidden; 366 | } 367 | } 368 | 369 | &-search-query { 370 | display: block; 371 | font-size: @font-size-heading; 372 | } 373 | 374 | &-demo { 375 | overflow: hidden; 376 | border: 1px solid @light-gray; 377 | background: @white; 378 | border-radius: 5px; 379 | } 380 | 381 | &-demo-iframe { 382 | display: block; 383 | width: 100%; 384 | height: 100%; 385 | } 386 | 387 | &-swatch, 388 | &-icon { 389 | display: inline-block; 390 | margin-bottom: 20px; 391 | } 392 | 393 | &-swatch { 394 | width: calc(100% / 3 - 8px); 395 | margin-right: 5px; 396 | } 397 | 398 | &-swatch-color { 399 | height: 160px; 400 | } 401 | 402 | &-swatch-meta { 403 | padding: 20px; 404 | background-color: @white-smoke; 405 | } 406 | 407 | &-swatch-hex, 408 | &-swatch-rgb, 409 | &-icon-name { 410 | font-family: @font-monospace; 411 | } 412 | 413 | &-swatch-hex, 414 | &-swatch-rgb { 415 | display: block; 416 | } 417 | 418 | &-font-example { 419 | font-size: @font-size-typography; 420 | line-height: 1; 421 | } 422 | 423 | &-font-name { 424 | margin-right: 20px; 425 | } 426 | 427 | &-icon { 428 | display: inline-block; 429 | width: calc(100% / 5 - 5px); 430 | margin-bottom: 20px; 431 | text-align: center; 432 | } 433 | 434 | &-icon-image { 435 | width: auto; 436 | height: 64px; 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /public/css/theme.less: -------------------------------------------------------------------------------- 1 | /* Use this file to customize your guide */ 2 | 3 | @header-background: linear-gradient( 4 | 134deg, 5 | #cc9300 0%, 6 | #eb4339 47%, 7 | #b327bf 100% 8 | ); 9 | -------------------------------------------------------------------------------- /public/images/space.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-guides/27be69898d5b0bdbbe331eb22733a793234e479a/public/images/space.jpg -------------------------------------------------------------------------------- /public/js/guides.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | const $iframe = document.body.querySelector('[data-apos-guides-iframe]'); 3 | if (!$iframe) { 4 | return false; 5 | } 6 | 7 | $iframe.onload = function() { 8 | setHeight(); 9 | }; 10 | 11 | window.onresize = throttle(function() { 12 | setHeight(); 13 | }, 50); 14 | 15 | function getHeight() { 16 | return $iframe.contentWindow.document.body.scrollHeight; 17 | } 18 | 19 | function setHeight() { 20 | $iframe.style.height = ''; 21 | const height = getHeight(); 22 | $iframe.style.height = height + 'px'; 23 | } 24 | 25 | function throttle(func, duration) { 26 | var shouldWait = false; 27 | return function(args) { 28 | if (!shouldWait) { 29 | func.apply(this, args); 30 | shouldWait = true; 31 | setTimeout(function() { 32 | shouldWait = false; 33 | }, duration); 34 | } 35 | }; 36 | } 37 | })(); 38 | -------------------------------------------------------------------------------- /test/docs/README.md: -------------------------------------------------------------------------------- 1 | # Readme 2 | -------------------------------------------------------------------------------- /test/extensions.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const extensions = require('../lib/extensions'); 4 | 5 | describe('apostrophe-guides:extensions', function() { 6 | it('should convert a font tag to html', function(done) { 7 | const md = '[font family="Helvetica"]'; 8 | 9 | const expected = `
10 |
Helvetica
11 |
12 |

Helvetica

13 |
14 |
`; 15 | 16 | const actual = extensions.findFonts(md); 17 | 18 | assert.equal(expected, actual); 19 | done(); 20 | }); 21 | 22 | it('should convert a icon tag to html', function(done) { 23 | const md = '[icon name="foo" src="/images/foo.svg"]'; 24 | 25 | const expected = `
26 |
27 | 28 |
29 |
30 |

foo

31 |
32 |
`; 33 | 34 | const actual = extensions.findIcons(md); 35 | 36 | assert.equal(expected, actual); 37 | done(); 38 | }); 39 | 40 | it('should convert a swatch tag to html', function(done) { 41 | const md = '[swatch name="red" hex="#F00"]'; 42 | 43 | const expected = `
44 |
45 |
46 |

red

47 | #F00 48 | rgb(255,0,0) 49 |
50 |
`; 51 | 52 | const actual = extensions.findSwatches(md); 53 | 54 | assert.equal(expected, actual); 55 | done(); 56 | }); 57 | 58 | it('should convert a rgb to hex', function(done) { 59 | const md = '[swatch name="red" rgb="rgb(255,0,0)"]'; 60 | 61 | const expected = `
62 |
63 |
64 |

red

65 | #FF0000 66 | rgb(255,0,0) 67 |
68 |
`; 69 | 70 | const actual = extensions.findSwatches(md); 71 | 72 | assert.equal(expected, actual); 73 | done(); 74 | }); 75 | 76 | it('should just return the string if no custom tags are found', function(done) { 77 | const md = 'font icon swatch foo bar baz'; 78 | 79 | const expected = 'font icon swatch foo bar baz'; 80 | 81 | let actual = extensions.findFonts(md); 82 | actual = extensions.findIcons(actual); 83 | actual = extensions.findSwatches(actual); 84 | 85 | assert.equal(expected, actual); 86 | done(); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | describe('apostrophe-guides:canary', function() { 4 | it('should pass a canary test', function(done) { 5 | // eslint-disable-next-line 6 | assert.equal(true, true); 7 | done(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/markdown.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const utils = require('../lib/markdown'); 4 | 5 | describe('apostrophe-guides:markdown', function() { 6 | it('should render markdown', function(done) { 7 | const expected = `

8 | Title 9 |

`; 10 | 11 | const md = '# Title'; 12 | const actual = utils.renderDoc(md); 13 | 14 | assert.equal(expected, actual); 15 | done(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/pages.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const pages = require('../lib/pages'); 4 | 5 | describe('apostrophe-guides:pages', function() { 6 | let apos; 7 | 8 | let options = {}; 9 | 10 | this.timeout(20000); 11 | 12 | after(function(done) { 13 | require('apostrophe/test-lib/util').destroy(apos, done); 14 | }); 15 | 16 | it('initializes', function(done) { 17 | options = { 18 | path: 'guide', 19 | sections: [ 20 | { 21 | name: 'Overview', 22 | docs: [`${__dirname}/docs/README.md`] 23 | } 24 | ] 25 | }; 26 | 27 | apos = require('apostrophe')({ 28 | testModule: true, 29 | modules: { 30 | 'apostrophe-express': { 31 | secret: 'xxx', 32 | port: 7900 33 | }, 34 | 'apostrophe-guides': options 35 | }, 36 | afterInit: function(callback) { 37 | return callback(null); 38 | }, 39 | afterListen: function(err) { 40 | assert(!err); 41 | done(); 42 | } 43 | }); 44 | }); 45 | 46 | it('creates page data', function(done) { 47 | const expected = [ 48 | { 49 | docs: [ 50 | { 51 | demos: null, 52 | name: 'README', 53 | filepath: `${__dirname}/docs/README.md`, 54 | url: '/guide/overview/readme', 55 | doc: 56 | '

\n' + 57 | ' Readme\n' + 58 | '

' 59 | } 60 | ], 61 | name: 'Overview' 62 | } 63 | ]; 64 | 65 | const actual = pages(apos, options); 66 | assert.deepEqual(expected, actual); 67 | done(); 68 | }); 69 | 70 | it('creates page data from a glob', function(done) { 71 | options = { 72 | path: 'guide', 73 | sections: [ 74 | { 75 | name: 'Overview', 76 | docs: [`${__dirname}/docs/*`] 77 | } 78 | ] 79 | }; 80 | 81 | apos.modules['apostrophe-guides'] = options; 82 | 83 | const expected = [ 84 | { 85 | docs: [ 86 | { 87 | demos: null, 88 | name: 'README', 89 | filepath: `${__dirname}/docs/README.md`, 90 | url: '/guide/overview/readme', 91 | doc: 92 | '

\n' + 93 | ' Readme\n' + 94 | '

' 95 | } 96 | ], 97 | name: 'Overview' 98 | } 99 | ]; 100 | 101 | const actual = pages(apos, options); 102 | assert.deepEqual(expected, actual); 103 | done(); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /test/templates.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const templates = require('../lib/templates'); 4 | 5 | describe('apostrophe-guides:templates', function() { 6 | it('should render a font', function(done) { 7 | const expected = `
8 |
Aa
9 |
10 |

Helvetica Bold

11 |
12 |
`; 13 | 14 | const options = { 15 | name: 'Helvetica', 16 | family: 'Helvetica, san-serif', 17 | weight: 'bold', 18 | size: '12px', 19 | text: 'Aa' 20 | }; 21 | 22 | const actual = templates.font(options); 23 | 24 | assert.equal(expected, actual); 25 | done(); 26 | }); 27 | 28 | it('should render a font without a name', function(done) { 29 | const expected = `
30 |
Helvetica, sans-serif
31 |
32 |

Helvetica, sans-serif

33 |
34 |
`; 35 | 36 | const options = { 37 | family: 'Helvetica, sans-serif' 38 | }; 39 | 40 | const actual = templates.font(options); 41 | 42 | assert.equal(expected, actual); 43 | done(); 44 | }); 45 | 46 | it('should render a icon', function(done) { 47 | const expected = `
48 |
49 | 50 |
51 |
52 |

Cart

53 |
54 |
`; 55 | 56 | const options = { 57 | name: 'Cart', 58 | src: '/images/cart.svg' 59 | }; 60 | 61 | const actual = templates.icon(options); 62 | 63 | assert.equal(expected, actual); 64 | done(); 65 | }); 66 | 67 | it('should render a icon without a name', function(done) { 68 | const expected = `
69 |
70 | 71 |
72 |
`; 73 | 74 | const options = { 75 | src: '/images/cart.svg' 76 | }; 77 | 78 | const actual = templates.icon(options); 79 | 80 | assert.equal(expected, actual); 81 | done(); 82 | }); 83 | 84 | it('should render a swatch', function(done) { 85 | const expected = `
86 |
87 |
88 |

$red

89 | #F00 90 | rgb(221,238,255) 91 |
92 |
`; 93 | 94 | const options = { 95 | name: '$red', 96 | hex: '#F00', 97 | rgb: 'rgb(221,238,255)' 98 | }; 99 | 100 | const actual = templates.swatch(options); 101 | 102 | assert.equal(expected, actual); 103 | done(); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const assert = require('assert'); 3 | 4 | const utils = require('../lib/utils'); 5 | 6 | describe('apostrophe-guides:utils', function() { 7 | it('should stringify with formatting', function(done) { 8 | const obj = { foo: 'bar' }; 9 | 10 | const expected = `{ 11 | "foo": "bar" 12 | }`; 13 | 14 | const actual = utils.stringify(obj); 15 | 16 | assert.equal(expected, actual); 17 | done(); 18 | }); 19 | 20 | it('should highlight a term', function(done) { 21 | const str = 'Hello World'; 22 | 23 | const expected = 'Hello World'; 24 | const actual = utils.highlight([[0, 3]], str, 4); 25 | 26 | assert.equal(expected, actual); 27 | done(); 28 | }); 29 | 30 | it('should return an object of attributes', function(done) { 31 | const attrs = ['foo="bar"', 'baz="qux"']; 32 | 33 | const expected = { 34 | foo: 'bar', 35 | baz: 'qux' 36 | }; 37 | 38 | const actual = utils.addAttr(attrs); 39 | 40 | assert.deepEqual(expected, actual); 41 | done(); 42 | }); 43 | 44 | it('should log an error', function(done) { 45 | const spy = sinon.spy(console, 'error'); 46 | 47 | const expected = 'Error: \'apostrophe-guides\' \nfoo\n'; 48 | 49 | utils.logError('foo'); 50 | 51 | assert(spy.calledWith(expected)); 52 | spy.restore(); 53 | done(); 54 | }); 55 | 56 | it('should set a section to active', function(done) { 57 | const sections = [ 58 | { 59 | name: 'Section', 60 | docs: [{ name: 'Page One' }, { name: 'Page Two' }] 61 | } 62 | ]; 63 | 64 | const page = { name: 'Page One' }; 65 | 66 | const expected = [ 67 | { 68 | name: 'Section', 69 | docs: [ 70 | { 71 | name: 'Page One', 72 | active: true 73 | }, 74 | { 75 | name: 'Page Two', 76 | active: false 77 | } 78 | ] 79 | } 80 | ]; 81 | 82 | const actual = utils.setActive(sections, page); 83 | 84 | assert.deepEqual(expected, actual); 85 | done(); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /views/demo.html: -------------------------------------------------------------------------------- 1 | {% set demo = data.demo %} 2 | 3 | 4 | 5 | {% for stylesheet in demo.stylesheets %} 6 | 7 | {% endfor %} 8 | 9 | 10 | {% block apostropheMenu %}{% endblock %} 11 | {% block content %} 12 | 13 | {% endblock %} 14 | {% for script in demo.scripts %} 15 | 16 | {% endfor %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /views/guide.html: -------------------------------------------------------------------------------- 1 | {% extends data.outerLayout %} 2 | 3 | {% import 'macros/section.html' as section %} 4 | {% import 'macros/navigation.html' as navigation %} 5 | 6 | {% set guide = data.guide %} 7 | 8 | {% block title %}{{ guide.title }}{% endblock %} 9 | 10 | {% block extraHead %} 11 | 12 | {% endblock %} 13 | 14 | {% block beforeMain %} 15 |
16 | {% include 'header.html' %} 17 |
18 | {{ navigation.render(guide.sections) }} 19 | {% endblock %} 20 | 21 | {% block main %} 22 |
23 | {{ section.render(guide.page) }} 24 |
25 | {% endblock %} 26 | 27 | {% block afterMain %} 28 |
29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /views/header.html: -------------------------------------------------------------------------------- 1 |
2 | {{ guide.title }} 3 |
4 | 5 | 6 |
7 |
8 | -------------------------------------------------------------------------------- /views/macros/example.html: -------------------------------------------------------------------------------- 1 | {% macro render(options) %} 2 |
3 |
4 | 5 | 6 |
7 |
8 | {% endmacro %} 9 | -------------------------------------------------------------------------------- /views/macros/navigation.html: -------------------------------------------------------------------------------- 1 | {% macro render(data) %} 2 | 16 | {% endmacro %} 17 | -------------------------------------------------------------------------------- /views/macros/section.html: -------------------------------------------------------------------------------- 1 | {% import 'apostrophe-guides:macros/example.html' as example %} 2 | 3 | {% macro render(options) %} 4 | 5 | {% set footer = options.footer %} 6 | {% set footerTemplate = options.footerTemplate %} 7 | 8 |
9 |
10 | 11 |
12 | {{ options.doc | safe }} 13 | 14 | {% for demo in options.demos %} 15 | {{ example.render({ demo: demo }) }} 16 | {% endfor %} 17 |
18 | 19 | {% if footerTemplate %} 20 | {% include footerTemplate %} 21 | {% elseif footer %} 22 | 23 | {% endif %} 24 | 25 |
26 |
27 | {% endmacro %} 28 | -------------------------------------------------------------------------------- /views/search.html: -------------------------------------------------------------------------------- 1 | {% extends data.outerLayout %} 2 | 3 | {% import 'macros/section.html' as section %} 4 | {% import 'macros/navigation.html' as navigation %} 5 | 6 | {% set guide = data.guide %} 7 | 8 | {% block title %}{{ guide.title }}{% endblock %} 9 | 10 | {% block beforeMain %} 11 |
12 | {% include 'header.html' %} 13 |
14 | {{ navigation.render(guide.sections) }} 15 | {% endblock %} 16 | 17 | {% block main %} 18 |
19 |
20 |
21 |
22 | 23 | {% if data.totalResults %} 24 |

{{ data.totalDocuments }} {{ __("Documents for") if data.totalDocuments > 1 else __("Document for") }} {{ data.query.term }}

25 | {% for result in data.results %} 26 |
27 |

{{ result.name | safe }}

28 |
{{ result.doc | safe }}
29 |
30 | {% endfor %} 31 | {% else %} 32 |

{{__("Sorry, no results found for") }} {{ data.query.term }}

33 | {% endif %} 34 | 35 |
36 |
37 |
38 |
39 | {% endblock %} 40 | 41 | {% block afterMain %} 42 |
43 |
44 | {% endblock %} 45 | --------------------------------------------------------------------------------