Skip to content
This repository was archived by the owner on Oct 11, 2021. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: CI
on: push
jobs:
test:
name: Test node ${{ matrix.node }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
node: [10, 12, 13]
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v2
- name: Use node ${{ matrix.node }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm i
- name: Test
run: npm run test
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 13
- name: Install dependencies
run: npm i
- name: Lint
run: npm run lint
coverage:
needs: [ test ]
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 13
- name: Install dependencies
run: npm i
- name: Coverage
uses: paambaati/[email protected]
env:
CC_TEST_REPORTER_ID: ${{ secrets.CODECLIMATE_TOKEN }}
with:
coverageCommand: npm run coverage
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,30 @@
# koa-sassy
# koa-sassy

> Modern Node.js Koa Middleware for Compiling and Serving Sass 🎨

[Sass](https://sass-lang.com/) is the most mature, stable, and powerful professional grade CSS extension language in the world.

Why use `koa-sassy` to compile and serve your Sass :thinking:

- Lighting fast - complied files are cached in memory :rocket:
- Built-in [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) support :bookmark:
- Control the cache-control header :clock1:
- Easy to use API :package:

## Install

``` shell
npm i koa-sassy
```

## Usage

``` js
const Koa = require('koa')
const sassy = require('koa-sassy')
const app = new Koa()

app.use(sassy('/sass'))

app.listen(3000)
```
68 changes: 68 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use strict'

/* MODULE DEPENDENCIES */
const sass = require('node-sass')
const fs = require('fs-extra')
const etag = require('etag')
const path = require('path')

/* MODULE VARIABLES */
const ONE_YEAR_MS = 60 * 60 * 24 * 365 // one year in seconds
const ONE_DAY_MS = 60 * 60 * 24 // one day in seconds

function middleware (src, options) {
if (!src) throw new Error('[koa-sassy] src path is required')
if (!fs.existsSync(src)) throw new Error('[koa-sassy] src path must exist')
if (!fs.statSync(src).isDirectory()) throw new Error('[koa-sassy] src path must be directory not file')

options = options || {}
options.mount = options.mount == null ? '' : options.mount
options.maxAge = options.maxAge == null ? ONE_DAY_MS : Math.min(Math.max(0, options.maxAge), ONE_YEAR_MS)

const sassFiles = fs.readdirSync(src)
.filter(fileName => fileName.endsWith('.sass'))
.map(fileName => path.resolve(src, fileName))
.map(filePath => {
return {
path: filePath,
name: path.basename(filePath).slice(0, -5),
css: sass.renderSync({ file: filePath }).css,
mtimeMs: fs.statSync(filePath).mtimeMs
}
})

return (ctx, next) => {
const regex = new RegExp(`^${options.mount}/[^/<>\\^%]*.css$`)
if (!regex.test(ctx.path)) return next()
if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
ctx.status = ctx.method === 'OPTIONS' ? 200 : 405
ctx.set('Allow', 'GET, HEAD, OPTIONS')
} else {
const cssName = path.basename(ctx.path, '.css')
const sassNames = sassFiles.map(sassFile => { return sassFile.name })
const casheIndex = sassNames.indexOf(cssName)
if (casheIndex < 0) {
ctx.status = 404
return
}
const sassFile = sassFiles[casheIndex]
const mtimeMs = fs.statSync(sassFile.path).mtimeMs
if (sassFile.mtimeMs < mtimeMs) {
sassFiles[casheIndex].css = sass.renderSync({ file: sassFiles[casheIndex].path }).css
sassFiles[casheIndex].mtimeMs = mtimeMs
}
ctx.set('Cache-Control', `public, max-age=${options.maxAge}`)
ctx.type = 'text/css;charset=utf-8'
ctx.status = 200
ctx.set('ETag', etag(sassFile.css))
if (ctx.fresh) {
ctx.status = 304
return
}
ctx.body = sassFile.css
return ctx
}
}
}

module.exports = middleware
42 changes: 42 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "koa-sassy",
"version": "0.0.0",
"description": "Modern Node.js Koa Middleware for Compiling and Serving Sass 🎨",
"main": "index.js",
"scripts": {
"test": "jest",
"lint": "standard",
"coverage": "jest --coverage"
},
"repository": {
"type": "git",
"url": "git+https://github.com/dominicegginton/koa-sassy.git"
},
"keywords": [
"koa",
"middleware",
"sass"
],
"author": {
"name": "Dominic Egginton",
"email": "[email protected]",
"url": "https://dominicegginton.com"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/dominicegginton/koa-sassy/issues",
"email": "[email protected]"
},
"homepage": "https://github.com/dominicegginton/koa-sassy#readme",
"devDependencies": {
"jest": "^25.3.0",
"koa": "^2.11.0",
"standard": "^14.3.3",
"supertest": "^4.0.2"
},
"dependencies": {
"etag": "^1.8.1",
"fs-extra": "^9.0.0",
"node-sass": "^4.13.1"
}
}
3 changes: 3 additions & 0 deletions test/assets/includes/vars.sass
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
$red: rgb(225, 0, 0)
$green: rgb(0, 225, 0)
$blue: rgb(0, 0, 225)
13 changes: 13 additions & 0 deletions test/assets/one.sass
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@import includes/vars

html
background: black

h1
color: $red

h2
color: $green

h3
color: $blue
1 change: 1 addition & 0 deletions test/assets/three.sass
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import includes/vars
1 change: 1 addition & 0 deletions test/assets/two.sass
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import includes/vars
166 changes: 166 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/* eslint-env jest */
'use strict'

/* IMPORT TEST */
const sassy = require('..')

/* IMPORT MODULES */
const Koa = require('koa')
const supertest = require('supertest')
const fs = require('fs-extra')

beforeEach(() => {
this.app = new Koa()
this.request = supertest(this.app.callback())
})

describe('sassy()', () => {
describe('arguments', () => {
describe('src', () => {
test('should throw error when null', async () => {
expect(() => { this.app.use(sassy()) }).toThrow(new Error('[koa-sassy] src path is required'))
})

test('should throw error if src path does not exist', async () => {
expect(() => { this.app.use(sassy('./invalid')) }).toThrow(new Error('[koa-sassy] src path must exist'))
})

test('should throw error if src path is file not directory', async () => {
expect(() => { this.app.use(sassy('./test/assets/one.sass')) }).toThrow(new Error('[koa-sassy] src path must be directory not file'))
})
})

describe('options.maxAge', () => {
test('should set valid delta seconds', async () => {
this.app.use(sassy('./test/assets', { maxAge: 200 }))
const res = await this.request.get('/one.css')
expect(res.header['cache-control']).toBe('public, max-age=200')
})

test('should default to one day', async () => {
this.app.use(sassy('./test/assets', {}))
const res = await this.request.get('/one.css')
expect(res.header['cache-control']).toBe('public, max-age=86400')
})

test('should accept 0', async () => {
this.app.use(sassy('./test/assets', { maxAge: 0 }))
const res = await this.request.get('/one.css')
expect(res.header['cache-control']).toBe('public, max-age=0')
})

test('should floor at 0', async () => {
this.app.use(sassy('./test/assets', { maxAge: -100 }))
const res = await this.request.get('/one.css')
expect(res.header['cache-control']).toBe('public, max-age=0')
})

test('should ceil at 1 year', async () => {
this.app.use(sassy('./test/assets', { maxAge: 999999999999 }))
const res = await this.request.get('/one.css')
expect(res.header['cache-control']).toBe('public, max-age=31536000')
})

test('should accept infinity ', async () => {
this.app.use(sassy('./test/assets', { maxAge: Infinity }))
const res = await this.request.get('/one.css')
expect(res.header['cache-control']).toBe('public, max-age=31536000')
})
})

describe('options.mount', () => {
test('defult is /', async () => {
this.app.use(sassy('./test/assets', {}))
const res = await this.request.get('/one.css')
expect(res.status).toBe(200)
})

test('should set valid mount point', async () => {
this.app.use(sassy('./test/assets', { mount: '/stylesheet' }))
const res = await this.request.get('/stylesheet/one.css')
expect(res.status).toBe(200)
})
})
})

describe('requests', () => {
test('GET / ', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.get('/one.css')
expect(res.status).toBe(200)
})

test('should serve css file with @/{filename}.css query string', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.get('/one.css?v=1')
expect(res.status).toBe(200)
})

test('should not serve css file !@/{filename}.css', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.get('/')
expect(res.status).not.toBe(200)
})

test('should include cache-control header', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.get('/one.css')
expect(res.status).toBe(200)
expect(res.header['cache-control']).toBe('public, max-age=86400')
})

test('should include strong etag header', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.get('/one.css')
expect(res.status).toBe(200)
expect(res.header.etag).not.toBe(null)
expect(typeof res.header.etag).toBe('string')
})

