Typescript rewrite #9
|
@ -1,4 +1,2 @@
|
|||
*.swp
|
||||
bin/
|
||||
build/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
|
|
|
@ -1,72 +0,0 @@
|
|||
module.exports = (grunt) ->
|
||||
pkg = require './package.json'
|
||||
grunt.initConfig
|
||||
pkg: pkg
|
||||
|
||||
coffee:
|
||||
compile:
|
||||
options:
|
||||
join: true
|
||||
bare: true
|
||||
files: [
|
||||
'bin/ficdown.js': ['src/*.coffee']
|
||||
]
|
||||
|
||||
stylus:
|
||||
compile:
|
||||
options:
|
||||
compress: true
|
||||
expand: true
|
||||
files: [
|
||||
'bin/example/player.css': ['src/example/*.styl']
|
||||
]
|
||||
|
||||
uglify:
|
||||
js:
|
||||
files:
|
||||
'bin/ficdown.min.js': [
|
||||
'bin/ficdown.js'
|
||||
]
|
||||
|
||||
copy:
|
||||
static:
|
||||
files: [
|
||||
expand: true
|
||||
flatten: true
|
||||
src: ['src/*.html']
|
||||
dest: 'bin/'
|
||||
]
|
||||
example:
|
||||
files: [
|
||||
expand: true
|
||||
flatten: true
|
||||
src: ['src/example/*.html', 'src/example/*.png', 'src/example/*.md', 'bin/ficdown.min.js']
|
||||
dest: 'bin/example/'
|
||||
]
|
||||
|
||||
watch:
|
||||
js:
|
||||
files: ['src/**/*.coffee']
|
||||
tasks: ['build:js', 'copy:example']
|
||||
css:
|
||||
files: ['src/**/*.styl']
|
||||
tasks: ['stylus:compile']
|
||||
static:
|
||||
files: ['src/**/*.html','src/**/*.js','src/**/*.md']
|
||||
tasks: ['copy:static', 'copy:example']
|
||||
|
||||
for name of pkg.devDependencies when name.substring(0, 6) is 'grunt-'
|
||||
grunt.loadNpmTasks name
|
||||
|
||||
grunt.registerTask 'build:js', [
|
||||
'coffee:compile'
|
||||
'uglify:js'
|
||||
]
|
||||
|
||||
grunt.registerTask 'default', [
|
||||
'coffee:compile'
|
||||
'uglify:js'
|
||||
'stylus:compile'
|
||||
'copy:static'
|
||||
'copy:example'
|
||||
]
|
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
|
@ -2,14 +2,13 @@
|
|||
"name": "ficdown.js",
|
||||
"version": "0.9.1",
|
||||
"description": "A parser and player for Interactive Fiction written in Ficdown",
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"jquery": "^3.4.0",
|
||||
"markdown-it": "^8.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"coffee-script": "^1.7.1",
|
||||
"grunt": "^0.4.5",
|
||||
"grunt-contrib-coffee": "^0.10.1",
|
||||
"grunt-contrib-uglify": "^0.5.0",
|
||||
"grunt-contrib-copy": "^0.5.0",
|
||||
"grunt-contrib-watch": "^0.6.1",
|
||||
"grunt-contrib-stylus": "^0.17.0"
|
||||
"@types/jquery": "^3.3.29",
|
||||
"@types/markdown-it": "0.0.7",
|
||||
"typescript": "^3.4.5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export type Action = {
|
||||
lineNumber: number,
|
||||
state: string,
|
||||
description: string,
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export type AltText = {
|
||||
passed: string,
|
||||
failed: string,
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export type Anchor = {
|
||||
anchor: string,
|
||||
text: string,
|
||||
href: string,
|
||||
title: string,
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { Line } from './';
|
||||
|
||||
export enum BlockType {
|
||||
Action,
|
||||
Scene,
|
||||
Story,
|
||||
}
|
||||
|
||||
export type Block = {
|
||||
lineNumber: number,
|
||||
type: BlockType,
|
||||
name: string,
|
||||
lines: Line[],
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export type BoolHash = { [name: string]: boolean }
|
|
@ -0,0 +1,5 @@
|
|||
export type Href = {
|
||||
target: string,
|
||||
conditions: string,
|
||||
toggles: string,
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export type Line = {
|
||||
lineNumber: number,
|
||||
text: string,
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export class ParseError extends Error {
|
||||
constructor(message: string, public lineNumber: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export type PlayerOptions = {
|
||||
source: string,
|
||||
id: string,
|
||||
startText?: string,
|
||||
endText?: string,
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { BoolHash } from './';
|
||||
|
||||
export type Scene = {
|
||||
lineNumber: number,
|
||||
name: string,
|
||||
key: string,
|
||||
description: string,
|
||||
conditions?: BoolHash,
|
||||
id?: string;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { Scene, Action } from './';
|
||||
|
||||
export type Story = {
|
||||
name: string,
|
||||
description: string,
|
||||
firstScene: string,
|
||||
scenes: { [key: string]: Scene[] },
|
||||
actions: { [key: string]: Action },
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export * from './Action';
|
||||
export * from './AltText';
|
||||
export * from './Anchor';
|
||||
export * from './Block';
|
||||
export * from './BoolHash';
|
||||
export * from './Href';
|
||||
export * from './Line';
|
||||
export * from './ParseError';
|
||||
export * from './PlayerOptions';
|
||||
export * from './Scene';
|
||||
export * from './Story';
|
|
@ -0,0 +1,117 @@
|
|||
import {
|
||||
Action,
|
||||
Block,
|
||||
BlockType,
|
||||
BoolHash,
|
||||
Line,
|
||||
ParseError,
|
||||
Scene,
|
||||
Story,
|
||||
} from './Model';
|
||||
import { Util } from './Util';
|
||||
|
||||
export class Parser {
|
||||
public static parse(source: string): Story {
|
||||
const lines = source.split(/\n|\r\n/);
|
||||
const blocks = this.extractBlocks(lines);
|
||||
return this.parseBlocks(blocks);
|
||||
}
|
||||
|
||||
private static getBlockType(hashCount: 1 | 2 | 3): BlockType {
|
||||
switch(hashCount) {
|
||||
case 1: return BlockType.Story;
|
||||
case 2: return BlockType.Scene;
|
||||
case 3: return BlockType.Action;
|
||||
}
|
||||
}
|
||||
|
||||
private static extractBlocks(lines: string[]): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
let currentBlock: Block | undefined = undefined;
|
||||
for(let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^(#{1,3})\s+([^#].*)$/);
|
||||
if(match) {
|
||||
if(currentBlock) blocks.push(currentBlock);
|
||||
currentBlock = {
|
||||
lineNumber: i,
|
||||
type: this.getBlockType(<1 | 2 | 3>match[1].length),
|
||||
name: match[2],
|
||||
lines: [],
|
||||
};
|
||||
} else if(currentBlock) {
|
||||
currentBlock.lines.push({ lineNumber: i, text: lines[i] });
|
||||
}
|
||||
}
|
||||
if(currentBlock) blocks.push(currentBlock);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static parseBlocks(blocks: Block[]): Story {
|
||||
let storyBlock: Block | undefined =
|
||||
blocks.find(b => b.type === BlockType.Story);
|
||||
|
||||
if(!storyBlock) throw new ParseError('no story block', 0);
|
||||
const storyName = Util.matchAnchor(storyBlock.name);
|
||||
if(!storyName) throw new ParseError('no story name', storyBlock.lineNumber);
|
||||
const storyHref = Util.matchHref(storyName.href);
|
||||
if(!storyHref) throw new ParseError('no link to first scene', storyBlock.lineNumber);
|
||||
|
||||
const story: Story = {
|
||||
name: storyName.text,
|
||||
description: Util.trimText(storyBlock.lines.join("\n")),
|
||||
firstScene: storyHref.target,
|
||||
scenes: {},
|
||||
actions: {},
|
||||
};
|
||||
|
||||
for(let block of blocks) {
|
||||
switch(block.type) {
|
||||
case BlockType.Scene:
|
||||
const scene = this.blockToScene(block);
|
||||
if(!story.scenes[scene.key]) story.scenes[scene.key] = [];
|
||||
story.scenes[scene.key].push(scene);
|
||||
break;
|
||||
case BlockType.Action:
|
||||
const action = this.blockToAction(block);
|
||||
story.actions[action.state] = action;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return story;
|
||||
}
|
||||
|
||||
private static blockToScene(block: Block): Scene {
|
||||
const sceneName = Util.matchAnchor(block.name);
|
||||
let name: string | undefined = undefined;
|
||||
let key: string;
|
||||
let conditions: BoolHash | undefined = undefined;
|
||||
if(sceneName) {
|
||||
name = sceneName.title
|
||||
? Util.trimText(sceneName.title)
|
||||
: Util.trimText(sceneName.text);
|
||||
key = Util.normalize(sceneName.text);
|
||||
const href = Util.matchHref(sceneName.href);
|
||||
if(href && href.conditions) conditions =
|
||||
Util.toBoolHash(href.conditions.split('&'));
|
||||
} else {
|
||||
name = Util.trimText(block.name);
|
||||
key = Util.normalize(block.name);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
key,
|
||||
conditions,
|
||||
description: Util.trimText(block.lines.join("\n")),
|
||||
lineNumber: block.lineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
private static blockToAction(block: Block): Action {
|
||||
return {
|
||||
state: Util.normalize(block.name),
|
||||
description: Util.trimText(block.lines.join("\n")),
|
||||
lineNumber: block.lineNumber,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
BoolHash,
|
||||
PlayerOptions,
|
||||
Scene,
|
||||
Story,
|
||||
} from './Model';
|
||||
import { Parser } from './Parser';
|
||||
import * as Markdown from 'markdown-it';
|
||||
import * as $ from 'jquery';
|
||||
|
||||
export class Player {
|
||||
private static converter = new Markdown();
|
||||
private container: JQuery<any>;
|
||||
private playerState: BoolHash = {};
|
||||
private visitedScenes: BoolHash = {};
|
||||
private currentScene?: Scene;
|
||||
private moveCounter: number = 0;
|
||||
private story: Story;
|
||||
|
||||
constructor(private options: PlayerOptions) {
|
||||
this.story = Parser.parse(options.source);
|
||||
let i = 0;
|
||||
for(let [key, scenes] of Object.entries(this.story.scenes)) {
|
||||
for(let scene of scenes) scene.id = `#s${ ++i }`;
|
||||
}
|
||||
this.container = $(`#${ options.id }`);
|
||||
this.container.addClass('ficdown').data('player', this);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
import { Anchor, Href, BoolHash, AltText } from './Model';
|
||||
|
||||
export class Util {
|
||||
private static regexLib: { [name: string]: RegExp } = {
|
||||
anchors: /(\[((?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[\])*\])*\])*\])*\])*\])*)\]\([ ]*((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\(\))*\))*\))*\))*\))*\))*)[ ]*((['"])(.*?)\5[ ]*)?\))/gm,
|
||||
|
||||
href: /^(?:\/([a-zA-Z](?:-?[a-zA-Z0-9])*))?(?:\?((?:!?[a-zA-Z](?:-?[a-zA-Z0-9])*)(?:&!?[a-zA-Z](?:-?[a-zA-Z0-9])*)*)?)?(?:#((?:[a-zA-Z](?:-?[a-zA-Z0-9])*)(?:\+[a-zA-Z](?:-?[a-zA-Z0-9])*)*))?$/,
|
||||
|
||||
trim: /^\s+|\s+$/g,
|
||||
|
||||
altText: /^((?:[^|\\]|\\.)*)(?:\|((?:[^|\\]|\\.)+))?$/,
|
||||
|
||||
escapeChar: /\\(?=[^\\])/g,
|
||||
|
||||
emptyListItem: /^[ ]*-\s*([\r\n]+|$)/gm,
|
||||
};
|
||||
|
||||
private static matchToAnchor(match: RegExpExecArray): Anchor {
|
||||
const result: Anchor = {
|
||||
anchor: match[1],
|
||||
text: match[2],
|
||||
href: match[3],
|
||||
title: match[6],
|
||||
};
|
||||
if(result.href.indexOf('"') === 0) {
|
||||
result.title = result.href.substring(1, result.href.length - 1);
|
||||
result.href = '';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static matchAnchor(text: string): Anchor | undefined {
|
||||
const match = this.regexLib.anchors.exec(text);
|
||||
if(match) return this.matchToAnchor(match);
|
||||
}
|
||||
|
||||
public static matchAnchors(text: string): Anchor[] {
|
||||
let match: RegExpExecArray | null;
|
||||
const anchors: Anchor[] = [];
|
||||
while(match = this.regexLib.anchors.exec(text)) {
|
||||
anchors.push(this.matchToAnchor(match));
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
public static matchHref(text: string): Href | undefined {
|
||||
const match = this.regexLib.href.exec(text);
|
||||
if(match) {
|
||||
return {
|
||||
target: match[1],
|
||||
conditions: match[2],
|
||||
toggles: match[3],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static trimText(text: string): string {
|
||||
return text.replace(this.regexLib.trim, '');
|
||||
}
|
||||
|
||||
public static normalize(text: string): string {
|
||||
return text.toLowerCase().replace(/^\W+|\W+$/g, '').replace(/\W+/g, '-');
|
||||
}
|
||||
|
||||
public static toBoolHash(names: string[]): BoolHash | undefined {
|
||||
if(names) {
|
||||
const hash: BoolHash = {};
|
||||
for(let name of names) {
|
||||
if(name.indexOf('!') === 0) {
|
||||
hash[name.substring(1, name.length)] = false;
|
||||
} else {
|
||||
hash[name] = true;
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
public static conditionsMet(state: BoolHash, conditions: BoolHash): boolean {
|
||||
for(let [cond, val] of Object.entries(conditions)) {
|
||||
if((val && !state[cond]) || (!val && state[cond])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static splitAltText(text: string): AltText | undefined {
|
||||
const match = this.regexLib.altText.exec(text);
|
||||
if(match) {
|
||||
return {
|
||||
passed: match[0],
|
||||
failed: match[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
parseText = (text) ->
|
||||
lines = text.split /\n|\r\n/
|
||||
blocks = extractBlocks lines
|
||||
story = parseBlocks blocks
|
||||
|
||||
getBlockType = (hashCount) ->
|
||||
switch hashCount
|
||||
when 1 then 'story'
|
||||
when 2 then 'scene'
|
||||
when 3 then 'action'
|
||||
|
||||
extractBlocks = (lines) ->
|
||||
blocks = []
|
||||
currentBlock = null
|
||||
for line in lines
|
||||
match = line.match /^(#{1,3})\s+([^#].*)$/
|
||||
if match?
|
||||
if currentBlock != null
|
||||
blocks.push currentBlock
|
||||
currentBlock =
|
||||
type: getBlockType match[1].length
|
||||
name: match[2]
|
||||
lines: []
|
||||
else
|
||||
currentBlock.lines.push line
|
||||
if currentBlock != null
|
||||
blocks.push currentBlock
|
||||
return blocks
|
||||
|
||||
parseBlocks = (blocks) ->
|
||||
storyBlock = null
|
||||
for block in blocks
|
||||
if block.type == 'story'
|
||||
storyBlock = block
|
||||
break
|
||||
storyName = matchAnchor storyBlock.name
|
||||
storyHref = matchHref storyName.href
|
||||
story =
|
||||
name: storyName.text
|
||||
description: trimText storyBlock.lines.join "\n"
|
||||
firstScene: storyHref.target
|
||||
scenes: {}
|
||||
actions: {}
|
||||
for block in blocks
|
||||
switch block.type
|
||||
when 'scene'
|
||||
scene = blockToScene block
|
||||
if !story.scenes[scene.key]
|
||||
story.scenes[scene.key] = []
|
||||
story.scenes[scene.key].push scene
|
||||
when 'action'
|
||||
action = blockToAction block
|
||||
story.actions[action.state] = action
|
||||
return story
|
||||
|
||||
blockToScene = (block) ->
|
||||
sceneName = matchAnchor block.name
|
||||
if sceneName?
|
||||
title = if sceneName.title? then trimText sceneName.title else trimText sceneName.text
|
||||
key = normalize sceneName.text
|
||||
href = matchHref sceneName.href
|
||||
if href?.conditions?
|
||||
conditions = toBoolHash href.conditions.split '&'
|
||||
else
|
||||
title = trimText block.name
|
||||
key = normalize block.name
|
||||
scene =
|
||||
name: if title != '' then title else null
|
||||
key: key
|
||||
description: trimText block.lines.join "\n"
|
||||
conditions: if conditions? then conditions else null
|
||||
|
||||
blockToAction = (block) ->
|
||||
action =
|
||||
state: normalize block.name
|
||||
description: trimText block.lines.join "\n"
|
|
@ -1,89 +0,0 @@
|
|||
regexLib =
|
||||
|
||||
anchors: /(\[((?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[(?:[^\[\]]+|\[\])*\])*\])*\])*\])*\])*)\]\([ ]*((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\((?:[^()\s]+|\(\))*\))*\))*\))*\))*\))*)[ ]*((['"])(.*?)\5[ ]*)?\))/gm
|
||||
|
||||
href: /^(?:\/([a-zA-Z](?:-?[a-zA-Z0-9])*))?(?:\?((?:!?[a-zA-Z](?:-?[a-zA-Z0-9])*)(?:&!?[a-zA-Z](?:-?[a-zA-Z0-9])*)*)?)?(?:#((?:[a-zA-Z](?:-?[a-zA-Z0-9])*)(?:\+[a-zA-Z](?:-?[a-zA-Z0-9])*)*))?$/
|
||||
|
||||
trim: /^\s+|\s+$/g
|
||||
|
||||
altText: /^((?:[^|\\]|\\.)*)(?:\|((?:[^|\\]|\\.)+))?$/
|
||||
|
||||
escapeChar: /\\(?=[^\\])/g
|
||||
|
||||
emptyListItem: /^[ ]*-\s*([\r\n]+|$)/gm
|
||||
|
||||
matchAnchor = (text) ->
|
||||
re = new RegExp regexLib.anchors
|
||||
match = re.exec text
|
||||
if match?
|
||||
result =
|
||||
anchor: match[1]
|
||||
text: match[2]
|
||||
href: match[3]
|
||||
title: match[6]
|
||||
if result.href.indexOf('"') == 0
|
||||
result.title = result.href.substring 1, result.href.length - 1
|
||||
result.href = ''
|
||||
return result
|
||||
return match
|
||||
|
||||
matchAnchors = (text) ->
|
||||
re = new RegExp regexLib.anchors
|
||||
anchors = []
|
||||
while match = re.exec text
|
||||
anchor =
|
||||
anchor: match[1]
|
||||
text: match[2]
|
||||
href: match[3]
|
||||
title: match[6]
|
||||
if anchor.href.indexOf('"') == 0
|
||||
anchor.title = anchor.href.substring 1, anchor.href.length - 1
|
||||
anchor.href = ''
|
||||
anchors.push anchor
|
||||
return anchors
|
||||
|
||||
trimText = (text) ->
|
||||
text.replace regexLib.trim, ''
|
||||
|
||||
matchHref = (href) ->
|
||||
re = new RegExp regexLib.href
|
||||
match = re.exec href
|
||||
if match?
|
||||
result =
|
||||
target: match[1]
|
||||
conditions: match[2]
|
||||
toggles: match[3]
|
||||
return result
|
||||
return match
|
||||
|
||||
normalize = (text) ->
|
||||
text.toLowerCase().replace(/^\W+|\W+$/g, '').replace /\W+/g, '-'
|
||||
|
||||
toBoolHash = (names) ->
|
||||
if !names?
|
||||
return null
|
||||
hash = {}
|
||||
for name in names
|
||||
if name.indexOf('!') == 0
|
||||
hash[name.substring 1, name.length] = false
|
||||
else
|
||||
hash[name] = true
|
||||
return hash
|
||||
|
||||
conditionsMet = (state, conditions) ->
|
||||
met = true
|
||||
for cond, val of conditions
|
||||
if (val and !state[cond]) or (!val and state[cond])
|
||||
met = false
|
||||
break
|
||||
return met
|
||||
|
||||
splitAltText = (text) ->
|
||||
re = new RegExp regexLib.altText
|
||||
match = re.exec text
|
||||
if match?
|
||||
result =
|
||||
passed: match[1]
|
||||
failed: match[2]
|
||||
return result
|
||||
return
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": [ "es2017" ],
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./build",
|
||||
"declaration": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"strict": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue