├── sampleContent ├── 404.md ├── _assets │ ├── custom.css │ ├── images │ │ ├── favicon.png │ │ └── functions.svg │ └── page_template.html ├── docs │ ├── basics │ │ ├── image.png │ │ └── index.md │ ├── internals │ │ ├── diagram.png │ │ ├── diagram.pptx │ │ ├── appinsights.png │ │ └── index.md │ ├── index.md │ └── templating │ │ └── index.md ├── tests │ ├── markup │ │ ├── jekyll │ │ │ └── index.md │ │ └── rst │ │ │ └── index.rst │ ├── img │ │ ├── gradient.png │ │ └── index.md │ ├── footnotes │ │ └── index.md │ ├── acronyms │ │ └── index.md │ ├── aliases │ │ └── index.md │ ├── interwiki │ │ └── index.md │ ├── index.md │ └── highlight │ │ ├── index.md │ │ └── animate_svg.js ├── blog │ ├── 2016 │ │ └── 03 │ │ │ └── 19 │ │ │ └── 1900 │ │ │ └── index.md │ └── 2019 │ │ └── 08 │ │ └── 08 │ │ └── 1854 │ │ └── index.md └── index.md ├── host.json ├── .github ├── dependabot.yml └── workflows │ └── codeql.yml ├── copyActivity ├── function.json └── index.js ├── renderMarkdownActivity ├── function.json └── index.js ├── renderPipeline ├── function.json └── index.js ├── renderTemplateActivity ├── function.json └── index.js ├── renderTextileActivity ├── function.json └── index.js ├── triggerHandler ├── function.json └── index.js ├── LICENSE ├── package.json ├── Makefile ├── .gitattributes ├── README.md └── .gitignore /sampleContent/404.md: -------------------------------------------------------------------------------- 1 | --- 2 | Title: Page Not Found 3 | --- -------------------------------------------------------------------------------- /sampleContent/_assets/custom.css: -------------------------------------------------------------------------------- 1 | code span { 2 | font-family: Menlo, Consolas, monospace !important; 3 | } 4 | -------------------------------------------------------------------------------- /sampleContent/docs/basics/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarmo/azure-durable-functions-node-blog-engine/HEAD/sampleContent/docs/basics/image.png -------------------------------------------------------------------------------- /sampleContent/tests/markup/jekyll/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | title: Blogging Like a Hacker 4 | --- 5 | 6 | This is an imported Jekyll post. 7 | -------------------------------------------------------------------------------- /sampleContent/tests/img/gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarmo/azure-durable-functions-node-blog-engine/HEAD/sampleContent/tests/img/gradient.png -------------------------------------------------------------------------------- /sampleContent/_assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarmo/azure-durable-functions-node-blog-engine/HEAD/sampleContent/_assets/images/favicon.png -------------------------------------------------------------------------------- /sampleContent/docs/internals/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarmo/azure-durable-functions-node-blog-engine/HEAD/sampleContent/docs/internals/diagram.png -------------------------------------------------------------------------------- /sampleContent/docs/internals/diagram.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarmo/azure-durable-functions-node-blog-engine/HEAD/sampleContent/docs/internals/diagram.pptx -------------------------------------------------------------------------------- /host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "extensionBundle": { 4 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 5 | "version": "[1.*, 2.0.0)" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /sampleContent/docs/internals/appinsights.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rcarmo/azure-durable-functions-node-blog-engine/HEAD/sampleContent/docs/internals/appinsights.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /sampleContent/tests/footnotes/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Title: Footnotes 4 | Content-Type: text/x-markdown 5 | --- 6 | 7 | Here[^1] 8 | 9 | [^1]: There. 10 | -------------------------------------------------------------------------------- /copyActivity/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "name", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /renderMarkdownActivity/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "name", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /renderPipeline/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /renderTemplateActivity/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "page", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /renderTextileActivity/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "name", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /sampleContent/tests/acronyms/index.md: -------------------------------------------------------------------------------- 1 | From: Rui Carmo 2 | Title: Acronym Tests 3 | 4 | Generic `span`: 5 | 6 | FUD 7 | 8 | Generic `caps`: 9 | 10 | BTO 11 | -------------------------------------------------------------------------------- /sampleContent/tests/aliases/index.md: -------------------------------------------------------------------------------- 1 | From: Rui Carmo 2 | Title: Alias tests 3 | 4 | Aliases are defined in [meta/Aliases](meta/Aliases) 5 | 6 | * [Markdown](Markdown) - a simple alias 7 | * [Chrome](Chrome) - a nested alias -------------------------------------------------------------------------------- /sampleContent/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Date: 2014-10-04 09:09:00 4 | Title: Engine Documentation 5 | --- 6 | 7 | * [Basics](docs/basics) 8 | * [Templating](docs/templating) 9 | * [Internals](docs/internals) 10 | -------------------------------------------------------------------------------- /sampleContent/blog/2016/03/19/1900/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Title: Pressing on 4 | Date: 2016-03-19 18:54:00 5 | --- 6 | 7 | After a fairly long hiatus, development has resumed, focusing largely on templating and making this suitable for a blog. 8 | -------------------------------------------------------------------------------- /sampleContent/tests/interwiki/index.md: -------------------------------------------------------------------------------- 1 | From: Rui Carmo 2 | Title: InterWiki Tests 3 | 4 | * [Bon Jovi on iTunes](Artist:Bon%20Jovi) 5 | * [Wiki on Wikipedia](Wikipedia:Wiki) 6 | * [HomePage on Tao](Tao:HomePage) 7 | * [RFC 1918](RFC:1918) 8 | * [Blade Runner on IMDB](IMDB:tt0083658) -------------------------------------------------------------------------------- /sampleContent/blog/2019/08/08/1854/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Date: 2019-08-08 19:54:00 4 | Title: Azure Durable Functions 5 | --- 6 | 7 | Decided to use my usual sample blog content to test an Azure Durable Functions rendering pipeline. 8 | 9 | Which sort of works. Almost. Someday. 10 | -------------------------------------------------------------------------------- /sampleContent/tests/markup/rst/index.rst: -------------------------------------------------------------------------------- 1 | Title: ReStructured Text 2 | 3 | Chapter 1 Title 4 | =============== 5 | 6 | Section 1.1 Title 7 | ----------------- 8 | 9 | Subsection 1.1.1 Title 10 | ~~~~~~~~~~~~~~~~~~~~~~ 11 | 12 | Section 1.2 Title 13 | ----------------- 14 | 15 | Chapter 2 Title 16 | =============== 17 | -------------------------------------------------------------------------------- /sampleContent/tests/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Title: Tests 4 | --- 5 | 6 | ## To Do 7 | 8 | * [Textile Markup](tests/markup) 9 | 10 | ## Ongoing 11 | 12 | * [Syntax Highlighting](tests/highlight) 13 | 14 | ## Passed 15 | 16 | * [Footnotes](tests/footnotes) (Markdown-only test) 17 | * [Inline Images](tests/img) 18 | -------------------------------------------------------------------------------- /sampleContent/docs/templating/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Date: 2019-08-11 10:53:00 4 | Title: Templating 5 | --- 6 | 7 | Markup is rendered to HTML using a [Mustache](http://mustache.github.io) template in the `_assets` folder (`_assets/page_template.html`). 8 | 9 | Any static assets should be placed in that folder, and the engine will rewrite links to them (and to inline images) accordingly. 10 | -------------------------------------------------------------------------------- /triggerHandler/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "myBlob", 5 | "type": "blobTrigger", 6 | "direction": "in", 7 | "path": "raw-markup/{name}", 8 | "connection": "AzureWebJobsStorage" 9 | }, 10 | { 11 | "name": "starter", 12 | "type": "orchestrationClient", 13 | "direction": "in" 14 | } 15 | ], 16 | "disabled": false 17 | } 18 | -------------------------------------------------------------------------------- /sampleContent/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | Title: Home Page 3 | Author: Rui Carmo 4 | Date: 2019-08-10 13:05:00 5 | --- 6 | 7 | ## What this is about 8 | 9 | This is a serverless static site generator that takes Textile and Markdown files as input and generates a [static Azure Website](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blob-static-website). 10 | 11 | ## [Documentation](docs) 12 | 13 | This is (naturally) [self-documenting](docs). 14 | 15 | ## Demo Content 16 | 17 | There is a set of [formatting tests](tests) you can look at to get a feel for the way things work. 18 | -------------------------------------------------------------------------------- /sampleContent/tests/img/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Title: Inline Images 4 | --- 5 | 6 | Inserted using [Markdown](Wikipedia:Markdown): 7 | 8 | ![A gradient](./gradient.png) 9 | 10 | Inserted using inline HTML: 11 | 12 | 13 | 14 | With custom dimensions in HTML: 15 | 16 | 17 | 18 | With custom dimensions in CSS: 19 | 20 | 21 | 22 | An invalid image file: 23 | 24 | 25 | 26 | A missing image file: 27 | 28 | 29 | -------------------------------------------------------------------------------- /sampleContent/tests/highlight/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Date: 2013-07-06 23:33:00 4 | Title: Syntax Highlighting Tests 5 | --- 6 | 7 | ### Git-Flavored Markdown 8 | 9 | The standard triple-backquote form. 10 | 11 | ```clojure 12 | ; parallel consumption of perishable resources 13 | (defn foo [bar drinks] 14 | (pmap (:patrons bar) (lazy-seq drinks))) 15 | ``` 16 | 17 | ```python 18 | from bottle import view, request, abort 19 | 20 | @view("rss") 21 | def render_feed() 22 | if not items: 23 | abort("418", "I'm a teapot") 24 | else: 25 | return {"items": items} 26 | ``` 27 | 28 | This should be a plain text line. 29 | -------------------------------------------------------------------------------- /triggerHandler/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This is the starter function, which is triggered by a blob event. 3 | */ 4 | 5 | const df = require("durable-functions"); 6 | 7 | module.exports = async function (context, srcBlob) { 8 | const client = df.getClient(context), 9 | name = context.bindingData.name; 10 | 11 | //length = srcBlob.length; 12 | //context.log("name:", name, " size:", length); 13 | 14 | const instanceId = await client.startNew("renderPipeline", undefined, name); 15 | 16 | //context.log("orchestrator:", instanceId); 17 | 18 | return client.createCheckStatusResponse(name, instanceId) 19 | }; 20 | -------------------------------------------------------------------------------- /sampleContent/_assets/images/functions.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /copyActivity/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This function is not intended to be invoked directly. Instead it will be 3 | * triggered by an orchestrator function. 4 | */ 5 | 6 | const storage = require("azure-storage"), 7 | blobService = storage.createBlobService(process.env['AzureWebJobsStorage']); 8 | 9 | 10 | const copyBlob = async (srcUri, pathname) => { 11 | return new Promise((resolve, reject) => { 12 | blobService.startCopyBlob(srcUri, "$web", pathname, (err, data) => { 13 | if (err) { 14 | reject(err); 15 | } else { 16 | resolve({ message: data }); 17 | } 18 | }); 19 | }); 20 | } 21 | 22 | module.exports = async function (context, name) { 23 | //context.log("copy:", name); 24 | const srcUri = blobService.getUrl('raw-markup', name), 25 | result = await copyBlob(srcUri, name); 26 | 27 | //context.log(result); 28 | return result; 29 | }; -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "17 10 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /renderTextileActivity/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This function is not intended to be invoked directly. Instead it will be 3 | * triggered by an orchestrator function. 4 | */ 5 | 6 | const storage = require("azure-storage"), 7 | matter = require("gray-matter"), 8 | textile = require('textile'), 9 | blobService = storage.createBlobService(process.env['AzureWebJobsStorage']); 10 | 11 | const getMarkup = async (blobName) => { 12 | return new Promise((resolve, reject) => { 13 | blobService.getBlobToText("raw-markup", blobName, (err, data) => { 14 | if (err) { 15 | reject(new Error(err)); // fail with runtime error and kill activity 16 | } else { 17 | resolve(data); 18 | } 19 | }); 20 | }); 21 | }; 22 | 23 | module.exports = async function (context, name) { 24 | // context.log(name); 25 | 26 | const response = await getMarkup(name), 27 | page = matter(response); 28 | page.name = name; 29 | 30 | // replace Textile with rendered HTML 31 | page.content = textile(page.content); 32 | return page; 33 | }; -------------------------------------------------------------------------------- /sampleContent/tests/highlight/animate_svg.js: -------------------------------------------------------------------------------- 1 | var paths = Array.prototype.slice.call(document.querySelectorAll('.animated path'),0); 2 | 3 | paths.map(function(path) { 4 | var bag = document.createAttribute("bag"); 5 | bag.value = path.style.fill; 6 | path.setAttributeNode(bag); 7 | path.style.fill = "white"; 8 | }) 9 | 10 | paths.map(function(path){ 11 | var length = path.getTotalLength(); 12 | path.style.stroke="#000"; 13 | path.style.strokeWidth="5"; 14 | path.style.transition = path.style.WebkitTransition = 'none'; 15 | path.style.strokeDasharray = length + ' ' + length; 16 | path.style.strokeDashoffset = length; 17 | path.getBoundingClientRect(); 18 | path.style.transition = path.style.WebkitTransition = 'stroke-dashoffset 2s ease-in-out'; 19 | path.style.strokeDashoffset = '0'; 20 | }); 21 | 22 | 23 | setTimeout(function(){ 24 | paths.map(function(path){ 25 | path.style.transition = path.style.WebkitTransition = 'fill 2s ease, stroke-width 2s ease'; 26 | path.style.fill = path.getAttribute("bag"); 27 | path.style.strokeWidth = "0"; 28 | }) 29 | },3000) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Rui Carmo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "azure-durable-functions-node-blog-engine", 3 | "version": "1.0.0", 4 | "description": "This is a static file generator that demonstrates how to build a blob storage processing pipeline with durable functions", 5 | "main": "index.js", 6 | "dependencies": { 7 | "durable-functions": "^3.0.0", 8 | "cheerio": "^1.0.0-rc.3", 9 | "gray-matter": "^4.0.2", 10 | "remarkable": "^2.0.0", 11 | "moment": "^2.24.0", 12 | "mustache": "^4.0.0", 13 | "textile-js": "^2.0.4", 14 | "highlight.js": "^11.0.1", 15 | "dompurify": "^3.0.1", 16 | "azure-storage": "^2.10.3", 17 | "mime-types": "^2.1.24" 18 | }, 19 | "devDependencies": {}, 20 | "scripts": { 21 | "test": "echo \"Error: no test specified\" && exit 1" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/rcarmo/azure-durable-functions-node-blog-engine.git" 26 | }, 27 | "author": "Rui Carmo", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/rcarmo/azure-durable-functions-node-blog-engine/issues" 31 | }, 32 | "homepage": "https://github.com/rcarmo/azure-durable-functions-node-blog-engine#readme" 33 | } 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This grabs the function app name (assuming you set up a remote called "production" correctly) 2 | FUNCTION_APP_NAME?=$(shell git remote get-url production | awk -F[/:] '{print $$4}' | cut -f 1 -d.) 3 | # This assumes the function app lives in a resource group with the same name 4 | RESOURCE_GROUP?=${FUNCTION_APP_NAME} 5 | # Sanity setting for when I run this from a machine with zsh 6 | SHELL=bash 7 | 8 | # Safe default 9 | debug: 10 | @echo $(FUNCTION_APP_NAME) 11 | 12 | # Sync the sample content to the incoming blob container 13 | sync: 14 | # Grab the storage connection string and set the environment so we don't have to keep auth keys around 15 | @$(eval export AZURE_STORAGE_CONNECTION_STRING := $(shell az webapp config appsettings list \ 16 | --name $(FUNCTION_APP_NAME) \ 17 | --resource-group $(RESOURCE_GROUP) \ 18 | | jq -r '.[] | select (.name == "AzureWebJobsStorage").value')) 19 | # Cleanup Mac filesystem metadata 20 | @find . -print | grep .DS_Store | xargs rm 21 | # Sync requires azcopy to be installed 22 | az storage blob sync --source sampleContent --container raw-markup 23 | 24 | sync-all: 25 | find sampleContent -print | xargs -J % touch %; make sync 26 | 27 | git-rewind-%: 28 | git reset --soft HEAD~$* && git commit -------------------------------------------------------------------------------- /sampleContent/docs/internals/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Date: 2014-11-03 19:09:00 4 | Last-Modified: 2014-12-29 11:15:00 5 | Title: Internals 6 | --- 7 | 8 | ## Azure Durable Functions 9 | 10 | This simple blog engine is written in NodeJS atop Azure Durable Functions: 11 | 12 | ![Diagram](diagram.png) 13 | 14 | The following is a short description of each function: 15 | 16 | ### `triggerHandler` 17 | 18 | This is a simple blob trigger that is invoked whenever a file is modified in Azure storage. 19 | 20 | ### `renderPipeline` 21 | 22 | This is the pipeline orchestrator, which figures out what needs to be done for each filetype. 23 | 24 | ### `renderMarkdownActivity` 25 | 26 | This is the Markdown formatter, which takes the raw markup and issues a page object containing HTML. 27 | 28 | ### `renderTextileActivity` 29 | 30 | This is the Textile formatter, which takes the raw markup and issues a page object containing HTML. 31 | 32 | ### `renderTemplateActivity` 33 | 34 | This is the template formatter, which takes the HTML, rewrites relative URLs, and writes it to blob storage. 35 | 36 | ### `copyActivity` 37 | 38 | This is a straightforward blob-to-blob copy activity for non-markup files (images, etc.). No transformations are currently performed. 39 | 40 | ## Monitoring 41 | 42 | Since Azure Functions is usually deployed with Application Insights enabled, you have real-time monitoring and tracing, as well as per-activity metrics: 43 | 44 | ![Portal](appinsights.png) 45 | -------------------------------------------------------------------------------- /renderMarkdownActivity/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This function is not intended to be invoked directly. Instead it will be 3 | * triggered by an orchestrator function. 4 | */ 5 | 6 | const storage = require("azure-storage"), 7 | matter = require("gray-matter"), 8 | remarkable = require('remarkable'), 9 | hljs = require('highlight.js'), 10 | blobService = storage.createBlobService(process.env['AzureWebJobsStorage']); 11 | 12 | var md = new remarkable.Remarkable({ 13 | highlight: function (str, lang) { 14 | if (lang && hljs.getLanguage(lang)) { 15 | try { 16 | return hljs.highlight(lang, str).value; 17 | } catch (err) {} 18 | } 19 | 20 | try { 21 | return hljs.highlightAuto(str).value; 22 | } catch (err) {} 23 | 24 | return ''; // use external default escaping 25 | }, 26 | typographer: true, 27 | html: true 28 | }); 29 | 30 | const getMarkup = async (blobName) => { 31 | return new Promise((resolve, reject) => { 32 | blobService.getBlobToText("raw-markup", blobName, (err, data) => { 33 | if (err) { 34 | reject(new Error(err)); // fail with runtime error and kill activity 35 | } else { 36 | resolve(data); 37 | } 38 | }); 39 | }); 40 | }; 41 | 42 | module.exports = async function (context, name) { 43 | //context.log(name); 44 | const response = await getMarkup(name), 45 | page = matter(response); 46 | page.name = name; 47 | // replace Markdown with rendered HTML 48 | page.content = md.render(page.content); 49 | return page; 50 | }; -------------------------------------------------------------------------------- /renderPipeline/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This function is not intended to be invoked directly. Instead it will be 3 | * triggered by a starter function. 4 | */ 5 | 6 | const df = require('durable-functions'), 7 | path = require('path'), 8 | activityMap = { 9 | renderMarkdownActivity: { 10 | extensions: ['.md', '.mkd', '.markdown'], 11 | pipeline: ['renderMarkdownActivity', 'renderTemplateActivity'] 12 | }, 13 | renderTextileActivity: { 14 | extensions: ['.text', '.textile'], 15 | pipeline: ['renderTextileActivity', 'renderTemplateActivity'] 16 | }, 17 | /* Render HTML snippets directly (disabled to allow custom content pass-through) 18 | renderTemplateActivity: { 19 | extensions: ['.htm', '.html'], 20 | pipeline: ['renderTemplateActivity'] 21 | } 22 | */ 23 | }; 24 | 25 | module.exports = df.orchestrator(function* (context) { 26 | const outputs = [], 27 | name = context.df.getInput(), 28 | extension = path.extname(name); 29 | 30 | var pipeline = ["copyActivity"], 31 | currentItem = name; 32 | 33 | // context.log("pipeline:", pipeline, name, extension); 34 | 35 | Object.keys(activityMap).forEach(key => { 36 | if(activityMap[key].extensions.includes(extension)) { 37 | pipeline = activityMap[key].pipeline; 38 | } 39 | }) 40 | 41 | // all activities we build should return a JS object 42 | for(let activity of pipeline) { 43 | // context.log("running:", activity); 44 | currentItem = yield context.df.callActivity(activity, currentItem); 45 | //context.log(currentItem); 46 | if(currentItem == null) { // the activity has failed 47 | context.log("error:", activity) 48 | yield cancel(activity) 49 | break 50 | } 51 | outputs.push(currentItem); 52 | } 53 | return outputs; 54 | }); -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /sampleContent/docs/basics/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | From: Rui Carmo 3 | Date: 2014-10-04 09:17:00 4 | Title: Content Basics 5 | --- 6 | 7 | ## Files and Folders 8 | 9 | This simple blog engine expects you to organize your content using _a folder per page_ with an "index" document inside. The folder path determines the URL it's published under (so you get "nice" URLs by default), and this makes it easier to manage media assets on a per-post basis: 10 | 11 | ![Folders](image.png) 12 | 13 | ## Uploading Content 14 | 15 | The easiest way to batch upload content is using `az` (which uses `azcopy sync` under the covers). Just take your Azure Storage connection string and do this: 16 | 17 | ```bash 18 | export AZURE_STORAGE_CONNECTION_STRING="" 19 | az storage blob sync --source sampleContent --container raw-markup 20 | ``` 21 | 22 | It will then only transfer any files you've modified locally, and remove any leftover files on the `raw-markup` container[^1]: 23 | 24 | ```bash 25 | Azcopy command: ['/usr/local/bin/azcopy', 'sync', 'sampleContent', 'https://, '--delete-destination', 'true'] 26 | 27 | Job cfa91ed8-1aa3-0745-68c5-8ad363d8e540 has started 28 | Log file is located at: /Users/rcarmo/.azcopy/cfa91ed8-1aa3-0745-68c5-8ad363d8e540.log 29 | 30 | 0 Files Scanned at Source, 0 Files Scanned at Destination 31 | 32 | Job cfa91ed8-1aa3-0745-68c5-8ad363d8e540 Summary 33 | Files Scanned at Source: 26 34 | Files Scanned at Destination: 26 35 | Elapsed Time (Minutes): 0.0334 36 | Total Number Of Copy Transfers: 1 37 | Number of Copy Transfers Completed: 1 38 | Number of Copy Transfers Failed: 0 39 | Number of Deletions at Destination: 0 40 | Total Number of Bytes Transferred: 496 41 | Total Number of Bytes Enumerated: 496 42 | Final Job Status: Completed 43 | ``` 44 | 45 | ## Markup 46 | 47 | This engine supports Markdown and Textile, and determines which markup processor to use based on the file extension. 48 | 49 | Syntax highlighting is also supported for fenced Markdown code blocks: 50 | 51 | ```javascript 52 | const magic = require('leftpad') 53 | ``` 54 | 55 | Since this is performed client-side it can be a little temperamental, but generally works fine. 56 | 57 | ## Front Matter 58 | 59 | Your index document must contain "front matter" to provide page metadata: 60 | 61 | ```plaintext 62 | --- 63 | From: Rui Carmo 64 | Date: 2014-10-04 09:24:00 65 | Title: Test post 66 | --- 67 | 68 | ## Content Heading 69 | 70 | Body text 71 | ``` 72 | 73 | The engine isn't especially picky about anything except that header names should be followed by a colon and that the first blank line separates them from the body text. 74 | 75 | ## Recognized Headers 76 | 77 | The simple blog engine looks for the following (case-insensitive) headers: 78 | 79 | * `Date:` the original publishing date for your post. If missing, the file modification time is used. 80 | * `From:` the author name 81 | * `Last-Modified:` lets you override file modification time explicitly and trumps the date for insertion in RSS feeds 82 | * `Title:` your page/post title 83 | 84 | 85 | [^1]: Note that any _rendered_ files that are published to `$web` are not removed automatically, since the engine does not (yet) handle blob deletions (that requires [Event Grid integration](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blob-event-quickstart), which isn't implemented yet). -------------------------------------------------------------------------------- /renderTemplateActivity/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This function is not intended to be invoked directly. Instead it will be 3 | * triggered by an orchestrator function. 4 | */ 5 | 6 | const path = require('path'), 7 | mime = require('mime-types'), 8 | mustache = require('mustache'), 9 | storage = require('azure-storage'), 10 | cheerio = require('cheerio'), 11 | blobService = storage.createBlobService(process.env['AzureWebJobsStorage']); 12 | 13 | const getTemplate = async (blobName) => { 14 | return new Promise((resolve, reject) => { 15 | blobService.getBlobToText("raw-markup", blobName, (err, data) => { 16 | if (err) { 17 | reject(new Error(err)); // fail with runtime error and kill activity 18 | } else { 19 | resolve(data); 20 | } 21 | }); 22 | }); 23 | }; 24 | 25 | const uploadHTML = async (pathname, text) => { 26 | return new Promise((resolve, reject) => { 27 | var options = { 28 | contentSettings:{ 29 | contentType: mime.lookup(pathname) + "; charset=utf-8", // ensure we write as UTF-8 30 | cacheControl: 'max-age: 38400' 31 | } 32 | }; 33 | blobService.createBlockBlobFromText("$web", pathname, text, options, err => { 34 | if (err) { 35 | reject(new Error(err)); // fail with runtime error and kill activity 36 | } else { 37 | resolve(true); 38 | } 39 | }); 40 | }); 41 | }; 42 | 43 | // Polyfill to rebuild an object from key/value pairs 44 | Object.fromEntries = arr => Object.assign({}, ...Array.from(arr, ([k, v]) => ({[k]: v}) )); 45 | 46 | 47 | module.exports = async function (context, page) { 48 | // context.log(page.name); 49 | 50 | const template = await getTemplate('_assets/page_template.html'); 51 | 52 | // lowercase front matter before applying it to template 53 | page.data = Object.fromEntries(Object.entries(page.data).map(([k, v]) => [k.toLowerCase(), v])); 54 | page.data.content = page.content; 55 | 56 | var html = mustache.render(template, page.data), 57 | $ = cheerio.load(html); 58 | 59 | // rewrite template head links (CSS, etc.) 60 | ['head > link'].forEach(selector => { 61 | $(selector).each((i, elem) => { 62 | const href = $(elem).attr("href"); 63 | if(href) { 64 | if(!(href.startsWith("http") || (href[0] == '/'))) { 65 | $(elem).attr("href", '/_assets/' + href); 66 | } 67 | } 68 | }) 69 | }); 70 | 71 | // rewrite script SRC tags to have absolute URLs from site root 72 | ['script'].forEach(selector => { 73 | $(selector).each((i, elem) => { 74 | const src = $(elem).attr("src"); 75 | if(src) { 76 | if(!(src.startsWith("http") || (src[0] == '/'))) { 77 | $(elem).attr("src", '/_assets/' + src); 78 | } 79 | } 80 | }) 81 | }); 82 | 83 | // rewrite image tags to have absolute URLs from site root 84 | ['img'].forEach(selector => { 85 | $(selector).each((i, elem) => { 86 | const src = $(elem).attr("src"); 87 | if(src) { 88 | if(!(src.startsWith("http") || (src[0] == '/'))) { 89 | $(elem).attr("src", '/' + path.dirname(page.name) + '/' + src); 90 | } 91 | } 92 | }) 93 | }); 94 | html = $.html(); 95 | 96 | const result = await uploadHTML(page.name.substr(0, page.name.lastIndexOf(".")) + ".html", html); 97 | 98 | // context.log(html); 99 | // context.log(result); 100 | return result; 101 | }; 102 | -------------------------------------------------------------------------------- /sampleContent/_assets/page_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{title}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |  Docs 33 | 35 | 38 | 40 | 41 | 42 |  Github 43 | 44 | 47 | 48 | 49 | 50 |  Tests 51 | 52 |
53 |
54 |
55 |

{{title}}

56 |
57 |
58 |
59 |
60 | {{{content}}} 61 |
62 |
63 |
64 |
65 |

Layout based on mini.css. 67 | Source code licensed under MIT License. 68 | Icons provided by Feather.

69 |
70 |
71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # azure-durable-functions-node-blog-engine 2 | 3 | This is an [Azure Functions][azf] sample that demonstrates how to build a static site generator with [durable functions][azdf] to take raw markup files and render them to a [static Azure website][saw]: 4 | 5 | ![diagram](sampleContent/docs/internals/diagram.png) 6 | 7 | All you need to do is upload your Markdown/Textile files to Azure storage, and they'll be rendered into a nicely formatted web site. You can even [use OneDrive to do that automatically for you][flow]. 8 | 9 | The current demo site is [here](http://acmeblogenginebfa7.z6.web.core.windows.net) (may be temporarily broken as I build this out). 10 | 11 | ## Why 12 | 13 | This was originally part of an "ETL" blob storage processing pipeline and intended to demonstrate how to process thousands of XML/CSV files in a scalable way and insert them into a database as they were uploaded. 14 | 15 | But the general principle behind that kind of transformation pipeline is exactly the same as a static site generator, and handling text and image content also provides opportunity to incorporate Azure Cognitive Services and other fun things, so I turned the original pipeline into something of more general interest - i.e., a fully serverless static file generator. 16 | 17 | > Incidentally, you can run this completely inside the [Azure Free Tier][azfree]! 18 | 19 | ## Setup 20 | 21 | This sample currently assumes you've performed the following provisioning actions via the Azure Portal: 22 | 23 | - Set up a NodeJS Function App (on Windows or Linux - a free tier Function App will do just fine) 24 | - Upgraded the associated storage account to `StorageV2 (general purpose v2)` 25 | - Enabled the `Static website` feature and made sure the `$web` container has public access enabled 26 | - Gone into Function App `Configuration` -> `All Settings` -> `Deployment Center` and activated `Local Git` deployment via Kudu (re-visit that pane after configuration to get the Git URL and credentials) 27 | 28 | ## Contributing/Roadmap/To Do 29 | 30 | If you feel like contributing, this is a rough roadmap/To Do list that matches what I usually need from a static file generator and some Azure-specific features that I intend to implement given time: 31 | 32 | - [ ] Integration with Cognitive Services 33 | - [ ] Higher-level integration with Application Insights 34 | - [ ] Update diagram 35 | - [ ] Add `renderTableActivity` to render YAML data into nice tables 36 | - [ ] Azure Template for automated deployment 37 | - [ ] List of blog posts (ordered list of everything under `/blog`) 38 | - [ ] Add auxiliary Azure storage table for metadata lookup and building page listings using page front matter 39 | - [ ] Handle blob deletion [using EventGrid](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blob-event-quickstart) - **Good first contribution!** 40 | - [ ] Set generated blob metadata (modification time, caching, etc.) from page front matter 41 | - [ ] Add screenshots on how to [integrate with OneDrive using Flow/Logic Apps][flow] - **Good first contribution!** 42 | - [x] Flesh out example content/documentation and formatting tests 43 | - [x] Reformat asset links (images, stylesheets, etc.) 44 | - [x] Split blob storage access from rendering (`renderTemplateActivity`) 45 | - [x] Templating 46 | - [x] Image processing 47 | - [x] Simple rendering 48 | - [x] Sample content tree 49 | - [x] Basic engine 50 | 51 | ## Monitoring 52 | 53 | Since Azure Functions is usually deployed with Application Insights enabled, you can do real-time monitoring and tracing, as well as per-activity metrics: 54 | 55 | ![appinsights](sampleContent/docs/internals/appinsights.png) 56 | 57 | 58 | [n]: http://nodejs.org 59 | [azf]: https://docs.microsoft.com/en-us/azure/azure-functions/ 60 | [azdf]: https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-overview 61 | [azfree]: https://azure.microsoft.com/free/ 62 | [saw]: https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blob-static-website 63 | [flow]: https://flow.microsoft.com/en-us/galleries/public/templates/2f90b5d3-029b-4e2e-ad37-1c0fe6d187fe/when-a-file-is-uploaded-to-onedrive-copy-it-to-azure-storage-container/ 64 | 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | [Xx]64/ 19 | [Xx]86/ 20 | [Bb]uild/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | *.VC.db 84 | 85 | # Visual Studio profiler 86 | *.psess 87 | *.vsp 88 | *.vspx 89 | *.sap 90 | 91 | # TFS 2012 Local Workspace 92 | $tf/ 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | *.DotSettings.user 101 | 102 | # JustCode is a .NET coding add-in 103 | .JustCode 104 | 105 | # TeamCity is a build add-in 106 | _TeamCity* 107 | 108 | # DotCover is a Code Coverage Tool 109 | *.dotCover 110 | 111 | # NCrunch 112 | _NCrunch_* 113 | .*crunch*.local.xml 114 | nCrunchTemp_* 115 | 116 | # MightyMoose 117 | *.mm.* 118 | AutoTest.Net/ 119 | 120 | # Web workbench (sass) 121 | .sass-cache/ 122 | 123 | # Installshield output folder 124 | [Ee]xpress/ 125 | 126 | # DocProject is a documentation generator add-in 127 | DocProject/buildhelp/ 128 | DocProject/Help/*.HxT 129 | DocProject/Help/*.HxC 130 | DocProject/Help/*.hhc 131 | DocProject/Help/*.hhk 132 | DocProject/Help/*.hhp 133 | DocProject/Help/Html2 134 | DocProject/Help/html 135 | 136 | # Click-Once directory 137 | publish/ 138 | 139 | # Publish Web Output 140 | *.[Pp]ublish.xml 141 | *.azurePubxml 142 | 143 | # TODO: Un-comment the next line if you do not want to checkin 144 | # your web deploy settings because they may include unencrypted 145 | # passwords 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # NuGet Packages 150 | *.nupkg 151 | # The packages folder can be ignored because of Package Restore 152 | **/packages/* 153 | # except build/, which is used as an MSBuild target. 154 | !**/packages/build/ 155 | # Uncomment if necessary however generally it will be regenerated when needed 156 | #!**/packages/repositories.config 157 | # NuGet v3's project.json files produces more ignoreable files 158 | *.nuget.props 159 | *.nuget.targets 160 | 161 | # Microsoft Azure Build Output 162 | csx/ 163 | *.build.csdef 164 | 165 | # Microsoft Azure Emulator 166 | ecf/ 167 | rcf/ 168 | 169 | # Windows Store app package directory 170 | AppPackages/ 171 | BundleArtifacts/ 172 | 173 | # Visual Studio cache files 174 | # files ending in .cache can be ignored 175 | *.[Cc]ache 176 | # but keep track of directories ending in .cache 177 | !*.[Cc]ache/ 178 | 179 | # Others 180 | ClientBin/ 181 | [Ss]tyle[Cc]op.* 182 | ~$* 183 | *~ 184 | *.dbmdl 185 | *.dbproj.schemaview 186 | *.pfx 187 | *.publishsettings 188 | local.settings.json 189 | node_modules/ 190 | orleans.codegen.cs 191 | 192 | # RIA/Silverlight projects 193 | Generated_Code/ 194 | 195 | # Backup & report files from converting an old project file 196 | # to a newer Visual Studio version. Backup files are not needed, 197 | # because we have git ;-) 198 | _UpgradeReport_Files/ 199 | Backup*/ 200 | UpgradeLog*.XML 201 | UpgradeLog*.htm 202 | 203 | # SQL Server files 204 | *.mdf 205 | *.ldf 206 | 207 | # Business Intelligence projects 208 | *.rdl.data 209 | *.bim.layout 210 | *.bim_*.settings 211 | 212 | # Microsoft Fakes 213 | FakesAssemblies/ 214 | 215 | # GhostDoc plugin setting file 216 | *.GhostDoc.xml 217 | 218 | # Node.js Tools for Visual Studio 219 | .ntvs_analysis.dat 220 | 221 | # Visual Studio 6 build log 222 | *.plg 223 | 224 | # Visual Studio 6 workspace options file 225 | *.opt 226 | 227 | # Visual Studio LightSwitch build output 228 | **/*.HTMLClient/GeneratedArtifacts 229 | **/*.DesktopClient/GeneratedArtifacts 230 | **/*.DesktopClient/ModelManifest.xml 231 | **/*.Server/GeneratedArtifacts 232 | **/*.Server/ModelManifest.xml 233 | _Pvt_Extensions 234 | 235 | # LightSwitch generated files 236 | GeneratedArtifacts/ 237 | ModelManifest.xml 238 | 239 | # Paket dependency manager 240 | .paket/paket.exe 241 | 242 | # FAKE - F# Make 243 | .fake/ 244 | .DS_Store 245 | 246 | # Avoid bundling this 247 | .python-version 248 | .vscode 249 | --------------------------------------------------------------------------------