feat: full syntax for strftime, close #177

- strftime syntax: <flags><width><modifier><conversion>
- new conversions: %n, %t
- fixed conversions: %N
- flags: _ ^ - 0 # :
- modifiers: E, O (ignored)
This commit is contained in:
harttle
2019-12-15 02:28:33 +00:00
parent c69ad538d4
commit ba5ff3f41c
7 changed files with 368 additions and 253 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import { Expression } from '../../render/expression'
import { Context } from '../../context/context'
import { isArray } from '../../util/underscore'
import { isArray, identify } from '../../util/underscore'
import { FilterImplOptions } from './filter-impl-options'
type KeyValuePair = [string?, string?]
@@ -18,7 +18,7 @@ export class Filter {
if (!impl && strictFilters) throw new TypeError(`undefined filter: ${name}`)
this.name = name
this.impl = impl || (x => x)
this.impl = impl || identify
this.args = args
}
public * render (value: any, context: Context) {
+137 -151
View File
@@ -1,5 +1,6 @@
import { padStart } from './underscore'
import { changeCase, padStart, padEnd } from './underscore'
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
'September', 'October', 'November', 'December'
@@ -15,170 +16,155 @@ const suffixes = {
3: 'rd',
'default': 'th'
}
interface FormatOptions {
flags: object;
width?: string;
modifier?: string;
}
function abbr (str: string) {
return str.slice(0, 3)
}
// prototype extensions
const _date = {
daysInMonth: function (d: Date) {
const feb = _date.isLeapYear(d) ? 29 : 28
return [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
},
getDayOfYear: function (d: Date) {
let num = 0
for (let i = 0; i < d.getMonth(); ++i) {
num += _date.daysInMonth(d)[i]
}
return num + d.getDate()
},
getWeekOfYear: function (d: Date, startDay: number) {
// Skip to startDay of this week
const now = this.getDayOfYear(d) + (startDay - d.getDay())
// Find the first startDay of the year
const jan1 = new Date(d.getFullYear(), 0, 1)
const then = (7 - jan1.getDay() + startDay)
return padStart(String(Math.floor((now - then) / 7) + 1), 2, '0')
},
isLeapYear: function (d: Date) {
const year = d.getFullYear()
return !!((year & 3) === 0 && (year % 100 || (year % 400 === 0 && year)))
},
getSuffix: function (d: Date) {
const str = d.getDate().toString()
const index = parseInt(str.slice(-1))
return suffixes[index] || suffixes['default']
},
century: function (d: Date) {
return parseInt(d.getFullYear().toString().substring(0, 2), 10)
function daysInMonth (d: Date) {
const feb = isLeapYear(d) ? 29 : 28
return [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
}
function getDayOfYear (d: Date) {
let num = 0
for (let i = 0; i < d.getMonth(); ++i) {
num += daysInMonth(d)[i]
}
return num + d.getDate()
}
function getWeekOfYear (d: Date, startDay: number) {
// Skip to startDay of this week
const now = getDayOfYear(d) + (startDay - d.getDay())
// Find the first startDay of the year
const jan1 = new Date(d.getFullYear(), 0, 1)
const then = (7 - jan1.getDay() + startDay)
return String(Math.floor((now - then) / 7) + 1)
}
function isLeapYear (d: Date) {
const year = d.getFullYear()
return !!((year & 3) === 0 && (year % 100 || (year % 400 === 0 && year)))
}
function getSuffix (d: Date) {
const str = d.getDate().toString()
const index = parseInt(str.slice(-1))
return suffixes[index] || suffixes['default']
}
function century (d: Date) {
return parseInt(d.getFullYear().toString().substring(0, 2), 10)
}
// default to 0
const padWidths = {
d: 2,
e: 2,
H: 2,
I: 2,
j: 3,
k: 2,
l: 2,
L: 3,
m: 2,
M: 2,
S: 2,
U: 2,
W: 2
}
// default to '0'
const padChars = {
a: ' ',
A: ' ',
b: ' ',
B: ' ',
c: ' ',
e: ' ',
k: ' ',
l: ' ',
p: ' ',
P: ' '
}
const formatCodes = {
a: function (d: Date) {
return dayNamesShort[d.getDay()]
a: (d: Date) => dayNamesShort[d.getDay()],
A: (d: Date) => dayNames[d.getDay()],
b: (d: Date) => monthNamesShort[d.getMonth()],
B: (d: Date) => monthNames[d.getMonth()],
c: (d: Date) => d.toLocaleString(),
C: (d: Date) => century(d),
d: (d: Date) => d.getDate(),
e: (d: Date) => d.getDate(),
H: (d: Date) => d.getHours(),
I: (d: Date) => String(d.getHours() % 12 || 12),
j: (d: Date) => getDayOfYear(d),
k: (d: Date) => d.getHours(),
l: (d: Date) => String(d.getHours() % 12 || 12),
L: (d: Date) => d.getMilliseconds(),
m: (d: Date) => d.getMonth() + 1,
M: (d: Date) => d.getMinutes(),
N: (d: Date, opts: FormatOptions) => {
const width = Number(opts.width) || 9
const str = String(d.getMilliseconds()).substr(0, width)
return padEnd(str, width, '0')
},
A: function (d: Date) {
return dayNames[d.getDay()]
p: (d: Date) => (d.getHours() < 12 ? 'AM' : 'PM'),
P: (d: Date) => (d.getHours() < 12 ? 'am' : 'pm'),
q: (d: Date) => getSuffix(d),
s: (d: Date) => Math.round(d.valueOf() / 1000),
S: (d: Date) => d.getSeconds(),
u: (d: Date) => d.getDay() || 7,
U: (d: Date) => getWeekOfYear(d, 0),
w: (d: Date) => d.getDay(),
W: (d: Date) => getWeekOfYear(d, 1),
x: (d: Date) => d.toLocaleDateString(),
X: (d: Date) => d.toLocaleTimeString(),
y: (d: Date) => d.getFullYear().toString().substring(2, 4),
Y: (d: Date) => d.getFullYear(),
z: (d: Date, opts: FormatOptions) => {
const offset = d.getTimezoneOffset()
const nOffset = Math.abs(offset)
const h = Math.floor(nOffset / 60)
const m = nOffset % 60
return (offset > 0 ? '-' : '+') +
padStart(h, 2, '0') +
(opts.flags[':'] ? ':' : '') +
padStart(m, 2, '0')
},
b: function (d: Date) {
return monthNamesShort[d.getMonth()]
},
B: function (d: Date) {
return monthNames[d.getMonth()]
},
c: function (d: Date) {
return d.toLocaleString()
},
C: function (d: Date) {
return _date.century(d)
},
d: function (d: Date) {
return padStart(d.getDate(), 2, '0')
},
e: function (d: Date) {
return padStart(d.getDate(), 2)
},
H: function (d: Date) {
return padStart(d.getHours(), 2, '0')
},
I: function (d: Date) {
return padStart(String(d.getHours() % 12 || 12), 2, '0')
},
j: function (d: Date) {
return padStart(_date.getDayOfYear(d), 3, '0')
},
k: function (d: Date) {
return padStart(d.getHours(), 2)
},
l: function (d: Date) {
return padStart(String(d.getHours() % 12 || 12), 2)
},
L: function (d: Date) {
return padStart(d.getMilliseconds(), 3, '0')
},
m: function (d: Date) {
return padStart(d.getMonth() + 1, 2, '0')
},
M: function (d: Date) {
return padStart(d.getMinutes(), 2, '0')
},
p: function (d: Date) {
return (d.getHours() < 12 ? 'AM' : 'PM')
},
P: function (d: Date) {
return (d.getHours() < 12 ? 'am' : 'pm')
},
q: function (d: Date) {
return _date.getSuffix(d)
},
s: function (d: Date) {
return Math.round(d.valueOf() / 1000)
},
S: function (d: Date) {
return padStart(d.getSeconds(), 2, '0')
},
u: function (d: Date) {
return d.getDay() || 7
},
U: function (d: Date) {
return _date.getWeekOfYear(d, 0)
},
w: function (d: Date) {
return d.getDay()
},
W: function (d: Date) {
return _date.getWeekOfYear(d, 1)
},
x: function (d: Date) {
return d.toLocaleDateString()
},
X: function (d: Date) {
return d.toLocaleTimeString()
},
y: function (d: Date) {
return d.getFullYear().toString().substring(2, 4)
},
Y: function (d: Date) {
return d.getFullYear()
},
z: function (d: Date) {
const tz = d.getTimezoneOffset() / 60 * 100
return (tz > 0 ? '-' : '+') + padStart(String(Math.abs(tz)), 4, '0')
},
'%': function () {
return '%'
}
't': () => '\t',
'n': () => '\n',
'%': () => '%'
};
(formatCodes as any).h = formatCodes.b;
(formatCodes as any).N = formatCodes.L
(formatCodes as any).h = formatCodes.b
export default function (d: Date, format: string) {
export default function (d: Date, formatStr: string) {
let output = ''
let remaining = format
while (true) {
const r = /%./g
const results = r.exec(remaining)
// No more format codes. Add the remaining text and return
if (!results) {
return output + remaining
}
// Add the preceding text
output += remaining.slice(0, r.lastIndex - 2)
remaining = remaining.slice(r.lastIndex)
// Add the format code
const ch = results[0].charAt(1)
const func = formatCodes[ch]
output += func ? func(d) : '%' + ch
let remaining = formatStr
let match
while ((match = rFormat.exec(remaining))) {
output += remaining.slice(0, match.index)
remaining = remaining.slice(match.index + match[0].length)
output += format(d, match)
}
return output + remaining
}
function format (d: Date, match: RegExpExecArray) {
const [input, flagStr = '', width, modifier, conversion] = match
const convert = formatCodes[conversion]
if (!convert) return input
const flags = {}
for (const flag of flagStr) flags[flag] = true
let ret = String(convert(d, { flags, width, modifier }))
let padChar = padChars[conversion] || '0'
let padWidth = width || padWidths[conversion] || 0
if (flags['^']) ret = ret.toUpperCase()
else if (flags['#']) ret = changeCase(ret)
if (flags['_']) padChar = ' '
else if (flags['0']) padChar = '0'
if (flags['-']) padWidth = 0
return padStart(ret, padWidth, padChar)
}
+18 -1
View File
@@ -101,8 +101,25 @@ export function range (start: number, stop: number, step = 1) {
}
export function padStart (str: any, length: number, ch = ' ') {
return pad(str, length, ch, (str, ch) => ch + str)
}
export function padEnd (str: any, length: number, ch = ' ') {
return pad(str, length, ch, (str, ch) => str + ch)
}
export function pad (str: any, length: number, ch: string, add: (str: string, ch: string) => string) {
str = String(str)
let n = length - str.length
while (n-- > 0) str = ch + str
while (n-- > 0) str = add(str, ch)
return str
}
export function identify<T> (val: T): T {
return val
}
export function changeCase (str: string): string {
const hasLowerCase = [...str].some(ch => ch >= 'a' && ch <= 'z')
return hasLowerCase ? str.toUpperCase() : str.toLowerCase()
}
+1 -1
View File
@@ -12,7 +12,7 @@ describe('LiquidOptions#fs', function () {
engine = new Liquid({
root: '/root/',
fs
})
} as any)
})
it('should be used to read templates', function () {
return engine.renderFile('files/foo')
+2 -2
View File
@@ -18,8 +18,8 @@ describe('Liquid', function () {
})
it('should call plugin with Liquid', async function () {
const engine = new Liquid()
engine.plugin(function (Liquid) {
this.registerFilter('t', x => isFalsy(x))
engine.plugin(function () {
this.registerFilter('t', isFalsy)
})
const html = await engine.parseAndRender('{{false|t}}')
expect(html).to.equal('true')
+195 -96
View File
@@ -7,114 +7,213 @@ describe('util/strftime', function () {
const now = new Date('2016-01-04 13:15:23')
const then = new Date('2016-03-06 03:05:03')
it('should format detailed datetime', function () {
expect(t(now, '%Y-%m-%d %H:%M:%S')).to.equal('2016-01-04 13:15:23')
})
it('should format %A as Monday', function () {
expect(t(now, '%A')).to.equal('Monday')
})
it('should format %B as month name', function () {
expect(t(now, '%B')).to.equal('January')
})
it('should format %C as century', function () {
expect(t(now, '%C')).to.equal('20')
})
it('should format %c as local string', function () {
expect(t(now, '%c')).to.equal(now.toLocaleString())
})
it('should format %e as space padded date', function () {
expect(t(now, '%e')).to.equal(' 4')
})
it('should format %I as 0 padded hour12', function () {
expect(t(now, '%I')).to.equal('01')
})
it('should format %I as 12 for 00:00', function () {
const date = new Date('2016-01-01 00:00:00')
expect(t(date, '%I')).to.equal('12')
})
describe('%j', function () {
it('should format %j as day of year', function () {
expect(t(then, '%j')).to.equal('066')
describe('Date (Year, Month, Day)', () => {
it('should format %C as century', function () {
expect(t(now, '%C')).to.equal('20')
})
it('should take count of leap years', function () {
const date = new Date('2001-03-01')
expect(t(date, '%j')).to.equal('060')
it('should format %B as month name', function () {
expect(t(now, '%B')).to.equal('January')
})
it('should take count of leap years', function () {
const date = new Date('2000-03-01')
expect(t(date, '%j')).to.equal('061')
it('should format %e as space padded date', function () {
expect(t(now, '%e')).to.equal(' 4')
})
it('should format %y as 2-digit year', function () {
expect(t(now, '%y')).to.equal('16')
})
describe('%j', function () {
it('should format %j as day of year', function () {
expect(t(then, '%j')).to.equal('066')
})
it('should take count of leap years', function () {
const date = new Date('2001-03-01')
expect(t(date, '%j')).to.equal('060')
})
it('should take count of leap years', function () {
const date = new Date('2000-03-01')
expect(t(date, '%j')).to.equal('061')
})
})
it('should format %q as date suffix', function () {
const st = new Date('2016-03-01T03:05:03.000Z')
const nd = new Date('2016-03-02T03:05:03.000Z')
const rd = new Date('2016-03-03T03:05:03.000Z')
expect(t(st, '%q')).to.equal('st')
expect(t(nd, '%q')).to.equal('nd')
expect(t(rd, '%q')).to.equal('rd')
expect(t(now, '%q')).to.equal('th')
})
})
it('should format %k as space padded hour', function () {
expect(t(then, '%k')).to.equal(' 3')
describe('Time (Hour, Minute, Second, Subsecond)', function () {
it('should format %I as 0 padded hour12', function () {
expect(t(now, '%I')).to.equal('01')
})
it('should format %I as 12 for 00:00', function () {
const date = new Date('2016-01-01 00:00:00')
expect(t(date, '%I')).to.equal('12')
})
it('should format %k as space padded hour', function () {
expect(t(then, '%k')).to.equal(' 3')
})
it('should format %l as space padded hour12', function () {
expect(t(now, '%l')).to.equal(' 1')
})
it('should format %l as 12 for 00:00', function () {
const date = new Date('2016-01-01 00:00:00')
expect(t(date, '%l')).to.equal('12')
})
it('should format %L as 0 padded millisecond', function () {
expect(t(then, '%L')).to.equal('000')
})
it('should format %N as fractional seconds digits', function () {
const time = new Date('2019-12-15 01:21:00.129')
expect(t(time, '%N')).to.equal('129000000')
expect(t(time, '%2N')).to.equal('12')
expect(t(time, '%10N')).to.equal('1290000000')
expect(t(time, '%0N')).to.equal('129000000')
})
it('should format %p as upper cased am/pm', function () {
expect(t(now, '%p')).to.equal('PM')
expect(t(then, '%p')).to.equal('AM')
})
it('should format %P as lower cased am/pm', function () {
expect(t(now, '%P')).to.equal('pm')
expect(t(now, '%^8P')).to.equal(' PM')
expect(t(then, '%P')).to.equal('am')
})
})
it('should format %l as space padded hour12', function () {
expect(t(now, '%l')).to.equal(' 1')
describe('Weekday', function () {
it('should format %A as Monday', function () {
expect(t(now, '%A')).to.equal('Monday')
expect(t(now, '%^A')).to.equal('MONDAY')
expect(t(now, '%#A')).to.equal('MONDAY')
})
it('should format %a as Mon', function () {
expect(t(now, '%a')).to.equal('Mon')
expect(t(now, '%^a')).to.equal('MON')
})
it('should format %u as day of week(1-7)', function () {
expect(t(now, '%u')).to.be.equal('1')
expect(t(then, '%u')).to.be.equal('7')
})
it('should format %w as day of week(0-7)', function () {
expect(t(now, '%w')).to.be.equal('1')
expect(t(then, '%w')).to.be.equal('0')
})
})
it('should format %l as 12 for 00:00', function () {
const date = new Date('2016-01-01 00:00:00')
expect(t(date, '%l')).to.equal('12')
describe('Seconds since the Unix Epoch', () => {
it('should format %s as UNIX seconds', function () {
expect(t(now, '%s')).to.be.match(/\d+/)
})
})
it('should format %L as 0 padded millisecond', function () {
expect(t(then, '%L')).to.equal('000')
describe('Week number', () => {
it('should format %U as week of year, starts with 0', function () {
expect(t(now, '%U')).to.equal('01')
})
it('should format %W as week of year, starts with 1', function () {
expect(t(now, '%W')).to.be.equal('01')
})
})
it('should format %p as upper cased am/pm', function () {
expect(t(now, '%p')).to.equal('PM')
expect(t(then, '%p')).to.equal('AM')
describe('Time zone', () => {
it('should format %z as time zone', function () {
const now = new Date('2016-01-04 13:15:23')
now.getTimezoneOffset = () => -480 // suppose we're in +8:00
expect(t(now, '%z')).to.equal('+0800')
})
it('should format %z as negative time zone', function () {
const date = new Date('2016-01-04T13:15:23.000Z')
date.getTimezoneOffset = () => 480 // suppose we're in -8:00
expect(t(date, '%z')).to.equal('-0800')
})
})
it('should format %P as lower cased am/pm', function () {
expect(t(now, '%P')).to.equal('pm')
expect(t(then, '%P')).to.equal('am')
describe('combination', () => {
it('should format %x as local date string', function () {
expect(t(now, '%x')).to.equal(now.toLocaleDateString())
})
it('should format %X as local time string', function () {
expect(t(now, '%X')).to.equal(now.toLocaleTimeString())
})
it('should format detailed datetime', function () {
expect(t(now, '%Y-%m-%d %H:%M:%S')).to.equal('2016-01-04 13:15:23')
})
it('should format %c as local string', function () {
expect(t(now, '%c')).to.equal(now.toLocaleString())
})
})
it('should format %q as date suffix', function () {
const st = new Date('2016-03-01T03:05:03.000Z')
const nd = new Date('2016-03-02T03:05:03.000Z')
const rd = new Date('2016-03-03T03:05:03.000Z')
expect(t(st, '%q')).to.equal('st')
expect(t(nd, '%q')).to.equal('nd')
expect(t(rd, '%q')).to.equal('rd')
expect(t(now, '%q')).to.equal('th')
describe('literal strings', () => {
it('should escape %% as %', function () {
expect(t(now, '%%')).to.equal('%')
})
it('should escape %n as \\n', function () {
expect(t(now, '%n')).to.equal('\n')
})
it('should escape %t as \\t', function () {
expect(t(now, '%t')).to.equal('\t')
})
it('should retain un-recognized formaters', function () {
expect(t(now, '%o')).to.equal('%o')
})
})
it('should format %s as UNIX seconds', function () {
expect(t(now, '%s')).to.be.match(/\d+/)
describe('width field', () => {
it('should support width field', () => {
expect(t(now, '%8Y')).to.equal('00002016')
})
it('should ignore invalid width', () => {
expect(t(then, '%1Y')).to.equal('2016')
expect(t(then, '%1H')).to.equal('3')
})
it('should have higher priority than H', () => {
expect(t(then, '%0H')).to.equal('03')
})
})
it('should format %u as day of week(1-7)', function () {
expect(t(now, '%u')).to.be.equal('1')
expect(t(then, '%u')).to.be.equal('7')
describe('modifier field', () => {
it('should ignore E modifier', () => {
expect(t(now, '%EY')).to.equal('2016')
})
it('should ignore O modifier', () => {
expect(t(now, '%OY')).to.equal('2016')
})
it('should support modifier with width field', () => {
expect(t(now, '%8EY')).to.equal('00002016')
})
})
it('should format %U as week of year, starts with 0', function () {
expect(t(now, '%U')).to.equal('01')
})
it('should format %w as day of month(0-7)', function () {
expect(t(now, '%w')).to.be.equal('1')
expect(t(then, '%w')).to.be.equal('0')
})
it('should format %W as week of year, starts with 1', function () {
expect(t(now, '%W')).to.be.equal('01')
})
it('should format %x as local date string', function () {
expect(t(now, '%x')).to.equal(now.toLocaleDateString())
})
it('should format %X as local time string', function () {
expect(t(now, '%X')).to.equal(now.toLocaleTimeString())
})
it('should format %y as 2-digit year', function () {
expect(t(now, '%y')).to.equal('16')
})
it('should format %z as time zone', function () {
const now = new Date('2016-01-04 13:15:23')
now.getTimezoneOffset = () => -480 // suppose we're in +8:00
expect(t(now, '%z')).to.equal('+0800')
})
it('should format %z as negative time zone', function () {
const date = new Date('2016-01-04T13:15:23.000Z')
date.getTimezoneOffset = () => 480 // suppose we're in -8:00
expect(t(date, '%z')).to.equal('-0800')
})
it('should escape %% as %', function () {
expect(t(now, '%%')).to.equal('%')
})
it('should retain un-recognized formaters', function () {
expect(t(now, '%o')).to.equal('%o')
describe('flags field', () => {
it('should support - flag', () => {
expect(t(now, '%-m')).to.equal('1')
})
it('should support _ flag', () => {
expect(t(now, '%_m')).to.equal(' 1')
})
it('should support 0 flag', () => {
expect(t(now, '%0m')).to.equal('01')
})
it('should support ^ flag', () => {
expect(t(now, '%^B')).to.equal('JANUARY')
})
it('should respect to specific conversion', () => {
expect(t(now, '%^P')).to.equal('PM')
expect(t(now, '%P')).to.equal('pm')
})
it('should support # flag', () => {
expect(t(now, '%#B')).to.equal('JANUARY')
expect(t(now, '%#P')).to.equal('PM')
})
it('should support : flag', () => {
const date = new Date('2016-01-04T13:15:23.000Z')
date.getTimezoneOffset = () => -480 // suppose we're in +8:00
expect(t(date, '%:z')).to.equal('+08:00')
expect(t(date, '%z')).to.equal('+0800')
})
it('should support multiple flags', () => {
expect(t(now, '%^08P')).to.equal('000000PM')
})
})
})
+13
View File
@@ -95,4 +95,17 @@ describe('util/underscore', function () {
expect(_.isObject(2)).to.be.false
})
})
describe('.padEnd()', function () {
it('should default ch to " "', () => {
expect(_.padEnd('foo', 5)).to.equal('foo ')
})
})
describe('.changeCase()', function () {
it('should to upper case if there is one lowercase', () => {
expect(_.changeCase('fooA')).to.equal('FOOA')
})
it('should to lower case if all upper case', () => {
expect(_.changeCase('FOOA')).to.equal('fooa')
})
})
})