test('should not accept POST requests', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.post('/one.css')
expect(res.status).toBe(405)
expect(res.header.allow).toBe('GET, HEAD, OPTIONS')
})

test('should accept OPTIONS request', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.options('/one.css')
expect(res.status).toBe(200)
expect(res.header.allow).toBe('GET, HEAD, OPTIONS')
})

test('should respond with 404 when file can not be found', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.get('/main.css')
expect(res.status).toBe(404)
})

test('should respond with 304 when If-None-Match header matches', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.get('/one.css')
expect(res.status).toBe(200)
const res2 = await this.request.get('/one.css').set('If-None-Match', res.header.etag)
expect(res2.status).toBe(304)
})

test('should update cashed file when sass file is modified', async () => {
this.app.use(sassy('./test/assets'))
const res = await this.request.get('/one.css')
expect(res.status).toBe(200)
const file = fs.readFileSync('./test/assets/one.sass')
fs.writeFileSync('./test/assets/one.sass', file)
const res2 = await this.request.get('/one.css')
expect(res2.status).toBe(200)
})

test('should serve css file on mount', async () => {
this.app.use(sassy('./test/assets', { mount: '/stylesheet' }))
const res = await this.request.get('/stylesheet/one.css')
expect(res.status).toBe(200)
const res2 = await this.request.get('/one.css')
expect(res2.status).toBe(404)
})
})
})