mirror of
https://github.com/tmedwards/tweego.git
synced 2025-07-04 13:47:03 -04:00
Initial Bitbucket→GitHub migration commit, based on release v2.0.0.
This commit is contained in:
commit
57e1aa52ff
36 changed files with 5026 additions and 0 deletions
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
* text eol=lf
|
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Shell scripts
|
||||
*.sh
|
||||
|
||||
# Dependency directories & files
|
||||
go.sum
|
||||
# vendor/
|
||||
|
||||
# Documentation directories & files
|
||||
*.html
|
||||
|
||||
# Build & distribution directories
|
||||
dist/
|
||||
|
||||
# Testing directories & files
|
||||
storyformats/
|
||||
prerelease.go
|
||||
|
||||
# Miscellaneous directories & files
|
||||
_*
|
||||
CHANGELOG*
|
||||
TODO*
|
25
LICENSE
Normal file
25
LICENSE
Normal file
|
@ -0,0 +1,25 @@
|
|||
|
||||
tweego is licensed under this Simplified BSD License.
|
||||
|
||||
Copyright (c) 2014-2019 Thomas Michael Edwards <thomasmedwards@gmail.com>.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
11
README.md
Normal file
11
README.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Tweego
|
||||
|
||||
[Tweego](http://www.motoslave.net/tweego/) is a free (gratis and libre) command line compiler for [Twine/Twee](http://twinery.org/) story formats, written in [Go](http://golang.org/).
|
||||
|
||||
See [Tweego's documentation](http://www.motoslave.net/tweego/docs/) for information on how to set it up and use it.
|
||||
|
||||
Tweego has a Trello board, [Tweego TODO](https://trello.com/b/l5xuRzFu). Feel free to comment.
|
||||
|
||||
## NOTICE
|
||||
|
||||
This is the initial commit to this GitHub repository, which is a migration from the old Bitbucket repository. It is based on the v2.0.0 release and is not suitable for compilation as-is due to import path differences. The first release from this repository will address that problem.
|
261
config.go
Normal file
261
config.go
Normal file
|
@ -0,0 +1,261 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
// standard packages
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
// internal packages
|
||||
"bitbucket.org/tmedwards/tweego/internal/option"
|
||||
// external packages
|
||||
"github.com/paulrosania/go-charset/charset"
|
||||
)
|
||||
|
||||
type outputMode int
|
||||
|
||||
const (
|
||||
outModeHTML outputMode = iota
|
||||
outModeTwee3
|
||||
outModeTwee1
|
||||
outModeTwine2Archive
|
||||
outModeTwine1Archive
|
||||
)
|
||||
|
||||
type common struct {
|
||||
formatID string // ID of the story format to use
|
||||
startName string // name of the starting passage
|
||||
}
|
||||
|
||||
type config struct {
|
||||
cmdline common
|
||||
common
|
||||
|
||||
encoding string // input encoding
|
||||
sourcePaths []string // slice of paths to seach for source files
|
||||
modulePaths []string // slice of paths to seach for module files
|
||||
headFile string // name of the head file
|
||||
outFile string // name of the output file
|
||||
outMode outputMode // output mode
|
||||
|
||||
formats storyFormatsMap // map of all enumerated story formats
|
||||
testMode bool // enable test mode
|
||||
twee2Compat bool // enable Twee2 header extension compatibility mode
|
||||
watchFiles bool // enable filesystem watching
|
||||
logStats bool // log story statistics
|
||||
logFiles bool // log input files
|
||||
}
|
||||
|
||||
const (
|
||||
defaultFormatID = "sugarcube-2"
|
||||
defaultOutFile = "-" // <stdout>
|
||||
defaultOutMode = outModeHTML
|
||||
defaultStartName = "Start"
|
||||
)
|
||||
|
||||
// newConfig creates a new config instance
|
||||
func newConfig() *config {
|
||||
// Get the base paths to search for story formats.
|
||||
formatDirs := (func() []string {
|
||||
var (
|
||||
baseDirnames = []string{
|
||||
"storyformats",
|
||||
".storyformats",
|
||||
"story-formats", // DEPRECATED
|
||||
"storyFormats", // DEPRECATED
|
||||
"targets", // DEPRECATED
|
||||
}
|
||||
basePaths = []string{programDir}
|
||||
searchDirnames []string
|
||||
)
|
||||
if homeDir, err := userHomeDir(); err == nil {
|
||||
if !stringSliceContains(basePaths, homeDir) {
|
||||
basePaths = append(basePaths, homeDir)
|
||||
}
|
||||
}
|
||||
if !stringSliceContains(basePaths, workingDir) {
|
||||
basePaths = append(basePaths, workingDir)
|
||||
}
|
||||
for _, basePath := range basePaths {
|
||||
for _, baseDirname := range baseDirnames {
|
||||
searchDirname := filepath.Join(basePath, baseDirname)
|
||||
if info, err := os.Stat(searchDirname); err == nil && info.IsDir() {
|
||||
searchDirnames = append(searchDirnames, searchDirname)
|
||||
}
|
||||
}
|
||||
}
|
||||
return searchDirnames
|
||||
})()
|
||||
|
||||
// Create a new instance of `config` and assign defaults.
|
||||
c := &config{
|
||||
common: common{formatID: defaultFormatID, startName: defaultStartName},
|
||||
outFile: defaultOutFile,
|
||||
outMode: defaultOutMode,
|
||||
}
|
||||
|
||||
// Merge values from the environment variables.
|
||||
// /*
|
||||
// LEGACY
|
||||
// */
|
||||
// for _, env := range []string{"TWEEGO_CHARSET", "TWEEGO_FORMAT"} {
|
||||
// if _, exists := os.LookupEnv(env); exists {
|
||||
// log.Printf("warning: Detected obsolete environment variable %q. Please remove it from your environment.", env)
|
||||
// }
|
||||
// }
|
||||
// /*
|
||||
// END LEGACY
|
||||
// */
|
||||
if env := os.Getenv("TWEEGO_PATH"); env != "" {
|
||||
formatDirs = append(formatDirs, filepath.SplitList(env)...)
|
||||
}
|
||||
|
||||
// TODO: Move story formats out of the config?
|
||||
// Enumerate story formats.
|
||||
if len(formatDirs) == 0 {
|
||||
log.Fatal("error: Story format search directories not found.")
|
||||
}
|
||||
c.formats = newStoryFormatsMap(formatDirs)
|
||||
if c.formats.isEmpty() {
|
||||
log.Print("error: Story formats not found within the search directories: (in order)")
|
||||
for i, path := range formatDirs {
|
||||
log.Printf(" %2d. %s", i+1, path)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Merge values from the command line.
|
||||
options := option.NewParser()
|
||||
options.Add("archive_twine2", "-a|--archive-twine2")
|
||||
options.Add("archive_twine1", "--archive-twine1")
|
||||
options.Add("decompile_twee3", "-d|--decompile-twee3|--decompile") // NOTE: "--decompile" is deprecated.
|
||||
options.Add("decompile_twee1", "--decompile-twee1")
|
||||
options.Add("encoding", "-c=s|--charset=s")
|
||||
options.Add("format", "-f=s|--format=s")
|
||||
options.Add("head", "--head=s")
|
||||
options.Add("help", "-h|--help")
|
||||
options.Add("listcharsets", "--list-charsets")
|
||||
options.Add("listformats", "--list-formats")
|
||||
options.Add("logfiles", "--log-files")
|
||||
options.Add("logstats", "-l|--log-stats")
|
||||
options.Add("module", "-m=s+|--module=s+")
|
||||
options.Add("output", "-o=s|--output=s")
|
||||
options.Add("start", "-s=s|--start=s")
|
||||
options.Add("test", "-t|--test")
|
||||
options.Add("twee2_compat", "--twee2-compat")
|
||||
options.Add("version", "-v|--version")
|
||||
options.Add("watch", "-w|--watch")
|
||||
if opts, sources, err := options.ParseCommandLine(); err == nil {
|
||||
for opt, val := range opts {
|
||||
switch opt {
|
||||
case "archive_twine2":
|
||||
c.outMode = outModeTwine2Archive
|
||||
case "archive_twine1":
|
||||
c.outMode = outModeTwine1Archive
|
||||
case "decompile_twee3":
|
||||
c.outMode = outModeTwee3
|
||||
case "decompile_twee1":
|
||||
c.outMode = outModeTwee1
|
||||
case "encoding":
|
||||
c.encoding = val.(string)
|
||||
case "format":
|
||||
c.cmdline.formatID = val.(string)
|
||||
c.formatID = c.cmdline.formatID
|
||||
case "head":
|
||||
c.headFile = val.(string)
|
||||
case "help":
|
||||
usage()
|
||||
case "listcharsets":
|
||||
usageCharsets()
|
||||
case "listformats":
|
||||
usageFormats(c.formats)
|
||||
case "logfiles":
|
||||
c.logFiles = true
|
||||
case "logstats":
|
||||
c.logStats = true
|
||||
case "module":
|
||||
c.modulePaths = append(c.modulePaths, val.([]string)...)
|
||||
case "output":
|
||||
c.outFile = val.(string)
|
||||
case "start":
|
||||
c.cmdline.startName = val.(string)
|
||||
c.startName = c.cmdline.startName
|
||||
case "test":
|
||||
c.testMode = true
|
||||
case "twee2_compat":
|
||||
c.twee2Compat = true
|
||||
case "version":
|
||||
usageVersion()
|
||||
case "watch":
|
||||
c.watchFiles = true
|
||||
}
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
c.sourcePaths = append(c.sourcePaths, sources...)
|
||||
}
|
||||
} else {
|
||||
log.Printf("error: %s", err.Error())
|
||||
usage()
|
||||
}
|
||||
|
||||
// Basic sanity checks.
|
||||
if c.encoding != "" {
|
||||
if cs := charset.Info(c.encoding); cs == nil {
|
||||
log.Printf("error: Charset %q is unsupported.", c.encoding)
|
||||
usageCharsets()
|
||||
}
|
||||
}
|
||||
if len(c.sourcePaths) == 0 {
|
||||
log.Print("error: Input sources not specified.")
|
||||
usage()
|
||||
}
|
||||
if c.watchFiles {
|
||||
if c.outFile == "-" {
|
||||
log.Fatal("error: Writing to standard output is unsupported in watch mode.")
|
||||
}
|
||||
// if c.logFiles {
|
||||
// log.Print("warning: File logging is unsupported in watch mode.")
|
||||
// }
|
||||
// if c.logStats {
|
||||
// log.Print("warning: Statistic logging is unsupported in watch mode.")
|
||||
// }
|
||||
}
|
||||
|
||||
// Return the base configuration.
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *config) mergeStoryConfig(s *story) {
|
||||
if c.cmdline.formatID != "" {
|
||||
c.formatID = c.cmdline.formatID
|
||||
} else if s.twine2.format != "" {
|
||||
c.formatID = c.formats.getIDFromTwine2NameAndVersion(s.twine2.format, s.twine2.formatVersion)
|
||||
if c.formatID == "" {
|
||||
log.Printf("error: Story format named %q at version %q is not available.", s.twine2.format, s.twine2.formatVersion)
|
||||
usageFormats(c.formats)
|
||||
}
|
||||
} else {
|
||||
c.formatID = defaultFormatID
|
||||
}
|
||||
if !c.formats.hasByID(c.formatID) {
|
||||
log.Printf("error: Story format %q is not available.", c.formatID)
|
||||
usageFormats(c.formats)
|
||||
}
|
||||
|
||||
if c.cmdline.startName != "" {
|
||||
c.startName = c.cmdline.startName
|
||||
} else if s.twine2.start != "" {
|
||||
c.startName = s.twine2.start
|
||||
} else {
|
||||
c.startName = defaultStartName
|
||||
}
|
||||
|
||||
// Finalize the story setup.
|
||||
s.format = c.formats.getByID(c.formatID)
|
||||
s.twine2.options["debug"] = s.twine2.options["debug"] || c.testMode
|
||||
}
|
52
docs/core/faq-and-tips.md
Normal file
52
docs/core/faq-and-tips.md
Normal file
|
@ -0,0 +1,52 @@
|
|||
<!-- ***********************************************************************************************
|
||||
FAQ & Tips
|
||||
************************************************************************************************ -->
|
||||
<h1 id="faq-and-tips">FAQ & Tips</h1>
|
||||
|
||||
This is a collection of tips, from how to avoid pitfalls to best practices.
|
||||
|
||||
Suggestions for new entries may be submitted by [creating a new issue](https://bitbucket.org/tmedwards/tweego/issues?status=new&status=open) at Tweego's [code repository](https://bitbucket.org/tmedwards/tweego/). **NOTE:** Acceptance of submissions ***is not*** guaranteed.
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Avoid processing files
|
||||
**************************************************************************** -->
|
||||
<span id="faq-and-tips-avoid-processing-files"></span>
|
||||
## Avoid processing files
|
||||
|
||||
The way to avoid having Tweego process files is to not pass it the files in the first place—i.e. keep the files in question separate from the files you want Tweego to compile.
|
||||
|
||||
Using image files as an example, I would generally recommend a directory structure something like:
|
||||
|
||||
```
|
||||
project_directory/
|
||||
images/
|
||||
src/
|
||||
```
|
||||
|
||||
Where `src` is the directory you pass to Tweego, which only contains files you want it to compile—and possibly files that it will not process, like notes and whatnot. For example, while within the project directory the command:
|
||||
|
||||
```
|
||||
tweego -o project.html src
|
||||
```
|
||||
|
||||
Will only compile the files in `src`, leaving the image files in `images` alone.
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Convert Twee2 files to Twee v3
|
||||
**************************************************************************** -->
|
||||
<span id="faq-and-tips-convert-twee2-files-to-tweev3"></span>
|
||||
## Convert Twee2 files to Twee v3
|
||||
|
||||
You may convert a Twee2 notation file to a Twee v3 notation file like so:
|
||||
|
||||
```
|
||||
tweego -d -o twee_v3_file.twee twee2_file.tw2
|
||||
```
|
||||
|
||||
Or, if the Twee2 notation file has a standard Twee file extension, like so:
|
||||
|
||||
```
|
||||
tweego --twee2-compat -d -o twee_v3_file.twee twee2_file.twee
|
||||
```
|
114
docs/core/getting-started.md
Normal file
114
docs/core/getting-started.md
Normal file
|
@ -0,0 +1,114 @@
|
|||
<!-- ***********************************************************************************************
|
||||
Getting Started
|
||||
************************************************************************************************ -->
|
||||
<h1 id="getting-started">Getting Started</h1>
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Overview
|
||||
**************************************************************************** -->
|
||||
<span id="getting-started-overview"></span>
|
||||
## Overview
|
||||
|
||||
<p class="tip" role="note"><b>Tip:</b>
|
||||
In practice, most settings will be handled either by story configuration or via the command line, so the only configuration step that's absolutely necessary to begin using Tweego is to enable it to find your story formats.
|
||||
</p>
|
||||
|
||||
Tweego may be configured in a variety of ways—by environment variable, story configuration, and command line options.
|
||||
|
||||
The various methods for specifying configuration settings cascade in the following order:
|
||||
|
||||
1. Program defaults.
|
||||
2. Environment variables.
|
||||
3. Story configuration. See the [`StoryData` passage](#special-passages-storydata) for more information.
|
||||
4. Command line. See [Usage](#usage) for more information.
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Program Defaults
|
||||
**************************************************************************** -->
|
||||
<span id="getting-started-program-defaults"></span>
|
||||
## Program Defaults
|
||||
|
||||
<dl>
|
||||
<dt>Input charset</dt>
|
||||
<dd>
|
||||
<p>The default character set is <code>utf-8</code>, failing over to <code>windows-1252</code> if the input files are not in UTF-8.</p>
|
||||
<p class="tip" role="note"><b>Tip:</b> It is <strong><em>strongly recommended</em></strong> that you use UTF-8 for all of your text files.</p>
|
||||
</dd>
|
||||
<dt>Story format</dt>
|
||||
<dd>The default story format (by ID) is <code>sugarcube-2</code>.</dd>
|
||||
<dt>Output file</dt>
|
||||
<dd>The default output file is <code>-</code>, which is shorthand for <a href="https://en.wikipedia.org/wiki/Standard_streams" target="_blank"><i>standard output</i></a>.</dd>
|
||||
<dt>Starting passage</dt>
|
||||
<dd>The default starting passage name is <code>Start</code>.</dd>
|
||||
</dl>
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Environment Variables
|
||||
**************************************************************************** -->
|
||||
<span id="getting-started-environment-variables"></span>
|
||||
## Environment Variables
|
||||
|
||||
<dl>
|
||||
<dt id="getting-started-environment-variables-tweego-path"><var>TWEEGO_PATH</var></dt>
|
||||
<dd>
|
||||
<p>Path(s) to search for story formats. The value should be a list of directories to search for story formats. You may specify one directory or several. The format is exactly the same as any other <em>path type</em> environment variable for your operating system.</p>
|
||||
<p class="tip" role="note"><b>Tip:</b> Setting <var>TWEEGO_PATH</var> is only necessary if you intend to place your story formats outside of the directories normally searched by Tweego. See <a href="#getting-started-story-formats-search-directories">Search Directories</a> for more information.</p>
|
||||
<p role="note"><b>Note:</b> To separate multiple directories within <em>path</em> variables, Unix-like operating systems use the colon, while Windows uses the semi-colon. Only relevant if you intend to specify multiple directories.</p>
|
||||
<p><strong>Unix-y examples</strong></p>
|
||||
<p>If you wanted Tweego to search <code>/usr/local/storyformats</code>, then you'd set <code>TWEEGO_PATH</code> to:</p>
|
||||
<pre><code>/usr/local/storyformats</code></pre>
|
||||
<p>If you wanted Tweego to search <code>/storyformats</code> and <code>/usr/local/storyformats</code>, then you'd set <code>TWEEGO_PATH</code> to:</p>
|
||||
<pre><code>/storyformats:/usr/local/storyformats</code></pre>
|
||||
<p><strong>Windows examples</strong></p>
|
||||
<p>If you wanted Tweego to search <code>C:\\storyformats</code>, then you'd set <code>TWEEGO_PATH</code> to:</p>
|
||||
<pre><code>C:\storyformats</code></pre>
|
||||
<p>If you wanted Tweego to search <code>C:\storyformats</code> and <code>D:\storyformats</code>, then you'd set <code>TWEEGO_PATH</code> to:</p>
|
||||
<pre><code>C:\storyformats;D:\storyformats</code></pre>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Story Formats
|
||||
**************************************************************************** -->
|
||||
<span id="getting-started-story-formats"></span>
|
||||
## Story Formats
|
||||
|
||||
<p role="note"><b>Note:</b>
|
||||
Throughout this document the terms <code>story format</code> and <code>format</code> are virtually always used to encompass both story and proofing formats.
|
||||
</p>
|
||||
|
||||
Tweego should be compatible with *all* story formats—i.e., those written for Twine 2, Twine 1 ≥v1.4.0, and Twine 1 ≤v1.3.5.
|
||||
|
||||
Installing a story format can be as simple as moving its directory into one of the directories Tweego searches for story formats—see [Search Directories](#getting-started-story-formats-search-directories) for more information. Each installed story format, which includes separate versions of the same story format, should have its own <em>unique</em> directory within your story formats directory—i.e., if you have both SugarCube v2 and v1 installed, then they should each have their own separate directory; e.g., `sugarcube-2` and `sugarcube-1`. Do not create additional sub-directories, combine directories, or rename a story format's files.
|
||||
|
||||
<p class="tip" role="note"><b>Tip:</b>
|
||||
To ensure a story format has been installed correctly, use the list-formats command line option (<kbd>--list-formats</kbd>) to see if Tweego lists it as an available format.
|
||||
</p>
|
||||
|
||||
<p class="warning" role="note"><b>Warning:</b>
|
||||
Twine 2 story formats are, ostensibly, encoded as JSON-P. Unfortunately, some story formats deviate from proper JSON encoding and are thus broken. Tweego uses a strict JSON decoder and cannot decode such broken story formats for use. Should you receive a story format decoding error, all reports should go to the format's developer.
|
||||
</p>
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="getting-started-story-formats-search-directories"></span>
|
||||
### Search Directories
|
||||
|
||||
When Tweego is run, it finds story formats to use by searching the following directories: *(in order)*
|
||||
|
||||
1. The directories <kbd>storyformats</kbd> and <kbd>.storyformats</kbd> within its <em>program directory</em>—i.e., the directory where Tweego's binary file is located.
|
||||
2. The directories <kbd>storyformats</kbd> and <kbd>.storyformats</kbd> within the <em>user's home directory</em>—i.e., either the value of the <var>HOME</var> environment variable or the operating system specified home directory.
|
||||
3. The directories <kbd>storyformats</kbd> and <kbd>.storyformats</kbd> within the <em>current working directory</em>—i.e., the directory that you are executing Tweego from.
|
||||
4. The directories specified via the <var>TWEEGO_PATH</var> environment variable. See <a href="#getting-started-environment-variables">Environment Variables</a> for more information.
|
||||
|
||||
<p role="note"><b>Note:</b>
|
||||
For legacy compatibility, the following directories are also checked during steps #1–3: <kbd>story-formats</kbd>, <kbd>storyFormats</kbd>, and <kbd>targets</kbd>. You are encouraged to use one of the directory names listed above instead.
|
||||
</p>
|
||||
|
||||
<p class="warning" role="note"><b>Warning:</b>
|
||||
A story format's directory name is used as its <strong><em>unique</em></strong> ID within the story format list. As a consequence, if multiple story formats, from different search paths, have the same directory name, then only the last one found will be registered.
|
||||
</p>
|
109
docs/core/special-passages-and-tags.md
Normal file
109
docs/core/special-passages-and-tags.md
Normal file
|
@ -0,0 +1,109 @@
|
|||
<!-- ***********************************************************************************************
|
||||
Special Passages & Tags
|
||||
************************************************************************************************ -->
|
||||
<h1 id="special">Special Passages & Tags</h1>
|
||||
|
||||
Passages and tags that have special meaning to Tweego.
|
||||
|
||||
<p role="note"><b>Note:</b>
|
||||
This is <em>not</em> a exhaustive list of all special passages and tags that may have meaning to story formats—or other compilers. See the documentation of the specific story format—or compiler—for their list of special passages and tags.
|
||||
</p>
|
||||
|
||||
<p class="warning" role="note"><b>Warning:</b>
|
||||
The names of all special passages and tags listed herein are case sensitive, thus must be spelled <em>exactly</em> as shown.
|
||||
</p>
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Special Passages
|
||||
**************************************************************************** -->
|
||||
<span id="special-passages"></span>
|
||||
## Special Passages
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="special-passages-start"></span>
|
||||
### `Start`
|
||||
|
||||
The `Start` passage will, by default, be used as the starting passage—i.e. the first normal passage displayed to the player. That behavior may be overridden via either the <var>start</var> property from the [`StoryData` passage](#special-passages-storydata) or the start command line option (<kbd>-s NAME</kbd>, <kbd>--start=NAME</kbd>).
|
||||
|
||||
<p class="tip" role="note"><b>Tip:</b>
|
||||
It is <strong><em>strongly recommended</em></strong> that you simply use the default starting name, <code>Start</code>, when beginning new projects.
|
||||
</p>
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="special-passages-storydata"></span>
|
||||
### `StoryData`
|
||||
|
||||
The `StoryData` passage may be used to specify basic project settings. Its contents must consist of a JSON chunk, which is, generally, pretty-printed—i.e., line-broken and indented.
|
||||
|
||||
The core properties used with all story formats include:
|
||||
|
||||
- <var>ifid</var>: (string) Required. The project's Interactive Fiction IDentifier (IFID), which is a unique code used to identify your project—similar to the ISBN code assigned to a book. If the project does not already have an IFID, you may omit the property and Tweego will automatically generate one for you with instructions on how to copy it into the chunk.
|
||||
- <var>start</var>: (string) Optional. The name of the starting passage. If omitted, defaults to the [`Start` passage](#special-passages-start).
|
||||
|
||||
The properties used only with Twine 2-style story formats include:
|
||||
|
||||
- <var>format</var>: (string) Optional. The name of the story format to compile against—e.g., `SugarCube`, `Harlowe`, `Chapbook`, `Snowman`.
|
||||
- <var>format-version</var>: (string) Optional. The version of the story format to compile against—e.g., `2.29.0`. From the installed story formats matching the name specified in <var>format</var>, Tweego will attempt to use the greatest version that matches the specified major version—i.e., if <var>format-version</var> is `2.0.0` and you have the versions `1.0.0`, `2.0.0`, `2.5.0`, and `3.0.0` installed, Tweego will choose `2.5.0`.
|
||||
|
||||
<p role="note"><b>Note:</b>
|
||||
The above is <em>not</em> an exhaustive list of all Twine 2-style story format properties. There are others available that are only useful when actually interoperating with Twine 2—e.g, <var>tag-colors</var> and <var>zoom</var>. See the <a href="https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md" target="_blank">twee-3-specification.md</a> for more information.
|
||||
</p>
|
||||
|
||||
<p class="tip" role="note"><b>Tip:</b>
|
||||
To compile against a specific version of a story format, use the format command line option (<kbd>-f NAME</kbd>, <kbd>--format=NAME</kbd>) to specify the version by its ID. If you don't know the ID, use the list-formats command line option (<kbd>--list-formats</kbd>) to find it.
|
||||
</p>
|
||||
|
||||
<p class="warning" role="note"><b>Warning:</b>
|
||||
JSON chunks are not JavaScript object literals, though they look much alike. Property names must always be double quoted and you should not include a trailing comma after the last property.
|
||||
</p>
|
||||
|
||||
#### Example
|
||||
|
||||
```
|
||||
:: StoryData
|
||||
{
|
||||
"ifid": "D674C58C-DEFA-4F70-B7A2-27742230C0FC",
|
||||
"format": "SugarCube",
|
||||
"format-version": "2.29.0",
|
||||
"start": "My Starting Passage"
|
||||
}
|
||||
```
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="special-passages-storytitle"></span>
|
||||
### `StoryTitle`
|
||||
|
||||
The contents of the `StoryTitle` passage will be used as the name/title of the story.
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Special Tags
|
||||
**************************************************************************** -->
|
||||
<span id="special-tags"></span>
|
||||
## Special Tags
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="special-tags-script"></span>
|
||||
### `script`
|
||||
|
||||
The `script` tag denotes that the passage's contents are JavaScript code.
|
||||
|
||||
<p role="note"><b>Note:</b>
|
||||
In general, Tweego makes creating script passages unnecessary as it will automatically bundle any JavaScript source files (<code>.js</code>) it encounters into your project.
|
||||
</p>
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="special-tags-stylesheet"></span>
|
||||
### `stylesheet`
|
||||
|
||||
The `stylesheet` tag denotes that the passage's contents are CSS rules.
|
||||
|
||||
<p role="note"><b>Note:</b>
|
||||
In general, Tweego makes creating stylesheet passages unnecessary as it will automatically bundle any CSS source files (<code>.css</code>) it encounters into your project.
|
||||
</p>
|
149
docs/core/twee-notation.md
Normal file
149
docs/core/twee-notation.md
Normal file
|
@ -0,0 +1,149 @@
|
|||
<!-- ***********************************************************************************************
|
||||
Twee Notation
|
||||
************************************************************************************************ -->
|
||||
<h1 id="twee-notation">Twee Notation</h1>
|
||||
|
||||
In Twee and Twine, stories are arranged into units called passages. Each passage has a name, optional attributes, and content.
|
||||
|
||||
There are two official Twee notations, Twee v3 and Twee v1, and an unofficial Twee2 notation.
|
||||
|
||||
* Twee v3 is the current official notation—see the <a href="https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md" target="_blank">twee-3-specification.md</a> for more information.
|
||||
* Twee v1 is the classic/legacy official notation, which is a compatible subset of Twee v3.
|
||||
* The unofficial Twee2 notation is primarily generated and used by the Twee2 compiler, which is largely abandonware.
|
||||
|
||||
By default, Tweego supports compiling from both of the official Twee notations and decompiling to Twee v3. Compiling from the unofficial Twee2 notation is also supported via a compatibility mode, but is not enabled by default. To load files with the Twee2 compatibility mode enabled, either the files must have a Twee2 extension (`.tw2`, `.twee2`) or its command line option (<kbd>--twee2-compat</kbd>) must be used.
|
||||
|
||||
<p class="warning" role="note"><b>Warning:</b>
|
||||
It is <strong><em>strongly recommended</em></strong> that you do not enable Twee2 compatibility mode unless you absolutely need it.
|
||||
</p>
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Twee v3 Notation
|
||||
**************************************************************************** -->
|
||||
<span id="twee-notation-tweev3"></span>
|
||||
## Twee v3 Notation
|
||||
|
||||
In the Twee v3 notation, passages consist of a passage declaration and a following content section.
|
||||
|
||||
A passage declaration must be a single line and is composed of the following components *(in order)*:
|
||||
|
||||
1. A required start token that must begin the line. It is composed of a double colon (`::`).
|
||||
2. A required passage name.
|
||||
3. An optional tags block that must directly follow the passage name. It is composed of a left square bracket (`[`), a space separated list of tags, and a right square bracket (`]`).
|
||||
4. An optional metadata block that must directly follow either the tag block or, if the tag block is omitted, the passage name. It is composed of an inline JSON chunk containing the optional properties `position` and `size`.
|
||||
|
||||
The passage content section begins with the very next line and continues until the next passage declaration.
|
||||
|
||||
<p class="tip" role="note"><b>Tip:</b>
|
||||
For the sake of readability, it is recommended that each component within the passage declaration after the start token be preceded by one or more spaces and that, at least, one blank line is added between passages.
|
||||
</p>
|
||||
|
||||
<p role="note"><b>Note:</b>
|
||||
You will likely never need to create metadata blocks yourself. When compiling, any missing metadata will be automatically generated for the compiled file. When decompiling, they'll be automatically pulled from the compiled file.
|
||||
</p>
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="twee-notation-tweev3-passage-and-tag-name-escaping"></span>
|
||||
### Passage And Tag Name Escaping
|
||||
|
||||
To prevent ambiguity during parsing, passage and tag names that include the optional tag or metadata block delimiters (`[`, `]`, `{`, `}`) must escape them. The escapement mechanism is to prefix the escaped characters with a backslash (`\`). Further, to avoid ambiguity with the escape character itself, non-escape backslashes must also be escaped via the same mechanism—e.g., `foo\bar` should be escaped as `foo\\bar`.
|
||||
|
||||
<p class="tip" role="note"><b>Tip:</b>
|
||||
It is <strong><em>strongly recommended</em></strong> that you simply avoid needing to escape characters by not using the optional tag or metadata block delimiters within passage and tag names.
|
||||
</p>
|
||||
|
||||
<p class="tip" role="note"><b>Tip:</b>
|
||||
For different reasons, it is also <strong><em>strongly recommended</em></strong> that you avoid the use of the link markup separator delimiters (<code>|</code>, <code>-></code>, <code><-</code>) within passage and tag names.
|
||||
</p>
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="twee-notation-tweev3-example"></span>
|
||||
### Example
|
||||
|
||||
#### Without any passage metadata
|
||||
|
||||
Exactly the same as Twee v1, save for the [Passage And Tag Name Escaping](#twee-notation-tweev3-passage-and-tag-name-escaping) rules.
|
||||
|
||||
```
|
||||
:: A passage with no tags
|
||||
Content of the "A passage with no tags" passage.
|
||||
|
||||
|
||||
:: A tagged passage with three tags [alfa bravo charlie]
|
||||
Content of the "A tagged passage with three tags" passage.
|
||||
The three tags are: alfa, bravo, charlie.
|
||||
```
|
||||
|
||||
#### With some passage metadata
|
||||
|
||||
Mostly likely to come from decompiling Twine 2 or Twine 1 compiled HTML files.
|
||||
|
||||
```
|
||||
:: A passage with no tags {"position"="860,401"}
|
||||
Content of the "A passage with no tags" passage.
|
||||
|
||||
|
||||
:: A tagged passage with three tags [alfa bravo charlie] {"position"="860,530"}
|
||||
Content of the "A tagged passage with three tags" passage.
|
||||
The three tags are: alfa, bravo, charlie.
|
||||
```
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Twee v1 Notation
|
||||
**************************************************************************** -->
|
||||
<span id="twee-notation-tweev1"></span>
|
||||
## Twee v1 Notation
|
||||
|
||||
<p class="warning" role="note"><b>Warning:</b>
|
||||
Except in instances where you plan to interoperate with Twine 1, it is <strong><em>strongly recommended</em></strong> that you do not create new files using the Twee v1 notation. You should prefer the <a href="#twee-notation-tweev3">Twee v3 notation</a> instead.
|
||||
</p>
|
||||
|
||||
Twee v1 notation is a subset of Twee v3 that lacks support for both the optional metadata block within passage declarations and passage and tag name escaping.
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="twee-notation-tweev1-example"></span>
|
||||
### Example
|
||||
|
||||
```
|
||||
:: A passage with no tags
|
||||
Content of the "A passage with no tags" passage.
|
||||
|
||||
|
||||
:: A tagged passage with three tags [alfa bravo charlie]
|
||||
Content of the "A tagged passage with three tags" passage.
|
||||
The three tags are: alfa, bravo, charlie.
|
||||
```
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Twee2 Notation
|
||||
**************************************************************************** -->
|
||||
<span id="twee-notation-twee2"></span>
|
||||
## Twee2 Notation
|
||||
|
||||
<p class="warning" role="note"><b>Warning:</b>
|
||||
It is <strong><em>strongly recommended</em></strong> that you do not create new files using the unofficial Twee2 notation. You should prefer the <a href="#twee-notation-tweev3">Twee v3 notation</a> instead.
|
||||
</p>
|
||||
|
||||
The unofficial Twee2 notation is mostly identical to the Twee v1 notation, save that the passage declaration may also include an optional position block that must directly follow either the tag block or, if the tag block is omitted, the passage name.
|
||||
|
||||
|
||||
<!-- *********************************************************************** -->
|
||||
|
||||
<span id="twee-notation-tweev1-example"></span>
|
||||
### Example
|
||||
|
||||
```
|
||||
:: A passage with no tags <860,401>
|
||||
Content of the "A passage with no tags" passage.
|
||||
|
||||
|
||||
:: A tagged passage with three tags [alfa bravo charlie] <860,530>
|
||||
Content of the "A tagged passage with three tags" passage.
|
||||
The three tags are: alfa, bravo, charlie.
|
||||
```
|
164
docs/core/usage.md
Normal file
164
docs/core/usage.md
Normal file
|
@ -0,0 +1,164 @@
|
|||
<!-- ***********************************************************************************************
|
||||
Usage
|
||||
************************************************************************************************ -->
|
||||
<h1 id="usage">Usage</h1>
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Overview
|
||||
**************************************************************************** -->
|
||||
<span id="usage-overview"></span>
|
||||
## Overview
|
||||
|
||||
<p class="tip" role="note"><b>Tip:</b>
|
||||
At any time you may pass the help option (<kbd>-h</kbd>, <kbd>--help</kbd>) to Tweego to show its built-in help.
|
||||
</p>
|
||||
|
||||
Basic command line usage is as follows:
|
||||
|
||||
```
|
||||
tweego [options] sources…
|
||||
```
|
||||
|
||||
Where <code>[options]</code> are mostly optional configuration flags—see [Options](#usage-options)—and <code>sources</code> are the input sources which may consist of supported files and/or directories to recursively search for such files. Many types of files are supported as input sources—see [File & Directory Handling](#usage-file-and-directory-handling) for more information.
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Options
|
||||
**************************************************************************** -->
|
||||
<span id="usage-options"></span>
|
||||
## Options
|
||||
|
||||
<dl>
|
||||
<dt><kbd>-a</kbd>, <kbd>--archive-twine2</kbd></dt><dd>Output Twine 2 archive, instead of compiled HTML.</dd>
|
||||
<dt><kbd>--archive-twine1</kbd></dt><dd>Output Twine 1 archive, instead of compiled HTML.</dd>
|
||||
<dt><kbd>-c SET</kbd>, <kbd>--charset=SET</kbd></dt>
|
||||
<dd>
|
||||
<p>Name of the input character set (default: <code>"utf-8"</code>, fallback: <code>"windows-1252"</code>). Necessary only if the input files are not in either UTF-8 or the fallback character set.</p>
|
||||
<p class="tip" role="note"><b>Tip:</b> It is <strong><em>strongly recommended</em></strong> that you use UTF-8 for all of your text files.</p>
|
||||
</dd>
|
||||
<dt><kbd>-d</kbd>, <kbd>--decompile-twee3</kbd></dt><dd>Output Twee 3 source code, instead of compiled HTML. See <a href="#twee-notation-tweev3">Twee v3 Notation</a> for more information.</dd>
|
||||
<dt><kbd>--decompile-twee1</kbd></dt>
|
||||
<dd>
|
||||
<p>Output Twee 1 source code, instead of compiled HTML. See <a href="#twee-notation-tweev1">Twee v1 Notation</a> for more information.</p>
|
||||
<p role="note"><b>Note:</b> Except in instances where you plan to interoperate with Twine 1, it is <strong><em>strongly recommended</em></strong> that you decompile to Twee v3 notation rather than Twee v1.</p>
|
||||
</dd>
|
||||
<dt><kbd>-f NAME</kbd>, <kbd>--format=NAME</kbd></dt><dd>ID of the story format (default: <code>"sugarcube-2"</code>).</dd>
|
||||
<dt><kbd>-h</kbd>, <kbd>--help</kbd></dt><dd>Print the built-in help, then exit.</dd>
|
||||
<dt><kbd>--head=FILE</kbd></dt><dd>Name of the file whose contents will be appended as-is to the <head> element of the compiled HTML.</dd>
|
||||
<dt><kbd>--list-charsets</kbd></dt><dd>List the supported input character sets, then exit.</dd>
|
||||
<dt><kbd>--list-formats</kbd></dt><dd>List the available story formats, then exit.</dd>
|
||||
<dt><kbd>--log-files</kbd></dt>
|
||||
<dd>
|
||||
<p>Log the processed input files.</p>
|
||||
<p role="note"><b>Note:</b> Unsupported when watch mode (<kbd>-w</kbd>, <kbd>--watch</kbd>) is enabled.</p>
|
||||
</dd>
|
||||
<dt><kbd>-l</kbd>, <kbd>--log-stats</kbd></dt>
|
||||
<dd>
|
||||
<p>Log various story statistics. Primarily, passage and word counts.</p>
|
||||
<p role="note"><b>Note:</b> Unsupported when watch mode (<kbd>-w</kbd>, <kbd>--watch</kbd>) is enabled.</p>
|
||||
</dd>
|
||||
<dt><kbd>-m SRC</kbd>, <kbd>--module=SRC</kbd></dt><dd>Module sources (repeatable); may consist of supported files and/or directories to recursively search for such files. Each file will be wrapped within the appropriate markup and bundled into the <head> element of the compiled HTML. Supported files: <code>.css</code>, <code>.js</code>, <code>.otf</code>, <code>.ttf</code>, <code>.woff</code>, <code>.woff2</code>.</dd>
|
||||
<dt><kbd>-o FILE</kbd>, <kbd>--output=FILE</kbd></dt><dd>Name of the output file (default: <kbd>-</kbd>; i.e., <a href="https://en.wikipedia.org/wiki/Standard_streams" target="_blank"><i>standard output</i></a>).</dd>
|
||||
<dt><kbd>-s NAME</kbd>, <kbd>--start=NAME</kbd></dt><dd>Name of the starting passage (default: the passage set by the story data, elsewise <code>"Start"</code>).</dd>
|
||||
<dt><kbd>-t</kbd>, <kbd>--test</kbd></dt><dd>Compile in test mode; only for story formats in the Twine 2 style.</dd>
|
||||
<dt><kbd>--twee2-compat</kbd></dt><dd>Enable Twee2 source compatibility mode; files with the <code>.tw2</code> or <code>.twee2</code> extensions automatically have compatibility mode enabled.</dd>
|
||||
<dt><kbd>-v</kbd>, <kbd>--version</kbd></dt><dd>Print version information, then exit.</dd>
|
||||
<dt><kbd>-w</kbd>, <kbd>--watch</kbd></dt><dd>Start watch mode; watch input sources for changes, rebuilding the output as necessary.</dd>
|
||||
</dl>
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
Basic Examples
|
||||
**************************************************************************** -->
|
||||
<span id="usage-basic-examples"></span>
|
||||
## Basic Examples
|
||||
|
||||
Compile <kbd>example_1.twee</kbd> as <kbd>example_1.html</kbd> with the default story format:
|
||||
|
||||
```
|
||||
tweego -o example_1.html example_1.twee
|
||||
```
|
||||
|
||||
Compile all files in <kbd>example_directory_2</kbd> as <kbd>example_2.html</kbd> with the default story format:
|
||||
|
||||
```
|
||||
tweego -o example_2.html example_directory_2
|
||||
```
|
||||
|
||||
Compile <kbd>example_3.twee</kbd> as <kbd>example_3.html</kbd> with the story format <kbd>snowman</kbd>:
|
||||
|
||||
```
|
||||
tweego -f snowman -o example_3.html example_3.twee
|
||||
```
|
||||
|
||||
Compile all files in <kbd>example_directory_4</kbd> as <kbd>example_4.html</kbd> with the default story format while also bundling all files in <kbd>modules_directory_4</kbd> into the <head> element of the compiled HTML:
|
||||
|
||||
```
|
||||
tweego -o example_4.html -m modules_directory_4 example_directory_4
|
||||
```
|
||||
|
||||
Decompile <kbd>example_5.html</kbd> as <kbd>example_5.twee</kbd>:
|
||||
|
||||
```
|
||||
tweego -d -o example5.twee example5.html
|
||||
```
|
||||
|
||||
|
||||
<!-- ***************************************************************************
|
||||
File & Directory Handling
|
||||
**************************************************************************** -->
|
||||
<span id="usage-file-and-directory-handling"></span>
|
||||
## File & Directory Handling
|
||||
|
||||
Tweego allows you to specify an arbitrary number of files and directories on the command line for processing. In addition to those manually specified, it will recursively search all directories encountered looking for additional files and directories to process. Generally, this means that you only have to specify the base source directory of your project and Tweego will find all of its files automatically.
|
||||
|
||||
### Supported File Extensions
|
||||
|
||||
Tweego only processes files with the following extensions:
|
||||
|
||||
<dl>
|
||||
<dt><code>.tw</code>, <code>.twee</code></dt>
|
||||
<dd>
|
||||
<p>Twee notation source files to process for passages.</p>
|
||||
<p role="note"><b>Note:</b> If any of these files are in the unofficial Twee2 notation, you must manually enable the Twee2 compatibility mode via its command line option (<kbd>--twee2-compat</kbd>).</p>
|
||||
</dd>
|
||||
<dt><code>.tw2</code>, <code>.twee2</code></dt>
|
||||
<dd>Unofficial Twee2 notation source files to process for passages. Twee2 compatibility mode is automatically enabled for files with these extensions.</dd>
|
||||
<dt><code>.htm</code>, <code>.html</code></dt>
|
||||
<dd>HTML source files to process for passages, either compiled files or story archives.</dd>
|
||||
<dt><code>.css</code></dt>
|
||||
<dd>CSS source files to bundle.</dd>
|
||||
<dt><code>.js</code></dt>
|
||||
<dd>JavaScript source files to bundle.</dd>
|
||||
<dt><code>.otf</code>, <code>.ttf</code>, <code>.woff</code>, <code>.woff2</code></dt>
|
||||
<dd>Font files to bundle, as <code>@font-face</code> style rules. The generated name of the font family will be the font's base filename sans its extension—e.g., the family name for <code>chinacat.tff</code> will be <code>chinacat</code>.</dd>
|
||||
<dt><code>.gif</code>, <code>.jpeg</code>, <code>.jpg</code>, <code>.png</code>, <code>.svg</code>, <code>.tif</code>, <code>.tiff</code>, <code>.webp</code></dt>
|
||||
<dd>
|
||||
<p>Image files to bundle, as image passages. The generated name of the image passage will be the base filename sans its extension—e.g., the passage name for <code>rainboom.jpg</code> will be <code>rainboom</code>.</p>
|
||||
<p role="note"><b>Note:</b>
|
||||
As of this writing, image passages are only natively supported by SugarCube (all versions) and the Twine 1 ≥v1.4 vanilla story formats.
|
||||
</p>
|
||||
</dd>
|
||||
<dt><code>.aac</code>, <code>.flac</code>, <code>.m4a</code>, <code>.mp3</code>, <code>.oga</code>, <code>.ogg</code>, <code>.opus</code>, <code>.wav</code>, <code>.wave</code>, <code>.weba</code></dt>
|
||||
<dd>
|
||||
<p>Audio files to bundle, as audio passages. The generated name of the audio passage will be the base filename sans its extension—e.g., the passage name for <code>swamped.mp3</code> will be <code>swamped</code>.</p>
|
||||
<p role="note"><b>Note:</b>
|
||||
As of this writing, audio passages are only natively supported by SugarCube ≥v2.24.0.
|
||||
</p>
|
||||
</dd>
|
||||
<dt><code>.mp4</code>, <code>.ogv</code>, <code>.webm</code></dt>
|
||||
<dd>
|
||||
<p>Video files to bundle, as video passages. The generated name of the video passage will be the base filename sans its extension—e.g., the passage name for <code>cutscene.mp4</code> will be <code>cutscene</code>.</p>
|
||||
<p role="note"><b>Note:</b>
|
||||
As of this writing, video passages are only natively supported by SugarCube ≥v2.24.0.
|
||||
</p>
|
||||
</dd>
|
||||
<dt><code>.vtt</code></dt>
|
||||
<dd>
|
||||
<p>Text track files to bundle, as text track passages. The generated name of the text track passage will be the base filename sans its extension—e.g., the passage name for <code>captions.vtt</code> will be <code>captions</code>.</p>
|
||||
<p role="note"><b>Note:</b>
|
||||
As of this writing, text track passages are only natively supported by SugarCube ≥v2.24.0.
|
||||
</p>
|
||||
</dd>
|
||||
</dl>
|
8
docs/introduction.md
Normal file
8
docs/introduction.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
<!-- ***********************************************************************************************
|
||||
Introduction
|
||||
************************************************************************************************ -->
|
||||
<h1 id="introduction">Introduction</h1>
|
||||
|
||||
This documentation is a reference for [Tweego](http://www.motoslave.net/tweego/), a free (gratis and libre) command line compiler for [Twine/Twee](http://twinery.org/) story formats, written in [Go](http://golang.org/).
|
||||
|
||||
If you believe that you've found a bug in Tweego or simply wish to make a suggestion, you may do so by [creating a new issue](https://bitbucket.org/tmedwards/tweego/issues?status=new&status=open) at its [code repository](https://bitbucket.org/tmedwards/tweego/).
|
48
docs/table-of-contents.md
Normal file
48
docs/table-of-contents.md
Normal file
|
@ -0,0 +1,48 @@
|
|||
<!-- ***********************************************************************************************
|
||||
Table of Contents
|
||||
************************************************************************************************ -->
|
||||
<nav role="navigation">
|
||||
<header role="banner">
|
||||
<h1>Tweego Documentation</h1>
|
||||
<div><tt>{{.VERSION}}</tt> (<time datetime="{{.ISO_DATE}}">{{.DATE}}</time>)</div>
|
||||
</header>
|
||||
|
||||
## [Introduction](#introduction)
|
||||
|
||||
## [Getting Started](#getting-started)
|
||||
|
||||
* [Overview](#getting-started-overview)
|
||||
* [Program Defaults](#getting-started-program-defaults)
|
||||
* [Environment Variables](#getting-started-environment-variables)
|
||||
* [Story Formats](#getting-started-story-formats)
|
||||
|
||||
## [Usage](#usage)
|
||||
|
||||
* [Overview](#usage-overview)
|
||||
* [Options](#usage-options)
|
||||
* [Basic Examples](#usage-basic-examples)
|
||||
* [File & Directory Handling](#usage-file-and-directory-handling)
|
||||
|
||||
## [Twee Notation](#twee-notation)
|
||||
|
||||
* [Twee v3](#twee-notation-tweev3)
|
||||
* [Twee v1](#twee-notation-tweev1)
|
||||
* [Twee2](#twee-notation-twee2)
|
||||
|
||||
## [Special Passages & Tags](#special)
|
||||
|
||||
* [Special Passages](#special-passages)
|
||||
* [`Start`](#special-passages-start)
|
||||
* [`StoryData`](#special-passages-storydata)
|
||||
* [`StoryTitle`](#special-passages-storytitle)
|
||||
* [Special Tags](#special-tags)
|
||||
* [`script`](#special-tags-script)
|
||||
* [`stylesheet`](#special-tags-stylesheet)
|
||||
|
||||
## [FAQ & Tips](#faq-and-tips)
|
||||
|
||||
* [Avoid processing files](#faq-and-tips-how-to-avoid-processing-files)
|
||||
* [Convert Twee2 files to Twee v3](#faq-and-tips-how-to-convert-twee2-files-to-tweev3)
|
||||
|
||||
<div> </div>
|
||||
</nav>
|
177
escaping.go
Normal file
177
escaping.go
Normal file
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
/*
|
||||
HTML escaping/unescaping utilities.
|
||||
*/
|
||||
|
||||
// Escape the minimum characters required for attribute values.
|
||||
var attrEscaper = strings.NewReplacer(
|
||||
`&`, `&`,
|
||||
`"`, `"`,
|
||||
// FIXME: Keep the following? All markup we generate double quotes attribute
|
||||
// values, so escaping single quotes/apostrophes isn't actually necessary.
|
||||
`'`, `'`,
|
||||
)
|
||||
|
||||
func attrEscapeString(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return attrEscaper.Replace(s)
|
||||
}
|
||||
|
||||
// Escape the minimum characters required for general HTML escaping—i.e. only
|
||||
// the special characters (`&`, `<`, `>`, `"`, `'`).
|
||||
//
|
||||
// NOTE: The following exists because `html.EscapeString()` converts double
|
||||
// quotes (`"`) to their decimal numeric character reference (`"`) rather
|
||||
// than to their entity (`"`). While the behavior is entirely legal, and
|
||||
// browsers will happily accept the NCRs, a not insignificant amount of JavaScript
|
||||
// code does not expect it and will fail to properly unescape the NCR—expecting
|
||||
// only `"`.
|
||||
//
|
||||
// The primary special characters (`&`, `<`, `>`, `"`) should always be
|
||||
// converted to their entity forms and never to an NCR form. Saving one byte
|
||||
// (5 vs. 6) is not worth the issues it causes.
|
||||
var htmlEscaper = strings.NewReplacer(
|
||||
`&`, `&`,
|
||||
`<`, `<`,
|
||||
`>`, `>`,
|
||||
`"`, `"`,
|
||||
`'`, `'`,
|
||||
)
|
||||
|
||||
func htmlEscapeString(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return htmlEscaper.Replace(s)
|
||||
}
|
||||
|
||||
var tiddlerEscaper = strings.NewReplacer(
|
||||
`&`, `&`,
|
||||
`<`, `<`,
|
||||
`>`, `>`,
|
||||
`"`, `"`,
|
||||
`\`, `\s`,
|
||||
"\t", `\t`,
|
||||
"\n", `\n`,
|
||||
)
|
||||
|
||||
func tiddlerEscapeString(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return tiddlerEscaper.Replace(s)
|
||||
}
|
||||
|
||||
// NOTE: We only need the newline, tab, and backslash escapes here since
|
||||
// `tiddlerUnescapeString()` is only used when loading Twine 1 HTML and the
|
||||
// `x/net/html` package already handles entity/reference unescaping for us.
|
||||
var tiddlerUnescaper = strings.NewReplacer(
|
||||
`\n`, "\n",
|
||||
`\t`, "\t",
|
||||
`\s`, `\`,
|
||||
)
|
||||
|
||||
func tiddlerUnescapeString(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return tiddlerUnescaper.Replace(s)
|
||||
}
|
||||
|
||||
/*
|
||||
Twee escaping/unescaping utilities.
|
||||
*/
|
||||
|
||||
// Encode set: '\\', '[', ']', '{', '}'.
|
||||
|
||||
func tweeEscapeBytes(s []byte) []byte {
|
||||
if len(s) == 0 {
|
||||
return []byte(nil)
|
||||
}
|
||||
// e := bytes.Replace(s, []byte("\\"), []byte("\\\\"), -1)
|
||||
// e = bytes.Replace(e, []byte("["), []byte("\\["), -1)
|
||||
// e = bytes.Replace(e, []byte("]"), []byte("\\]"), -1)
|
||||
// e = bytes.Replace(e, []byte("{"), []byte("\\{"), -1)
|
||||
// e = bytes.Replace(e, []byte("}"), []byte("\\}"), -1)
|
||||
|
||||
// NOTE: The slices this will be used with will be short enough that
|
||||
// iterating a slice twice shouldn't be problematic. That said,
|
||||
// assuming an escape count of 8 or so wouldn't be a terrible way to
|
||||
// handle this either.
|
||||
cnt := 0
|
||||
for _, b := range s {
|
||||
switch b {
|
||||
case '\\', '[', ']', '{', '}':
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
e := make([]byte, 0, len(s)+cnt)
|
||||
for _, b := range s {
|
||||
switch b {
|
||||
case '\\', '[', ']', '{', '}':
|
||||
e = append(e, '\\')
|
||||
}
|
||||
e = append(e, b)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
var tweeEscaper = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`[`, `\[`,
|
||||
`]`, `\]`,
|
||||
`{`, `\{`,
|
||||
`}`, `\}`,
|
||||
)
|
||||
|
||||
func tweeEscapeString(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return tweeEscaper.Replace(s)
|
||||
}
|
||||
|
||||
func tweeUnescapeBytes(s []byte) []byte {
|
||||
if len(s) == 0 {
|
||||
return []byte(nil)
|
||||
}
|
||||
u := make([]byte, 0, len(s))
|
||||
for i, l := 0, len(s); i < l; i++ {
|
||||
if s[i] == '\\' {
|
||||
i++
|
||||
if i >= l {
|
||||
break
|
||||
}
|
||||
}
|
||||
u = append(u, s[i])
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
var tweeUnescaper = strings.NewReplacer(
|
||||
`\\`, `\`,
|
||||
`\[`, `[`,
|
||||
`\]`, `]`,
|
||||
`\{`, `{`,
|
||||
`\}`, `}`,
|
||||
)
|
||||
|
||||
func tweeUnescapeString(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return tweeUnescaper.Replace(s)
|
||||
}
|
219
filesystem.go
Normal file
219
filesystem.go
Normal file
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
// standard packages
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
// external packages
|
||||
"github.com/radovskyb/watcher"
|
||||
)
|
||||
|
||||
var programDir string
|
||||
var workingDir string
|
||||
|
||||
func init() {
|
||||
// Attempt to get the program directory, failure is okay.
|
||||
if pp, err := os.Executable(); err == nil {
|
||||
programDir = filepath.Dir(pp)
|
||||
}
|
||||
|
||||
// Attempt to get the working directory, failure is okay.
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
workingDir = wd
|
||||
}
|
||||
}
|
||||
|
||||
var noOutToIn = fmt.Errorf("no output to input source")
|
||||
|
||||
// Walk the specified pathnames, collecting regular files.
|
||||
func getFilenames(pathnames []string, outFilename string) []string {
|
||||
var (
|
||||
filenames []string
|
||||
absOutFile string
|
||||
)
|
||||
var fileWalker filepath.WalkFunc = func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absolute == absOutFile {
|
||||
return noOutToIn
|
||||
}
|
||||
relative, _ := filepath.Rel(workingDir, absolute) // Failure is okay.
|
||||
if relative != "" {
|
||||
filenames = append(filenames, relative)
|
||||
} else {
|
||||
filenames = append(filenames, absolute)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the absolute output filename.
|
||||
absOutFile, err := filepath.Abs(outFilename)
|
||||
if err != nil {
|
||||
log.Fatalf("error: path %s: %s", outFilename, err.Error())
|
||||
}
|
||||
|
||||
for _, pathname := range pathnames {
|
||||
if pathname == "-" {
|
||||
log.Print("warning: path -: Reading from standard input is unsupported.")
|
||||
continue
|
||||
} else if err := filepath.Walk(pathname, fileWalker); err != nil {
|
||||
if err == noOutToIn {
|
||||
log.Fatalf("error: path %s: Output file cannot be an input source.", pathname)
|
||||
} else {
|
||||
log.Printf("warning: path %s: %s", pathname, err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filenames
|
||||
}
|
||||
|
||||
// Watch the specified pathnames, calling the build callback as necessary.
|
||||
func watchFilesystem(pathnames []string, outFilename string, buildCallback func()) {
|
||||
var (
|
||||
buildRate = time.Millisecond * 500
|
||||
pollRate = buildRate * 2
|
||||
)
|
||||
|
||||
// Create a new watcher instance.
|
||||
w := watcher.New()
|
||||
|
||||
// Only notify on certain events.
|
||||
w.FilterOps(
|
||||
watcher.Create,
|
||||
watcher.Write,
|
||||
watcher.Remove,
|
||||
watcher.Rename,
|
||||
watcher.Move,
|
||||
)
|
||||
|
||||
// Ignore the output file.
|
||||
w.Ignore(outFilename)
|
||||
|
||||
// Start a goroutine to handle the event loop.
|
||||
go func() {
|
||||
build := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-time.After(buildRate):
|
||||
if build {
|
||||
buildCallback()
|
||||
build = false
|
||||
}
|
||||
case event := <-w.Event:
|
||||
if event.FileInfo != nil {
|
||||
isDir := event.IsDir()
|
||||
|
||||
if event.Op == watcher.Write && isDir {
|
||||
continue
|
||||
}
|
||||
|
||||
var pathname string
|
||||
switch event.Op {
|
||||
case watcher.Move, watcher.Rename:
|
||||
// NOTE: Format of Move/Rename event `Path` field: "oldName -> newName".
|
||||
// TODO: Should probably error out if we can't split the event.Path value.
|
||||
names := strings.Split(event.Path, " -> ")
|
||||
pathname = fmt.Sprintf("%s -> %s", relPath(names[0]), relPath(names[1]))
|
||||
if !build && !isDir {
|
||||
build = knownFileType(names[0]) || knownFileType(names[1])
|
||||
}
|
||||
default:
|
||||
pathname = relPath(event.Path)
|
||||
if !build && !isDir {
|
||||
build = knownFileType(event.Path)
|
||||
}
|
||||
}
|
||||
log.Printf("%s: %s", event.Op, pathname)
|
||||
}
|
||||
case err := <-w.Error:
|
||||
log.Fatalln(err)
|
||||
case <-w.Closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Recursively watch the specified paths for changes.
|
||||
for _, pathname := range pathnames {
|
||||
if err := w.AddRecursive(pathname); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Print a message telling the user how to cancel watching
|
||||
// and list all paths being watched.
|
||||
log.Print()
|
||||
log.Print("Watch mode started. Press CTRL+C to stop.")
|
||||
log.Print()
|
||||
log.Printf("Recursively watched paths: %d", len(pathnames))
|
||||
for _, pathname := range pathnames {
|
||||
log.Printf(" %s", relPath(pathname))
|
||||
}
|
||||
log.Print()
|
||||
|
||||
// Build the ouput once before the watcher starts.
|
||||
buildCallback()
|
||||
|
||||
// Start watching.
|
||||
if err := w.Start(pollRate); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func relPath(original string) string {
|
||||
absolute, err := filepath.Abs(original)
|
||||
if err != nil {
|
||||
// Failure is okay, just return the original path.
|
||||
return original
|
||||
}
|
||||
|
||||
relative, err := filepath.Rel(workingDir, absolute)
|
||||
if err != nil {
|
||||
// Failure is okay, just return the absolute path.
|
||||
return absolute
|
||||
}
|
||||
|
||||
return relative
|
||||
}
|
||||
|
||||
func knownFileType(filename string) bool {
|
||||
switch normalizedFileExt(filename) {
|
||||
// NOTE: The case values here should match those in `storyload.go:(*story).load()`.
|
||||
case "tw", "twee",
|
||||
"tw2", "twee2",
|
||||
"htm", "html",
|
||||
"css",
|
||||
"js",
|
||||
"otf", "ttf", "woff", "woff2",
|
||||
"gif", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp",
|
||||
"aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "wave", "weba",
|
||||
"mp4", "ogv", "webm",
|
||||
"vtt":
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
294
formats.go
Normal file
294
formats.go
Normal file
|
@ -0,0 +1,294 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
// standard packages
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
// external packages
|
||||
"github.com/blang/semver"
|
||||
)
|
||||
|
||||
type twine2FormatJSON struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
// Description string `json:"description"`
|
||||
// Author string `json:"author"`
|
||||
// Image string `json:"image"`
|
||||
// URL string `json:"url"`
|
||||
// License string `json:"license"`
|
||||
Proofing bool `json:"proofing"`
|
||||
Source string `json:"source"`
|
||||
// Setup string `json:"-"`
|
||||
}
|
||||
|
||||
type storyFormat struct {
|
||||
id string
|
||||
filename string
|
||||
twine2 bool
|
||||
name string
|
||||
version string
|
||||
proofing bool
|
||||
}
|
||||
|
||||
func (f *storyFormat) isTwine1Style() bool {
|
||||
return !f.twine2
|
||||
}
|
||||
|
||||
func (f *storyFormat) isTwine2Style() bool {
|
||||
return f.twine2
|
||||
}
|
||||
|
||||
func (f *storyFormat) getStoryFormatData(source []byte) (*twine2FormatJSON, error) {
|
||||
if !f.twine2 {
|
||||
return nil, errors.New("Not a Twine 2 style story format.")
|
||||
}
|
||||
|
||||
// get the JSON chunk from the source
|
||||
first := bytes.Index(source, []byte("{"))
|
||||
last := bytes.LastIndex(source, []byte("}"))
|
||||
if first == -1 || last == -1 {
|
||||
return nil, errors.New("Could not find Twine 2 style story format JSON chunk.")
|
||||
}
|
||||
source = source[first : last+1]
|
||||
|
||||
// parse the JSON
|
||||
data := &twine2FormatJSON{}
|
||||
if err := json.Unmarshal(source, data); err != nil {
|
||||
/*
|
||||
START Harlowe malformed JSON chunk workaround
|
||||
|
||||
TODO: Remove this workaround that attempts to handle Harlowe's
|
||||
broken JSON chunk.
|
||||
|
||||
NOTE: This worksaround is only possible because, currently,
|
||||
Harlowe's "setup" property is the last entry in the chunk.
|
||||
*/
|
||||
if strings.HasPrefix(strings.ToLower(f.id), "harlowe") {
|
||||
if i := bytes.LastIndex(source, []byte(`,"setup": function`)); i != -1 {
|
||||
// cut the "setup" property and its invalid value
|
||||
j := len(source) - 1
|
||||
source = append(source[:i], source[j:]...)
|
||||
return f.getStoryFormatData(source)
|
||||
}
|
||||
}
|
||||
/*
|
||||
If we've reached this point, either the format is not Harlowe
|
||||
or we cannot find the start of its "setup" property, so just
|
||||
return the JSON decoding error as normal.
|
||||
|
||||
END Harlowe malformed JSON chunk workaround
|
||||
*/
|
||||
|
||||
return nil, errors.New("Could not decode story format JSON chunk.")
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (f *storyFormat) unmarshalMetadata() error {
|
||||
if !f.twine2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
data *twine2FormatJSON
|
||||
source []byte
|
||||
err error
|
||||
)
|
||||
|
||||
// read in the story format
|
||||
if source, err = fileReadAllAsUTF8(f.filename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// load various bits of metadata from the JSON
|
||||
if data, err = f.getStoryFormatData(source); err != nil {
|
||||
return err
|
||||
}
|
||||
f.name = data.Name
|
||||
f.version = data.Version
|
||||
f.proofing = data.Proofing
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *storyFormat) source() []byte {
|
||||
var (
|
||||
source []byte
|
||||
err error
|
||||
)
|
||||
|
||||
// read in the story format
|
||||
if source, err = fileReadAllAsUTF8(f.filename); err != nil {
|
||||
log.Fatalf("error: format %s", err.Error())
|
||||
}
|
||||
|
||||
// if in Twine 2 style, extract the actual source from the JSON
|
||||
if f.twine2 {
|
||||
var data *twine2FormatJSON
|
||||
if data, err = f.getStoryFormatData(source); err != nil {
|
||||
log.Fatalf("error: format %s: %s", f.id, err.Error())
|
||||
}
|
||||
source = []byte(data.Source)
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
type storyFormatsMap map[string]*storyFormat
|
||||
|
||||
func newStoryFormatsMap(searchPaths []string) storyFormatsMap {
|
||||
var (
|
||||
baseFilenames = []string{"format.js", "header.html"}
|
||||
formats = make(storyFormatsMap)
|
||||
)
|
||||
|
||||
for _, searchDirname := range searchPaths {
|
||||
if info, err := os.Stat(searchDirname); err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
d, err := os.Open(searchDirname)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
baseDirnames, err := d.Readdirnames(0)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, baseDirname := range baseDirnames {
|
||||
formatDirname := filepath.Join(searchDirname, baseDirname)
|
||||
if info, err := os.Stat(formatDirname); err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, baseFilename := range baseFilenames {
|
||||
formatFilename := filepath.Join(formatDirname, baseFilename)
|
||||
if info, err := os.Stat(formatFilename); err == nil && info.Mode().IsRegular() {
|
||||
f := &storyFormat{
|
||||
id: baseDirname,
|
||||
filename: formatFilename,
|
||||
twine2: baseFilename == "format.js",
|
||||
}
|
||||
if err := f.unmarshalMetadata(); err != nil {
|
||||
log.Printf("warning: format %s: Skipping format; %s", f.id, err.Error())
|
||||
continue
|
||||
}
|
||||
formats[baseDirname] = f
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formats
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) isEmpty() bool {
|
||||
return len(m) == 0
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) getIDFromTwine2Name(name string) string {
|
||||
var (
|
||||
found *semver.Version
|
||||
id string
|
||||
)
|
||||
|
||||
for _, f := range m {
|
||||
if !f.twine2 {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.name == name {
|
||||
if v, err := semver.ParseTolerant(f.version); err == nil {
|
||||
if found == nil || v.GT(*found) {
|
||||
found = &v
|
||||
id = f.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) getIDFromTwine2NameAndVersion(name, version string) string {
|
||||
var (
|
||||
wanted *semver.Version
|
||||
found *semver.Version
|
||||
id string
|
||||
)
|
||||
if v, err := semver.ParseTolerant(version); err == nil {
|
||||
wanted = &v
|
||||
} else {
|
||||
log.Printf("warning: format %q: Auto-selecting greatest version; Could not parse version %q.", name, version)
|
||||
wanted = &semver.Version{Major: 0, Minor: 0, Patch: 0}
|
||||
}
|
||||
|
||||
for _, f := range m {
|
||||
if !f.twine2 {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.name == name {
|
||||
if v, err := semver.ParseTolerant(f.version); err == nil {
|
||||
if wanted.Major == 0 || v.Major == wanted.Major && v.GTE(*wanted) {
|
||||
if found == nil || v.GT(*found) {
|
||||
found = &v
|
||||
id = f.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) hasByID(id string) bool {
|
||||
_, ok := m[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) hasByTwine2Name(name string) bool {
|
||||
_, ok := m[m.getIDFromTwine2Name(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) hasByTwine2NameAndVersion(name, version string) bool {
|
||||
_, ok := m[m.getIDFromTwine2NameAndVersion(name, version)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) getByID(id string) *storyFormat {
|
||||
return m[id]
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) getByTwine2Name(name string) *storyFormat {
|
||||
return m[m.getIDFromTwine2Name(name)]
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) getByTwine2NameAndVersion(name, version string) *storyFormat {
|
||||
return m[m.getIDFromTwine2NameAndVersion(name, version)]
|
||||
}
|
||||
|
||||
func (m storyFormatsMap) ids() []string {
|
||||
var ids []string
|
||||
for id := range m {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
98
html.go
Normal file
98
html.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
// standard packages
|
||||
"bytes"
|
||||
"regexp"
|
||||
// external packages
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func getDocumentTree(source []byte) (*html.Node, error) {
|
||||
return html.Parse(bytes.NewReader(source))
|
||||
}
|
||||
|
||||
func getElementByID(node *html.Node, idPat string) *html.Node {
|
||||
return getElementByIDAndTag(node, idPat, "")
|
||||
}
|
||||
|
||||
func getElementByTag(node *html.Node, tag string) *html.Node {
|
||||
return getElementByIDAndTag(node, "", tag)
|
||||
}
|
||||
|
||||
func getElementByIDAndTag(node *html.Node, idPat, tag string) *html.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
tagOK = false
|
||||
idOK = false
|
||||
)
|
||||
if node.Type == html.ElementNode {
|
||||
if tag == "" || tag == node.Data {
|
||||
tagOK = true
|
||||
}
|
||||
if idPat == "" {
|
||||
idOK = true
|
||||
} else if len(node.Attr) > 0 {
|
||||
re := regexp.MustCompile(idPat)
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Key == "id" && re.MatchString(attr.Val) {
|
||||
idOK = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if tagOK && idOK {
|
||||
return node
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if result := getElementByIDAndTag(child, idPat, tag); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAttr(node *html.Node, attrName string) bool {
|
||||
for _, attr := range node.Attr {
|
||||
if attrName == attr.Key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAttrRe(node *html.Node, attrRe *regexp.Regexp) bool {
|
||||
for _, attr := range node.Attr {
|
||||
if attrRe.MatchString(attr.Key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getAttr(node *html.Node, attrName string) *html.Attribute {
|
||||
for _, attr := range node.Attr {
|
||||
if attrName == attr.Key {
|
||||
return &attr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAttrRe(node *html.Node, attrRe *regexp.Regexp) *html.Attribute {
|
||||
for _, attr := range node.Attr {
|
||||
if attrRe.MatchString(attr.Key) {
|
||||
return &attr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
85
ifid.go
Normal file
85
ifid.go
Normal file
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IFIDs are defined in The Treaty of Babel.
|
||||
// SEE: http://babel.ifarchive.org/
|
||||
//
|
||||
// Most IFIDs, and certainly those generated by Tweego, are simply the
|
||||
// string form of a v4 random UUID.
|
||||
|
||||
// newIFID generates a new IFID (UUID v4).
|
||||
func newIFID() (string, error) {
|
||||
var uuid [16]byte
|
||||
if _, err := io.ReadFull(rand.Reader, uuid[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uuid[6] = (uuid[6] & 0x0f) | 0x40 // version 4
|
||||
uuid[8] = (uuid[8] & 0x3f) | 0x80 // variant 10
|
||||
|
||||
return fmt.Sprintf("%X-%X-%X-%X-%X", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]), nil
|
||||
}
|
||||
|
||||
// validateIFID validates ifid or returns an error.
|
||||
func validateIFID(ifid string) error {
|
||||
switch len(ifid) {
|
||||
case 36: // xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
case 36 + 9: // UUID://xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx//
|
||||
if strings.ToUpper(ifid[:7]) != "UUID://" || ifid[43:] != "//" {
|
||||
return fmt.Errorf("invalid IFID UUID://…// format")
|
||||
}
|
||||
ifid = ifid[7:43]
|
||||
|
||||
default:
|
||||
return fmt.Errorf("invalid IFID length: %d", len(ifid))
|
||||
}
|
||||
|
||||
b := []byte(ifid)
|
||||
for i := 0; i < len(b); i++ {
|
||||
switch i {
|
||||
// hyphens
|
||||
case 8, 13, 18, 23:
|
||||
if b[i] != '-' {
|
||||
return fmt.Errorf("invalid IFID character %#U at position %d", b[i], i+1)
|
||||
}
|
||||
|
||||
// version
|
||||
case 14:
|
||||
if '1' > b[i] || b[i] > '5' {
|
||||
return fmt.Errorf("invalid version %#U at position %d", b[i], i+1)
|
||||
}
|
||||
|
||||
// variant
|
||||
case 19:
|
||||
switch b[i] {
|
||||
case '8', '9', 'a', 'A', 'b', 'B':
|
||||
default:
|
||||
return fmt.Errorf("invalid variant %#U at position %d", b[i], i+1)
|
||||
}
|
||||
|
||||
// regular hex character
|
||||
default:
|
||||
switch {
|
||||
case '0' <= b[i] && b[i] <= '9':
|
||||
case 'a' <= b[i] && b[i] <= 'f':
|
||||
case 'A' <= b[i] && b[i] <= 'F':
|
||||
default:
|
||||
return fmt.Errorf("invalid IFID hex value %#U at position %d", b[i], i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
25
internal/option/LICENSE
Normal file
25
internal/option/LICENSE
Normal file
|
@ -0,0 +1,25 @@
|
|||
|
||||
Go package 'option' is licensed under this Simplified BSD License.
|
||||
|
||||
Copyright (c) 2014-2018 Thomas Michael Edwards <tmedwards@motoslave.net>.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
300
internal/option/option.go
Normal file
300
internal/option/option.go
Normal file
|
@ -0,0 +1,300 @@
|
|||
/*
|
||||
option (a simple command-line option parser for Go)
|
||||
|
||||
Copyright © 2014–2018 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
// Package option implements simple command-line option parsing.
|
||||
package option
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OptionTerminator is the string, when seen on the command line, which terminates further option processing.
|
||||
const OptionTerminator = "--"
|
||||
|
||||
// OptionTypeMap is the map of recognized type abbreviations to types.
|
||||
var OptionTypeMap = map[string]string{"s": "string", "i": "int", "u": "uint", "f": "float", "b": "bool"}
|
||||
|
||||
// Config is {TODO}.
|
||||
type Config struct {
|
||||
Name string
|
||||
Definition string
|
||||
Flags int
|
||||
//Default interface{}
|
||||
}
|
||||
|
||||
// Options is {TODO}.
|
||||
type Options struct {
|
||||
Definitions []Config
|
||||
}
|
||||
|
||||
/*
|
||||
// NewOptions is {TODO}.
|
||||
func NewOptions(options ...Config) Options {
|
||||
return Options{options}
|
||||
}
|
||||
*/
|
||||
|
||||
// NewParser returns {TODO}.
|
||||
func NewParser() Options {
|
||||
return Options{}
|
||||
}
|
||||
|
||||
// Add adds a new option definition.
|
||||
func (optDef *Options) Add(name, def string /*, flags int*/) {
|
||||
optDef.Definitions = append(optDef.Definitions, Config{name, def, 0 /*flags*/})
|
||||
}
|
||||
|
||||
type optionDefinition struct {
|
||||
name string
|
||||
wantsValue bool
|
||||
valueType string
|
||||
repeatable bool
|
||||
flags int
|
||||
}
|
||||
type optionMap map[string]optionDefinition
|
||||
|
||||
func (optDef Options) buildOptionMap() optionMap {
|
||||
optMap := make(optionMap)
|
||||
for _, def := range optDef.Definitions {
|
||||
if def.Definition != "" {
|
||||
names, opts := parseDefinition(def.Definition)
|
||||
for i := range names {
|
||||
opts[i].name = def.Name
|
||||
opts[i].flags = def.Flags
|
||||
optMap[names[i]] = opts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return optMap
|
||||
}
|
||||
|
||||
func parseDefinition(optSpec string) ([]string, []optionDefinition) {
|
||||
var (
|
||||
names []string
|
||||
defs []optionDefinition
|
||||
)
|
||||
for _, def := range strings.Split(optSpec, "|") {
|
||||
if i := strings.LastIndex(def, "="); i != -1 {
|
||||
// value receiving option
|
||||
names = append(names, def[:i])
|
||||
optDef := optionDefinition{wantsValue: true}
|
||||
valueType := def[i+1:]
|
||||
if valueType == "s+" || valueType == "i+" || valueType == "u+" || valueType == "f+" {
|
||||
// special case: value receiving + repeatable
|
||||
optDef.repeatable = true
|
||||
optDef.valueType = OptionTypeMap[valueType[:1]]
|
||||
} else if _, ok := OptionTypeMap[valueType]; ok {
|
||||
// normal cases
|
||||
optDef.valueType = OptionTypeMap[valueType]
|
||||
} else {
|
||||
// what type now?
|
||||
panic(fmt.Errorf("Cannot parse value type %q in option specification %q.", valueType, optSpec))
|
||||
}
|
||||
defs = append(defs, optDef)
|
||||
} else if i := strings.LastIndex(def, "+"); i != -1 {
|
||||
// repeatable unsigned integer option
|
||||
names = append(names, def[:i])
|
||||
defs = append(defs, optionDefinition{
|
||||
repeatable: true,
|
||||
valueType: OptionTypeMap["u"],
|
||||
})
|
||||
} else {
|
||||
// void/empty option
|
||||
names = append(names, def)
|
||||
defs = append(defs, optionDefinition{})
|
||||
}
|
||||
}
|
||||
return names, defs
|
||||
}
|
||||
|
||||
// ParsedOptionsMap is {TODO}.
|
||||
type ParsedOptionsMap map[string]interface{}
|
||||
|
||||
// ParseCommandLine returns {TODO}.
|
||||
func (optDef Options) ParseCommandLine() (ParsedOptionsMap, []string, error) {
|
||||
return optDef.Parse(os.Args[1:])
|
||||
}
|
||||
|
||||
// Parse returns {TODO}.
|
||||
func (optDef Options) Parse(args []string) (ParsedOptionsMap, []string, error) {
|
||||
var (
|
||||
passThrough []string
|
||||
err error
|
||||
)
|
||||
options := make(ParsedOptionsMap)
|
||||
|
||||
optMap := optDef.buildOptionMap()
|
||||
|
||||
for i, argc := 0, len(args); i < argc; i++ {
|
||||
var (
|
||||
name string
|
||||
)
|
||||
sz := len(args[i])
|
||||
if sz > 1 && args[i][0] == '-' {
|
||||
// could be an option, try to parse it
|
||||
if eqPos := strings.Index(args[i], "="); eqPos != -1 {
|
||||
// with bundled value
|
||||
name = args[i][:eqPos]
|
||||
if opt, ok := optMap[name]; ok {
|
||||
if opt.wantsValue {
|
||||
if value, err := convertType(args[i][eqPos+1:], opt.valueType); err == nil {
|
||||
if opt.repeatable {
|
||||
if _, ok := options[opt.name]; !ok {
|
||||
switch opt.valueType {
|
||||
case "string":
|
||||
options[opt.name] = make([]string, 0, 4)
|
||||
case "int":
|
||||
options[opt.name] = make([]int, 0, 4)
|
||||
case "uint":
|
||||
options[opt.name] = make([]uint, 0, 4)
|
||||
case "float":
|
||||
options[opt.name] = make([]float64, 0, 4)
|
||||
}
|
||||
}
|
||||
switch opt.valueType {
|
||||
case "string":
|
||||
options[opt.name] = append(options[opt.name].([]string), value.(string))
|
||||
case "int":
|
||||
options[opt.name] = append(options[opt.name].([]int), value.(int))
|
||||
case "uint":
|
||||
options[opt.name] = append(options[opt.name].([]uint), value.(uint))
|
||||
case "float":
|
||||
options[opt.name] = append(options[opt.name].([]float64), value.(float64))
|
||||
}
|
||||
} else {
|
||||
options[opt.name] = value
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("Option %q %s.", name, err.Error())
|
||||
break
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("Option %q does not take a value.", name)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("Unknown option %q.", name)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// without bundled value
|
||||
name = args[i]
|
||||
if name == OptionTerminator {
|
||||
// processing terminated, pass any remaining arguments on through
|
||||
passThrough = append(passThrough, args[i+1:]...)
|
||||
break
|
||||
}
|
||||
if opt, ok := optMap[name]; ok {
|
||||
if opt.wantsValue {
|
||||
i++
|
||||
if i < argc {
|
||||
if value, err := convertType(args[i], opt.valueType); err == nil {
|
||||
if opt.repeatable {
|
||||
if _, ok := options[opt.name]; !ok {
|
||||
switch opt.valueType {
|
||||
case "string":
|
||||
options[opt.name] = make([]string, 0, 4)
|
||||
case "int":
|
||||
options[opt.name] = make([]int, 0, 4)
|
||||
case "uint":
|
||||
options[opt.name] = make([]uint, 0, 4)
|
||||
case "float":
|
||||
options[opt.name] = make([]float64, 0, 4)
|
||||
}
|
||||
}
|
||||
switch opt.valueType {
|
||||
case "string":
|
||||
options[opt.name] = append(options[opt.name].([]string), value.(string))
|
||||
case "int":
|
||||
options[opt.name] = append(options[opt.name].([]int), value.(int))
|
||||
case "uint":
|
||||
options[opt.name] = append(options[opt.name].([]uint), value.(uint))
|
||||
case "float":
|
||||
options[opt.name] = append(options[opt.name].([]float64), value.(float64))
|
||||
}
|
||||
} else {
|
||||
options[opt.name] = value
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("Option %q %s.", name, err.Error())
|
||||
break
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("Option %q requires a value.", name)
|
||||
break
|
||||
}
|
||||
} else if opt.repeatable {
|
||||
if _, ok := options[opt.name]; ok {
|
||||
options[opt.name] = options[opt.name].(uint) + 1
|
||||
} else {
|
||||
options[opt.name] = 1
|
||||
}
|
||||
} else {
|
||||
options[opt.name] = true
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("Unknown option %q.", name)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// not an option, pass it through
|
||||
passThrough = append(passThrough, args[i])
|
||||
}
|
||||
}
|
||||
return options, passThrough, err
|
||||
}
|
||||
|
||||
func convertType(original, targetType string) (interface{}, error) {
|
||||
var (
|
||||
value interface{}
|
||||
err error
|
||||
)
|
||||
switch targetType {
|
||||
|
||||
case "string":
|
||||
value = original
|
||||
|
||||
case "int":
|
||||
var tmp int64
|
||||
if tmp, err = strconv.ParseInt(original, 10, 0); err != nil {
|
||||
err = fmt.Errorf("Cannot interpret value %q as an integer: %s.", original, err.Error())
|
||||
break
|
||||
}
|
||||
value = int(tmp)
|
||||
|
||||
case "uint":
|
||||
var tmp uint64
|
||||
if tmp, err = strconv.ParseUint(original, 10, 0); err != nil {
|
||||
err = fmt.Errorf("Cannot interpret value %q as an unsigned integer: %s.", original, err.Error())
|
||||
break
|
||||
}
|
||||
value = uint(tmp)
|
||||
|
||||
case "float":
|
||||
var tmp float64
|
||||
if tmp, err = strconv.ParseFloat(original, 64); err != nil {
|
||||
err = fmt.Errorf("Cannot interpret value %q as a floating-point number: %s.", original, err.Error())
|
||||
break
|
||||
}
|
||||
value = tmp
|
||||
|
||||
case "bool":
|
||||
var tmp bool
|
||||
if tmp, err = strconv.ParseBool(original); err != nil {
|
||||
err = fmt.Errorf("Cannot interpret value %q as a boolean: %s.", original, err.Error())
|
||||
break
|
||||
}
|
||||
value = bool(tmp)
|
||||
|
||||
}
|
||||
return value, err
|
||||
}
|
40
internal/twee2compat/twee2compat.go
Normal file
40
internal/twee2compat/twee2compat.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package twee2compat
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Twee2 line regexp: `^:: *([^\[]*?) *(\[(.*?)\])? *(<(.*?)>)? *$`
|
||||
// See: https://github.com/Dan-Q/twee2/blob/d7659d84b5415d594dcc868628d74c3c9b48f496/lib/twee2/story_file.rb#L61
|
||||
|
||||
var (
|
||||
twee2DetectRe *regexp.Regexp
|
||||
twee2HeaderRe *regexp.Regexp
|
||||
twee2BadPosRe *regexp.Regexp
|
||||
)
|
||||
|
||||
func hasTwee2Syntax(s []byte) bool {
|
||||
// Initialize and cache the regular expressions if necessary.
|
||||
if twee2DetectRe == nil {
|
||||
twee2DetectRe = regexp.MustCompile(`(?m)^:: *[^\[]*?(?: *\[.*?\])? *<(.*?)> *$`)
|
||||
twee2HeaderRe = regexp.MustCompile(`(?m)^(:: *[^\[]*?)( *\[.*?\])?(?: *<(.*?)>)? *$`)
|
||||
twee2BadPosRe = regexp.MustCompile(`(?m)^(::.*?) *{"position":" *"}$`)
|
||||
}
|
||||
return twee2DetectRe.Match(s)
|
||||
}
|
||||
|
||||
// ToV3 returns a copy of the slice s with all instances of Twee2 position blocks
|
||||
// replaced with Twee v3 metadata blocks.
|
||||
func ToV3(s []byte) []byte {
|
||||
if hasTwee2Syntax(s) {
|
||||
s = twee2HeaderRe.ReplaceAll(s, []byte(`${1}${2} {"position":"${3}"}`))
|
||||
s = twee2BadPosRe.ReplaceAll(s, []byte(`$1`))
|
||||
}
|
||||
return s
|
||||
}
|
445
internal/tweelexer/tweelexer.go
Normal file
445
internal/tweelexer/tweelexer.go
Normal file
|
@ -0,0 +1,445 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
/*
|
||||
With kind regards to Rob Pike and his "Lexical Scanning in Go" talk.
|
||||
|
||||
Any and all coding horrors within are my own.
|
||||
*/
|
||||
|
||||
/*
|
||||
WARNING: Line Counts
|
||||
|
||||
Ensuring proper line counts is fraught with peril as several methods
|
||||
modify the line count and it's entirely possible, if one is not careful,
|
||||
to count newlines multiple times. For example, using `l.next()` to
|
||||
accept newlines, thus counting them, that are ultimately either emitted
|
||||
or ignored, which can cause them to be counted again.
|
||||
*/
|
||||
/*
|
||||
WARNING: Not Unicode Aware
|
||||
|
||||
Twee syntax is strictly limited to US-ASCII, so there's no compelling
|
||||
reason to decode the UTF-8 input.
|
||||
*/
|
||||
|
||||
package tweelexer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ItemType identifies the type of the items.
|
||||
type ItemType int
|
||||
|
||||
// Item represents a lexed item, a lexeme.
|
||||
type Item struct {
|
||||
Type ItemType // Type of the item.
|
||||
Line int // Line within the input (1-base) of the item.
|
||||
Pos int // Starting position within the input, in bytes, of the item.
|
||||
Val []byte // Value of the item.
|
||||
}
|
||||
|
||||
// String returns a formatted debugging string for the item.
|
||||
func (i Item) String() string {
|
||||
var name string
|
||||
switch i.Type {
|
||||
case ItemEOF:
|
||||
return fmt.Sprintf("[EOF: %d/%d]", i.Line, i.Pos)
|
||||
case ItemError:
|
||||
name = "Error"
|
||||
case ItemHeader:
|
||||
name = "Header"
|
||||
case ItemName:
|
||||
name = "Name"
|
||||
case ItemTags:
|
||||
name = "Tags"
|
||||
case ItemMetadata:
|
||||
name = "Metadata"
|
||||
case ItemContent:
|
||||
name = "Content"
|
||||
}
|
||||
if i.Type != ItemError && len(i.Val) > 80 {
|
||||
return fmt.Sprintf("[%s: %d/%d] %.80q...", name, i.Line, i.Pos, i.Val)
|
||||
}
|
||||
return fmt.Sprintf("[%s: %d/%d] %q", name, i.Line, i.Pos, i.Val)
|
||||
}
|
||||
|
||||
const eof = -1 // End of input value.
|
||||
|
||||
// TODO: golint claims ItemError, below, has no comment if the const
|
||||
// block comment, below, is removed. Report that lossage.
|
||||
|
||||
// Item type constants.
|
||||
const (
|
||||
ItemError ItemType = iota // Error. Its value is the error message.
|
||||
ItemEOF // End of input.
|
||||
ItemHeader // '::', but only when starting a line.
|
||||
ItemName // Text w/ backslash escaped characters.
|
||||
ItemTags // '[tag1 tag2 tagN]'.
|
||||
ItemMetadata // JSON chunk, '{…}'.
|
||||
ItemContent // Plain text.
|
||||
)
|
||||
|
||||
// stateFn state of the scanner as a function, which return the next state function.
|
||||
type stateFn func(*Tweelexer) stateFn
|
||||
|
||||
// Tweelexer holds the state of the scanner.
|
||||
type Tweelexer struct {
|
||||
input []byte // Byte slice being scanned.
|
||||
line int // Number of newlines seen (1-base).
|
||||
start int // Starting position of the current item.
|
||||
pos int // Current position within the input.
|
||||
items chan Item // Channel of scanned items.
|
||||
}
|
||||
|
||||
// next returns the next byte, as a rune, in the input.
|
||||
func (l *Tweelexer) next() rune {
|
||||
if l.pos >= len(l.input) {
|
||||
return eof
|
||||
}
|
||||
r := rune(l.input[l.pos])
|
||||
l.pos++
|
||||
if r == '\n' {
|
||||
l.line++
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// peek returns the next byte, as a rune, in the input, but does not consume it.
|
||||
func (l *Tweelexer) peek() rune {
|
||||
if l.pos >= len(l.input) {
|
||||
return eof
|
||||
}
|
||||
return rune(l.input[l.pos])
|
||||
}
|
||||
|
||||
// backup rewinds our position in the input by one byte.
|
||||
func (l *Tweelexer) backup() {
|
||||
if l.pos > l.start {
|
||||
l.pos--
|
||||
if l.input[l.pos] == '\n' {
|
||||
l.line--
|
||||
}
|
||||
} else {
|
||||
panic(fmt.Errorf("backup would leave pos < start"))
|
||||
}
|
||||
}
|
||||
|
||||
// emit sends an item to the item channel.
|
||||
func (l *Tweelexer) emit(t ItemType) {
|
||||
l.items <- Item{t, l.line, l.start, l.input[l.start:l.pos]}
|
||||
// Some items may contain newlines that must be counted.
|
||||
if t == ItemContent {
|
||||
l.line += bytes.Count(l.input[l.start:l.pos], []byte("\n"))
|
||||
}
|
||||
l.start = l.pos
|
||||
}
|
||||
|
||||
// ignore skips over the pending input.
|
||||
func (l *Tweelexer) ignore() {
|
||||
l.line += bytes.Count(l.input[l.start:l.pos], []byte("\n"))
|
||||
l.start = l.pos
|
||||
}
|
||||
|
||||
// accept consumes the next byte if it's from the valid set.
|
||||
func (l *Tweelexer) accept(valid []byte) bool {
|
||||
if bytes.ContainsRune(valid, l.next()) {
|
||||
return true
|
||||
}
|
||||
l.backup()
|
||||
return false
|
||||
}
|
||||
|
||||
// acceptRun consumes a run of bytes from the valid set.
|
||||
func (l *Tweelexer) acceptRun(valid []byte) {
|
||||
var r rune
|
||||
for r = l.next(); bytes.ContainsRune(valid, r); r = l.next() {
|
||||
}
|
||||
if r != eof {
|
||||
l.backup()
|
||||
}
|
||||
}
|
||||
|
||||
// errorf emits an error item and returns nil, allowing the scan to be terminated
|
||||
// simply by returning the call to errorf.
|
||||
func (l *Tweelexer) errorf(format string, args ...interface{}) stateFn {
|
||||
l.items <- Item{ItemError, l.line, l.start, []byte(fmt.Sprintf(format, args...))}
|
||||
return nil
|
||||
}
|
||||
|
||||
// run runs the state machine for tweelexer.
|
||||
func (l *Tweelexer) run() {
|
||||
for state := lexProlog; state != nil; {
|
||||
state = state(l)
|
||||
}
|
||||
close(l.items)
|
||||
}
|
||||
|
||||
// NewTweelexer creates a new scanner for the input text.
|
||||
func NewTweelexer(input []byte) *Tweelexer {
|
||||
l := &Tweelexer{
|
||||
input: input,
|
||||
line: 1,
|
||||
items: make(chan Item),
|
||||
}
|
||||
go l.run()
|
||||
return l
|
||||
}
|
||||
|
||||
// GetItems returns the item channel.
|
||||
// Called by the parser, not tweelexer.
|
||||
func (l *Tweelexer) GetItems() chan Item {
|
||||
return l.items
|
||||
}
|
||||
|
||||
// NextItem returns the next item and its ok status from the item channel.
|
||||
// Called by the parser, not tweelexer.
|
||||
func (l *Tweelexer) NextItem() (Item, bool) {
|
||||
// return <-l.items
|
||||
item, ok := <-l.items
|
||||
return item, ok
|
||||
}
|
||||
|
||||
// Drain drains the item channel so the lexing goroutine will close the item channel and exit.
|
||||
// Called by the parser, not tweelexer.
|
||||
func (l *Tweelexer) Drain() {
|
||||
for range l.items {
|
||||
}
|
||||
}
|
||||
|
||||
// acceptQuoted accepts a quoted string.
|
||||
// The opening quote has already been seen.
|
||||
func acceptQuoted(l *Tweelexer, quote rune) error {
|
||||
Loop:
|
||||
for {
|
||||
switch l.next() {
|
||||
case '\\':
|
||||
if r := l.next(); r != '\n' && r != eof {
|
||||
break
|
||||
}
|
||||
fallthrough
|
||||
case '\n', eof:
|
||||
return fmt.Errorf("unterminated quoted string")
|
||||
case quote:
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// State functions.
|
||||
|
||||
var (
|
||||
headerDelim = []byte("::")
|
||||
newlineHeaderDelim = []byte("\n::")
|
||||
)
|
||||
|
||||
// lexProlog skips until the first passage header delimiter.
|
||||
func lexProlog(l *Tweelexer) stateFn {
|
||||
if bytes.HasPrefix(l.input[l.pos:], headerDelim) {
|
||||
return lexHeaderDelim
|
||||
} else if i := bytes.Index(l.input[l.pos:], newlineHeaderDelim); i > -1 {
|
||||
l.pos += i + 1
|
||||
l.ignore()
|
||||
return lexHeaderDelim
|
||||
}
|
||||
l.emit(ItemEOF)
|
||||
return nil
|
||||
}
|
||||
|
||||
// lexContent scans until a passage header delimiter.
|
||||
func lexContent(l *Tweelexer) stateFn {
|
||||
if bytes.HasPrefix(l.input[l.pos:], headerDelim) {
|
||||
return lexHeaderDelim
|
||||
} else if i := bytes.Index(l.input[l.pos:], newlineHeaderDelim); i > -1 {
|
||||
l.pos += i + 1
|
||||
l.emit(ItemContent)
|
||||
return lexHeaderDelim
|
||||
}
|
||||
l.pos = len(l.input)
|
||||
if l.pos > l.start {
|
||||
l.emit(ItemContent)
|
||||
}
|
||||
l.emit(ItemEOF)
|
||||
return nil
|
||||
}
|
||||
|
||||
// lexHeaderDelim scans a passage header delimiter.
|
||||
func lexHeaderDelim(l *Tweelexer) stateFn {
|
||||
l.pos += len(headerDelim)
|
||||
l.emit(ItemHeader)
|
||||
return lexName
|
||||
}
|
||||
|
||||
// lexName scans a passage name until: one of the optional block delimiters, newline, or EOF.
|
||||
func lexName(l *Tweelexer) stateFn {
|
||||
var r rune
|
||||
Loop:
|
||||
for {
|
||||
r = l.next()
|
||||
switch r {
|
||||
case '\\':
|
||||
r = l.next()
|
||||
if r != '\n' && r != eof {
|
||||
break
|
||||
}
|
||||
fallthrough
|
||||
case '[', ']', '{', '}', '\n', eof:
|
||||
if r != eof {
|
||||
l.backup()
|
||||
}
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
// Always emit a name item, even if it's empty.
|
||||
l.emit(ItemName)
|
||||
|
||||
switch r {
|
||||
case '[':
|
||||
return lexTags
|
||||
case ']':
|
||||
return l.errorf("unexpected right square bracket %#U", r)
|
||||
case '{':
|
||||
return lexMetadata
|
||||
case '}':
|
||||
return l.errorf("unexpected right curly brace %#U", r)
|
||||
case '\n':
|
||||
l.pos++
|
||||
l.ignore()
|
||||
return lexContent
|
||||
}
|
||||
l.emit(ItemEOF)
|
||||
return nil
|
||||
}
|
||||
|
||||
// lexNextOptionalBlock scans within a header for the next optional block.
|
||||
func lexNextOptionalBlock(l *Tweelexer) stateFn {
|
||||
// Consume space.
|
||||
l.acceptRun([]byte(" \t"))
|
||||
l.ignore()
|
||||
|
||||
r := l.peek()
|
||||
// panic(fmt.Sprintf("[lexNextOptionalBlock: %d, %d:%d]", l.line, l.start, l.pos))
|
||||
switch r {
|
||||
case '[':
|
||||
return lexTags
|
||||
case ']':
|
||||
return l.errorf("unexpected right square bracket %#U", r)
|
||||
case '{':
|
||||
return lexMetadata
|
||||
case '}':
|
||||
return l.errorf("unexpected right curly brace %#U", r)
|
||||
case '\n':
|
||||
l.pos++
|
||||
l.ignore()
|
||||
return lexContent
|
||||
case eof:
|
||||
l.emit(ItemEOF)
|
||||
return nil
|
||||
}
|
||||
return l.errorf("illegal character %#U amid the optional blocks", r)
|
||||
}
|
||||
|
||||
// lexTags scans an optional tags block.
|
||||
func lexTags(l *Tweelexer) stateFn {
|
||||
// Consume the left delimiter '['.
|
||||
l.pos++
|
||||
|
||||
Loop:
|
||||
for {
|
||||
r := l.next()
|
||||
switch r {
|
||||
case '\\':
|
||||
r = l.next()
|
||||
if r != '\n' && r != eof {
|
||||
break
|
||||
}
|
||||
fallthrough
|
||||
case '\n', eof:
|
||||
if r == '\n' {
|
||||
l.backup()
|
||||
}
|
||||
return l.errorf("unterminated tag block")
|
||||
case ']':
|
||||
break Loop
|
||||
case '[':
|
||||
return l.errorf("unexpected left square bracket %#U", r)
|
||||
case '{':
|
||||
return l.errorf("unexpected left curly brace %#U", r)
|
||||
case '}':
|
||||
return l.errorf("unexpected right curly brace %#U", r)
|
||||
}
|
||||
}
|
||||
if l.pos > l.start {
|
||||
l.emit(ItemTags)
|
||||
}
|
||||
|
||||
return lexNextOptionalBlock
|
||||
}
|
||||
|
||||
// lexMetadata scans an optional (JSON) metadata block.
|
||||
func lexMetadata(l *Tweelexer) stateFn {
|
||||
// Consume the left delimiter '{'.
|
||||
l.pos++
|
||||
|
||||
depth := 1
|
||||
Loop:
|
||||
for {
|
||||
r := l.next()
|
||||
// switch r {
|
||||
// case '"': // Only double quoted strings are legal within JSON chunks.
|
||||
// if err := acceptQuoted(l, '"'); err != nil {
|
||||
// return l.errorf(err.Error())
|
||||
// }
|
||||
// case '\\':
|
||||
// r = l.next()
|
||||
// if r != '\n' && r != eof {
|
||||
// break
|
||||
// }
|
||||
// fallthrough
|
||||
// case '\n', eof:
|
||||
// if r == '\n' {
|
||||
// l.backup()
|
||||
// }
|
||||
// return l.errorf("unterminated metadata block")
|
||||
// case '{':
|
||||
// depth++
|
||||
// case '}':
|
||||
// depth--
|
||||
// switch {
|
||||
// case depth == 0:
|
||||
// break Loop
|
||||
// case depth < 0:
|
||||
// return l.errorf("unbalanced curly braces in metadata block")
|
||||
// }
|
||||
// }
|
||||
switch r {
|
||||
case '"': // Only double quoted strings are legal within JSON chunks.
|
||||
if err := acceptQuoted(l, '"'); err != nil {
|
||||
return l.errorf(err.Error())
|
||||
}
|
||||
case '\n':
|
||||
l.backup()
|
||||
fallthrough
|
||||
case eof:
|
||||
return l.errorf("unterminated metadata block")
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
}
|
||||
if l.pos > l.start {
|
||||
l.emit(ItemMetadata)
|
||||
}
|
||||
|
||||
return lexNextOptionalBlock
|
||||
}
|
184
io.go
Normal file
184
io.go
Normal file
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
// standard packages
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"unicode/utf8"
|
||||
// external packages
|
||||
"github.com/paulrosania/go-charset/charset"
|
||||
_ "github.com/paulrosania/go-charset/data" // import the charset data
|
||||
)
|
||||
|
||||
const (
|
||||
// Assumed encoding of input files which are not UTF-8 encoded.
|
||||
fallbackCharset = "windows-1252" // match case from "charset" packages
|
||||
|
||||
// Record separators.
|
||||
recordSeparatorLF = "\n" // I.e., UNIX-y OSes.
|
||||
recordSeparatorCRLF = "\r\n" // I.e., DOS/Windows.
|
||||
recordSeparatorCR = "\r" // I.e., MacOS ≤9.
|
||||
|
||||
utfBOM = "\uFEFF"
|
||||
)
|
||||
|
||||
func fileReadAllAsBase64(filename string) ([]byte, error) {
|
||||
var (
|
||||
r io.Reader
|
||||
data []byte
|
||||
err error
|
||||
)
|
||||
if filename == "-" {
|
||||
r = os.Stdin
|
||||
} else {
|
||||
var f *os.File
|
||||
if f, err = os.Open(filename); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
if data, err = ioutil.ReadAll(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := make([]byte, base64.StdEncoding.EncodedLen(len(data))) // try to avoid additional allocations
|
||||
base64.StdEncoding.Encode(buf, data)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func fileReadAllAsUTF8(filename string) ([]byte, error) {
|
||||
return fileReadAllWithEncoding(filename, "utf-8")
|
||||
}
|
||||
|
||||
func fileReadAllWithEncoding(filename, encoding string) ([]byte, error) {
|
||||
var (
|
||||
r io.Reader
|
||||
data []byte
|
||||
rsLF = []byte(recordSeparatorLF)
|
||||
rsCRLF = []byte(recordSeparatorCRLF)
|
||||
rsCR = []byte(recordSeparatorCR)
|
||||
err error
|
||||
)
|
||||
|
||||
// Read in the entire file.
|
||||
if filename == "-" {
|
||||
r = os.Stdin
|
||||
} else {
|
||||
var f *os.File
|
||||
if f, err = os.Open(filename); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
if data, err = ioutil.ReadAll(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert the charset to UTF-8, if necessary.
|
||||
encoding = charset.NormalizedName(encoding)
|
||||
if utf8.Valid(data) {
|
||||
switch encoding {
|
||||
case "", "utf-8", "utf8", "ascii", "us-ascii":
|
||||
// no-op
|
||||
default:
|
||||
log.Printf("warning: read %s: Already valid UTF-8; skipping charset conversion.", filename)
|
||||
}
|
||||
} else {
|
||||
switch encoding {
|
||||
case "utf-8", "utf8", "ascii", "us-ascii":
|
||||
log.Printf("warning: read %s: Invalid UTF-8; assuming charset is %s.", filename, fallbackCharset)
|
||||
fallthrough
|
||||
case "":
|
||||
encoding = charset.NormalizedName(fallbackCharset)
|
||||
}
|
||||
if r, err = charset.NewReader(encoding, bytes.NewReader(data)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data, err = ioutil.ReadAll(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return nil, fmt.Errorf("read %s: Charset conversion yielded invalid UTF-8.", filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the UTF BOM (\uFEFF), if it exists.
|
||||
if bytes.Equal(data[:3], []byte(utfBOM)) {
|
||||
data = data[3:]
|
||||
}
|
||||
|
||||
// Normalize record separators.
|
||||
data = bytes.Replace(data, rsCRLF, rsLF, -1)
|
||||
data = bytes.Replace(data, rsCR, rsLF, -1)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func alignRecordSeparators(data []byte) []byte {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return bytes.Replace(data, []byte(recordSeparatorLF), []byte(recordSeparatorCRLF), -1)
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
func modifyHead(data []byte, modulePaths []string, headFile, encoding string) []byte {
|
||||
var headTags [][]byte
|
||||
|
||||
if len(modulePaths) > 0 {
|
||||
source := bytes.TrimSpace(loadModules(modulePaths, encoding))
|
||||
if len(source) > 0 {
|
||||
headTags = append(headTags, source)
|
||||
}
|
||||
}
|
||||
|
||||
if headFile != "" {
|
||||
if source, err := fileReadAllWithEncoding(headFile, encoding); err == nil {
|
||||
source = bytes.TrimSpace(source)
|
||||
if len(source) > 0 {
|
||||
headTags = append(headTags, source)
|
||||
}
|
||||
statsAddExternalFile(headFile)
|
||||
} else {
|
||||
log.Fatalf("error: load %s: %s", headFile, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(headTags) > 0 {
|
||||
headTags = append(headTags, []byte("</head>"))
|
||||
return bytes.Replace(data, []byte("</head>"), bytes.Join(headTags, []byte("\n")), 1)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func fileWriteAll(filename string, data []byte) (int, error) {
|
||||
var (
|
||||
w io.Writer
|
||||
err error
|
||||
)
|
||||
if filename == "-" {
|
||||
w = os.Stdout
|
||||
} else {
|
||||
var f *os.File
|
||||
if f, err = os.Create(filename); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
w = f
|
||||
}
|
||||
return w.Write(data)
|
||||
}
|
131
module.go
Normal file
131
module.go
Normal file
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func loadModules(filenames []string, encoding string) []byte {
|
||||
var (
|
||||
processedModules = make(map[string]bool)
|
||||
headTags [][]byte
|
||||
)
|
||||
|
||||
for _, filename := range filenames {
|
||||
if processedModules[filename] {
|
||||
log.Printf("warning: load %s: Skipping duplicate.", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
source []byte
|
||||
err error
|
||||
)
|
||||
switch normalizedFileExt(filename) {
|
||||
// NOTE: The case values here should match those in `filesystem.go:knownFileType()`.
|
||||
case "css":
|
||||
source, err = loadModuleTagged("style", filename, encoding)
|
||||
case "js":
|
||||
source, err = loadModuleTagged("script", filename, encoding)
|
||||
case "otf", "ttf", "woff", "woff2":
|
||||
source, err = loadModuleFont(filename)
|
||||
default:
|
||||
// Simply ignore all other file types.
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
if len(source) > 0 {
|
||||
headTags = append(headTags, source)
|
||||
}
|
||||
processedModules[filename] = true
|
||||
statsAddExternalFile(filename)
|
||||
}
|
||||
|
||||
return bytes.Join(headTags, []byte("\n"))
|
||||
}
|
||||
|
||||
func loadModuleTagged(tag, filename, encoding string) ([]byte, error) {
|
||||
source, err := fileReadAllWithEncoding(filename, encoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source = bytes.TrimSpace(source)
|
||||
if len(source) == 0 {
|
||||
return source, nil
|
||||
}
|
||||
|
||||
var (
|
||||
idSlug = tag + "-module-" + slugify(strings.Split(filepath.Base(filename), ".")[0])
|
||||
mimeType string
|
||||
b bytes.Buffer
|
||||
)
|
||||
switch tag {
|
||||
case "script":
|
||||
mimeType = "text/javascript"
|
||||
case "style":
|
||||
mimeType = "text/css"
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(
|
||||
&b,
|
||||
`<%s id=%q type=%q>%s</%[1]s>`,
|
||||
tag,
|
||||
idSlug,
|
||||
mimeType,
|
||||
source,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func loadModuleFont(filename string) ([]byte, error) {
|
||||
source, err := fileReadAllAsBase64(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
name = filepath.Base(filename)
|
||||
family = strings.Split(name, ".")[0]
|
||||
idSlug = "style-module-" + slugify(family)
|
||||
ext = normalizedFileExt(filename)
|
||||
mediaType = mediaTypeFromExt(ext)
|
||||
hint string
|
||||
b bytes.Buffer
|
||||
)
|
||||
switch ext {
|
||||
case "ttf":
|
||||
hint = "truetype"
|
||||
case "otf":
|
||||
hint = "opentype"
|
||||
default:
|
||||
hint = ext
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(
|
||||
&b,
|
||||
"<style id=%q type=\"text/css\">@font-face {\n\tfont-family: %q;\n\tsrc: url(\"data:%s;base64,%s\") format(%q);\n}</style>",
|
||||
idSlug,
|
||||
family,
|
||||
mediaType,
|
||||
source,
|
||||
hint,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Bytes(), nil
|
||||
}
|
262
passage.go
Normal file
262
passage.go
Normal file
|
@ -0,0 +1,262 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
// standard packages
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
// external packages
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// Info passages are passages which contain solely structural data, metadata,
|
||||
// and code, rather than any actual story content.
|
||||
var infoPassages = []string{
|
||||
// Story formats: Twine 1.4+ vanilla & SugarCube.
|
||||
"StoryAuthor", "StoryInit", "StoryMenu", "StorySubtitle", "StoryTitle",
|
||||
|
||||
// Story formats: SugarCube.
|
||||
"PassageReady", "PassageDone", "PassageHeader", "PassageFooter", "StoryBanner", "StoryCaption",
|
||||
|
||||
// Story formats: SugarCube (v1 only).
|
||||
"MenuOptions", "MenuShare", "MenuStory",
|
||||
|
||||
// Story formats: SugarCube (v2 only).
|
||||
"StoryInterface", "StoryShare",
|
||||
|
||||
// Story formats: Twine 1.4+ vanilla.
|
||||
// Compilers: Twine/Twee 1.4+, Twee2, & Tweego.
|
||||
"StorySettings",
|
||||
|
||||
// Compilers: Tweego & (whatever Dan Cox's compiler is called).
|
||||
"StoryData",
|
||||
|
||||
// Compilers: Twine/Twee 1.4+ & Twee2.
|
||||
"StoryIncludes",
|
||||
}
|
||||
|
||||
type passageMetadata struct {
|
||||
position string // Unused by Tweego. Twine 1 & 2 passage block X and Y coordinates CSV.
|
||||
size string // Unused by Tweego. Twine 2 passage block width and height CSV.
|
||||
}
|
||||
|
||||
type passage struct {
|
||||
// Core.
|
||||
name string
|
||||
tags []string
|
||||
text string
|
||||
|
||||
// Compiler metadata.
|
||||
metadata *passageMetadata
|
||||
}
|
||||
|
||||
func newPassage(name string, tags []string, source string) *passage {
|
||||
return &passage{
|
||||
name: name,
|
||||
tags: tags,
|
||||
text: source,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *passage) equals(second passage) bool {
|
||||
return p.text == second.text
|
||||
}
|
||||
|
||||
func (p *passage) tagsHas(needle string) bool {
|
||||
if len(p.tags) > 0 {
|
||||
for _, tag := range p.tags {
|
||||
if tag == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *passage) tagsHasAny(needles ...string) bool {
|
||||
if len(p.tags) > 0 {
|
||||
for _, tag := range p.tags {
|
||||
for _, needle := range needles {
|
||||
if tag == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *passage) tagsContains(needle string) bool {
|
||||
if len(p.tags) > 0 {
|
||||
for _, tag := range p.tags {
|
||||
if strings.Contains(tag, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *passage) tagsStartsWith(needle string) bool {
|
||||
if len(p.tags) > 0 {
|
||||
for _, tag := range p.tags {
|
||||
if strings.HasPrefix(tag, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *passage) hasMetadataPosition() bool {
|
||||
return p.metadata != nil && p.metadata.position != ""
|
||||
}
|
||||
|
||||
func (p *passage) hasMetadataSize() bool {
|
||||
return p.metadata != nil && p.metadata.size != ""
|
||||
}
|
||||
|
||||
func (p *passage) hasAnyMetadata() bool {
|
||||
return p.metadata != nil && (p.metadata.position != "" || p.metadata.size != "")
|
||||
}
|
||||
|
||||
func (p *passage) hasInfoTags() bool {
|
||||
return p.tagsHasAny("annotation", "script", "stylesheet", "widget") || p.tagsStartsWith("Twine.")
|
||||
}
|
||||
|
||||
func (p *passage) hasInfoName() bool {
|
||||
return stringSliceContains(infoPassages, p.name)
|
||||
}
|
||||
|
||||
func (p *passage) isInfoPassage() bool {
|
||||
return p.hasInfoName() || p.hasInfoTags()
|
||||
}
|
||||
|
||||
func (p *passage) isStoryPassage() bool {
|
||||
return !p.hasInfoName() && !p.hasInfoTags()
|
||||
}
|
||||
|
||||
func (p *passage) toTwee(outMode outputMode) string {
|
||||
var output string
|
||||
if outMode == outModeTwee3 {
|
||||
output = ":: " + tweeEscapeString(p.name)
|
||||
if len(p.tags) > 0 {
|
||||
output += " [" + tweeEscapeString(strings.Join(p.tags, " ")) + "]"
|
||||
}
|
||||
if p.hasAnyMetadata() {
|
||||
output += " " + string(p.marshalMetadata())
|
||||
}
|
||||
} else {
|
||||
output = ":: " + p.name
|
||||
if len(p.tags) > 0 {
|
||||
output += " [" + strings.Join(p.tags, " ") + "]"
|
||||
}
|
||||
}
|
||||
output += "\n"
|
||||
if len(p.text) > 0 {
|
||||
output += p.text + "\n"
|
||||
}
|
||||
output += "\n\n"
|
||||
return output
|
||||
}
|
||||
|
||||
func (p *passage) toPassagedata(pid uint) string {
|
||||
var (
|
||||
position string
|
||||
size string
|
||||
)
|
||||
if p.hasMetadataPosition() {
|
||||
position = p.metadata.position
|
||||
} else {
|
||||
// No position metadata, so generate something sensible on the fly.
|
||||
x := pid % 10
|
||||
y := pid / 10
|
||||
if x == 0 {
|
||||
x = 10
|
||||
} else {
|
||||
y++
|
||||
}
|
||||
position = fmt.Sprintf("%d,%d", x*125-25, y*125-25)
|
||||
}
|
||||
if p.hasMetadataSize() {
|
||||
size = p.metadata.size
|
||||
} else {
|
||||
// No size metadata, so default to the normal size.
|
||||
size = "100,100"
|
||||
}
|
||||
|
||||
/*
|
||||
<tw-passagedata pid="…" name="…" tags="…" position="…" size="…">…</tw-passagedata>
|
||||
*/
|
||||
return fmt.Sprintf(`<tw-passagedata pid="%d" name=%q tags=%q position=%q size=%q>%s</tw-passagedata>`,
|
||||
pid,
|
||||
attrEscapeString(p.name),
|
||||
attrEscapeString(strings.Join(p.tags, " ")),
|
||||
attrEscapeString(position),
|
||||
attrEscapeString(size),
|
||||
htmlEscapeString(p.text),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *passage) toTiddler(pid uint) string {
|
||||
var position string
|
||||
if p.hasMetadataPosition() {
|
||||
position = p.metadata.position
|
||||
} else {
|
||||
// No position metadata, so generate something sensible on the fly.
|
||||
x := pid % 10
|
||||
y := pid / 10
|
||||
if x == 0 {
|
||||
x = 10
|
||||
} else {
|
||||
y++
|
||||
}
|
||||
position = fmt.Sprintf("%d,%d", x*140-130, y*140-130)
|
||||
}
|
||||
|
||||
/*
|
||||
<div tiddler="…" tags="…" created="…" modifier="…" twine-position="…">…</div>
|
||||
*/
|
||||
return fmt.Sprintf(`<div tiddler=%q tags=%q twine-position=%q>%s</div>`,
|
||||
attrEscapeString(p.name),
|
||||
attrEscapeString(strings.Join(p.tags, " ")),
|
||||
attrEscapeString(position),
|
||||
tiddlerEscapeString(p.text),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *passage) countWords() uint64 {
|
||||
text := p.text
|
||||
|
||||
// Strip newlines.
|
||||
text = strings.Replace(text, "\n", "", -1)
|
||||
|
||||
// Strip comments.
|
||||
re := regexp.MustCompile(`(?s:/%.*?%/|/\*.*?\*/|<!--.*?-->)`)
|
||||
text = re.ReplaceAllString(text, "")
|
||||
|
||||
// Count normalized "characters".
|
||||
var (
|
||||
count uint64
|
||||
ia norm.Iter
|
||||
)
|
||||
ia.InitString(norm.NFKD, text)
|
||||
for !ia.Done() {
|
||||
count++
|
||||
ia.Next()
|
||||
}
|
||||
|
||||
// Count "words", typing measurement style—i.e., 5 "characters" per "word".
|
||||
words := count / 5
|
||||
if count%5 > 0 {
|
||||
words++
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
42
passagedata.go
Normal file
42
passagedata.go
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type passageMetadataJSON struct {
|
||||
Position string `json:"position,omitempty"` // Twine 2 (`position`) & Twine 1 (`twine-position`).
|
||||
Size string `json:"size,omitempty"` // Twine 2 (`size`).
|
||||
}
|
||||
|
||||
func (p *passage) marshalMetadata() []byte {
|
||||
marshaled, err := json.Marshal(&passageMetadataJSON{
|
||||
p.metadata.position,
|
||||
p.metadata.size,
|
||||
})
|
||||
if err != nil {
|
||||
// NOTE: We should never be able to see an error here. If we do,
|
||||
// then something truly exceptional—in a bad way—has happened, so
|
||||
// we get our panic on.
|
||||
panic(err)
|
||||
}
|
||||
return marshaled
|
||||
}
|
||||
|
||||
func (p *passage) unmarshalMetadata(marshaled []byte) error {
|
||||
metadata := passageMetadataJSON{}
|
||||
if err := json.Unmarshal(marshaled, &metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
p.metadata = &passageMetadata{
|
||||
position: metadata.Position,
|
||||
size: metadata.Size,
|
||||
}
|
||||
return nil
|
||||
}
|
48
sort.go
Normal file
48
sort.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type StringsInsensitively []string
|
||||
|
||||
func (p StringsInsensitively) Len() int {
|
||||
return len(p)
|
||||
}
|
||||
|
||||
func (p StringsInsensitively) Swap(i, j int) {
|
||||
p[i], p[j] = p[j], p[i]
|
||||
}
|
||||
|
||||
func (p StringsInsensitively) Less(i, j int) bool {
|
||||
iRunes := []rune(p[i])
|
||||
jRunes := []rune(p[j])
|
||||
|
||||
uBound := len(iRunes)
|
||||
if uBound > len(jRunes) {
|
||||
uBound = len(jRunes)
|
||||
}
|
||||
|
||||
for pos := 0; pos < uBound; pos++ {
|
||||
iR := iRunes[pos]
|
||||
jR := jRunes[pos]
|
||||
|
||||
iRLo := unicode.ToLower(iR)
|
||||
jRLo := unicode.ToLower(jR)
|
||||
|
||||
if iRLo != jRLo {
|
||||
return iRLo < jRLo
|
||||
}
|
||||
if iR != jR {
|
||||
return iR < jR
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
52
statsitics.go
Normal file
52
statsitics.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
// statistics are collected statistics about the compiled story.
|
||||
type statistics struct {
|
||||
files struct {
|
||||
project []string // Project source files.
|
||||
external []string // Both modules and the head file.
|
||||
}
|
||||
counts struct {
|
||||
passages uint64 // Count of all passages.
|
||||
storyPassages uint64 // Count of story passages.
|
||||
storyWords uint64 // Count of story passage "words" (typing measurement style).
|
||||
}
|
||||
}
|
||||
|
||||
var stats = statistics{}
|
||||
|
||||
func statsAddProjectFile(filepath string) {
|
||||
stats.files.project = append(stats.files.project, filepath)
|
||||
}
|
||||
|
||||
func statsAddExternalFile(filepath string) {
|
||||
stats.files.external = append(stats.files.external, filepath)
|
||||
}
|
||||
|
||||
func statsLog() {
|
||||
log.Print("Statistics")
|
||||
log.Printf(" Total> Passages: %d", stats.counts.passages)
|
||||
log.Printf(" Story> Passages: %d, Words: %d", stats.counts.storyPassages, stats.counts.storyWords)
|
||||
}
|
||||
|
||||
func statsLogFiles() {
|
||||
log.Println("Processed files (in order)")
|
||||
log.Printf(" Project files: %d", len(stats.files.project))
|
||||
for _, file := range stats.files.project {
|
||||
log.Printf(" %s", file)
|
||||
}
|
||||
log.Printf(" External files: %d", len(stats.files.external))
|
||||
for _, file := range stats.files.external {
|
||||
log.Printf(" %s", file)
|
||||
}
|
||||
}
|
189
story.go
Normal file
189
story.go
Normal file
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Twine 1 story metadata.
|
||||
type twine1Metadata struct {
|
||||
// WARNING: Do not use individual fields here as story formats are allowed to
|
||||
// define their own `StorySettings` pairs—via a custom header file—so we have
|
||||
// no way of knowing what the keys might be prior to parsing the passage.
|
||||
settings map[string]string // Map of `StorySettings` key/value pairs.
|
||||
}
|
||||
|
||||
// Twine 2 story metadata.
|
||||
type twine2OptionsMap map[string]bool
|
||||
type twine2TagColorsMap map[string]string
|
||||
type twine2Metadata struct {
|
||||
format string // Name of the story format.
|
||||
formatVersion string // SemVer of the story format.
|
||||
options twine2OptionsMap // Map of option-name/bool pairs.
|
||||
start string // Name of the starting passage.
|
||||
tagColors twine2TagColorsMap // Unused by Tweego. Map of tag-name/color pairs.
|
||||
zoom float64 // Unused by Tweego. Zoom level. Why is this even a part of the story metadata? It's editor configuration.
|
||||
}
|
||||
|
||||
// Core story data.
|
||||
type story struct {
|
||||
name string
|
||||
ifid string // A v4 random UUID, see: https://ifdb.tads.org/help-ifid.
|
||||
passages []*passage
|
||||
|
||||
// Legacy fields from Tweego v1 StorySettings.
|
||||
legacyIFID string
|
||||
|
||||
// Twine 1 & 2 compiler metadata.
|
||||
twine1 twine1Metadata
|
||||
twine2 twine2Metadata
|
||||
|
||||
// Tweego compiler internals.
|
||||
format *storyFormat
|
||||
processed map[string]bool
|
||||
}
|
||||
|
||||
// newStory creates a new story instance.
|
||||
func newStory() *story {
|
||||
return &story{
|
||||
passages: make([]*passage, 0, 64), // Initially create enough space for 64 passages.
|
||||
twine1: twine1Metadata{
|
||||
settings: make(map[string]string),
|
||||
},
|
||||
twine2: twine2Metadata{
|
||||
options: make(twine2OptionsMap),
|
||||
tagColors: make(twine2TagColorsMap),
|
||||
zoom: 1,
|
||||
},
|
||||
processed: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *story) count() int {
|
||||
return len(s.passages)
|
||||
}
|
||||
|
||||
func (s *story) has(name string) bool {
|
||||
for _, p := range s.passages {
|
||||
if p.name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *story) index(name string) int {
|
||||
for i, p := range s.passages {
|
||||
if p.name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *story) get(name string) (*passage, error) {
|
||||
for _, p := range s.passages {
|
||||
if p.name == name {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("get %s: No such passage.", name)
|
||||
}
|
||||
|
||||
func (s *story) deleteAt(i int) error {
|
||||
upper := len(s.passages) - 1
|
||||
if 0 > i || i > upper {
|
||||
return fmt.Errorf("deleteAt %d: Index out of range.", i)
|
||||
}
|
||||
|
||||
// TODO: Should the `copy()` only occur if `i < upper`?
|
||||
copy(s.passages[i:], s.passages[i+1:]) // shift elements down by one to overwrite the original element
|
||||
|
||||
s.passages[upper] = nil // zero the last element, which was itself duplicated by the last operation
|
||||
s.passages = s.passages[:upper] // reslice to remove the last element
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *story) append(p *passage) {
|
||||
// Append the passage if new, elsewise replace the existing version.
|
||||
if i := s.index(p.name); i == -1 {
|
||||
s.passages = append(s.passages, p)
|
||||
stats.counts.passages++
|
||||
if p.isStoryPassage() {
|
||||
stats.counts.storyPassages++
|
||||
stats.counts.storyWords += p.countWords()
|
||||
}
|
||||
} else {
|
||||
log.Printf("warning: Replacing existing passage %q with duplicate.", p.name)
|
||||
s.passages[i] = p
|
||||
}
|
||||
}
|
||||
|
||||
func (s *story) prepend(p *passage) {
|
||||
// Prepend the passage if new, elsewise replace the existing version.
|
||||
if i := s.index(p.name); i == -1 {
|
||||
s.passages = append([]*passage{p}, s.passages...)
|
||||
stats.counts.passages++
|
||||
if p.isStoryPassage() {
|
||||
stats.counts.storyPassages++
|
||||
stats.counts.storyWords += p.countWords()
|
||||
}
|
||||
} else {
|
||||
log.Printf("warning: Replacing existing passage %q with duplicate.", p.name)
|
||||
s.passages[i] = p
|
||||
}
|
||||
}
|
||||
|
||||
func (s *story) add(p *passage) {
|
||||
// Preprocess compiler-oriented special passages.
|
||||
switch p.name {
|
||||
case "StoryIncludes":
|
||||
/*
|
||||
NOTE: StoryIncludes is a compiler special passage for Twine 1.4,
|
||||
and apparently Twee2. Twee 1.4 does not support it—likely for
|
||||
the same reasons Tweego will not (outlined below).
|
||||
|
||||
You may specify an arbitrary number of files and directories on
|
||||
the the command line for Tweego to process. Furthermore, it will
|
||||
search all directories encountered during processing looking for
|
||||
additional files and directories. Thus, supporting StoryIncludes
|
||||
would be beyond pointless.
|
||||
|
||||
If we see StoryIncludes, log a warning.
|
||||
*/
|
||||
log.Print(`warning: Ignoring "StoryIncludes" compiler special passage; and it is ` +
|
||||
`recommended that you remove it. Tweego allows you to specify project ` +
|
||||
`files and/or directories to recursively search for such files on the ` +
|
||||
`command line. Thus, in practice, you only need to specify a project's ` +
|
||||
`root directory and Tweego will find all of its files automatically.`)
|
||||
case "StoryData":
|
||||
if err := s.unmarshalStoryData([]byte(p.text)); err == nil {
|
||||
// Validiate the IFID.
|
||||
if len(s.ifid) > 0 {
|
||||
if err := validateIFID(s.ifid); err != nil {
|
||||
log.Fatalf(`error: Cannot validate IFID; %s.`, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the passage contents to remove deleted and/or erroneous entries.
|
||||
p.text = string(s.marshalStoryData())
|
||||
} else {
|
||||
// log.Printf(`warning: Cannot unmarshal "StoryData" compiler special passage; %s.`, err.Error())
|
||||
log.Fatalf(`error: Cannot unmarshal "StoryData" compiler special passage; %s.`, err.Error())
|
||||
}
|
||||
case "StorySettings":
|
||||
if err := s.unmarshalStorySettings([]byte(p.text)); err != nil {
|
||||
log.Printf(`warning: Cannot unmarshal "StorySettings" special passage; %s.`, err.Error())
|
||||
}
|
||||
case "StoryTitle":
|
||||
s.name = p.text
|
||||
}
|
||||
|
||||
s.append(p)
|
||||
}
|
175
storydata.go
Normal file
175
storydata.go
Normal file
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type storyDataJSON struct {
|
||||
Ifid string `json:"ifid,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
FormatVersion string `json:"format-version,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Start string `json:"start,omitempty"`
|
||||
TagColors twine2TagColorsMap `json:"tag-colors,omitempty"`
|
||||
Zoom float64 `json:"zoom,omitempty"`
|
||||
}
|
||||
|
||||
func (s *story) marshalStoryData() []byte {
|
||||
marshaled, err := json.MarshalIndent(
|
||||
&storyDataJSON{
|
||||
s.ifid,
|
||||
s.twine2.format,
|
||||
s.twine2.formatVersion,
|
||||
twine2OptionsMapToSlice(s.twine2.options),
|
||||
s.twine2.start,
|
||||
s.twine2.tagColors,
|
||||
s.twine2.zoom,
|
||||
},
|
||||
"",
|
||||
"\t",
|
||||
)
|
||||
if err != nil {
|
||||
// NOTE: We should never be able to see an error here. If we do,
|
||||
// then something truly exceptional—in a bad way—has happened, so
|
||||
// we get our panic on.
|
||||
panic(err)
|
||||
}
|
||||
return marshaled
|
||||
}
|
||||
|
||||
func (s *story) unmarshalStoryData(marshaled []byte) error {
|
||||
storyData := storyDataJSON{}
|
||||
if err := json.Unmarshal(marshaled, &storyData); err != nil {
|
||||
return err
|
||||
}
|
||||
s.ifid = strings.ToUpper(storyData.Ifid) // NOTE: Force uppercase for consistency.
|
||||
s.twine2.format = storyData.Format
|
||||
s.twine2.formatVersion = storyData.FormatVersion
|
||||
s.twine2.options = twine2OptionsSliceToMap(storyData.Options)
|
||||
s.twine2.start = storyData.Start
|
||||
s.twine2.tagColors = storyData.TagColors
|
||||
if storyData.Zoom != 0 {
|
||||
s.twine2.zoom = storyData.Zoom
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func twine2OptionsMapToSlice(optMap twine2OptionsMap) []string {
|
||||
optSlice := []string{}
|
||||
if len(optMap) > 0 {
|
||||
for opt, val := range optMap {
|
||||
if val {
|
||||
optSlice = append(optSlice, opt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return optSlice
|
||||
}
|
||||
|
||||
func twine2OptionsSliceToMap(optSlice []string) twine2OptionsMap {
|
||||
optMap := make(twine2OptionsMap)
|
||||
for _, opt := range optSlice {
|
||||
optMap[opt] = true
|
||||
}
|
||||
return optMap
|
||||
}
|
||||
|
||||
// func (s *story) marshalStorySettings() []byte {
|
||||
// var marshaled [][]byte
|
||||
// for key, val := range s.twine1.settings {
|
||||
// marshaled = append(marshaled, []byte(key+":"+val))
|
||||
// }
|
||||
// return bytes.Join(marshaled, []byte("\n"))
|
||||
// }
|
||||
|
||||
func (s *story) unmarshalStorySettings(marshaled []byte) error {
|
||||
/*
|
||||
NOTE: (ca. Feb 28, 2019) Transition away from storing metadata within
|
||||
the StorySettings special passage and to the StoryData special passages
|
||||
for two reasons:
|
||||
|
||||
1. I've discovered that it's not as Twine 1-safe as I'd originally believed.
|
||||
When Twine 1 imports a StorySettings passage, it does not check if fields
|
||||
exist before appending "missing" fields, so it's entirely possible to end
|
||||
up with the first appended field essentially being concatenated to the end
|
||||
of the last of the previously existing fields. Not good.
|
||||
2. Twee 3 standardization
|
||||
*/
|
||||
/*
|
||||
LEGACY
|
||||
*/
|
||||
var obsolete []string
|
||||
/*
|
||||
END LEGACY
|
||||
*/
|
||||
for _, line := range bytes.Split(marshaled, []byte{'\n'}) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) > 0 {
|
||||
if i := bytes.IndexRune(line, ':'); i != -1 {
|
||||
key := string(bytes.ToLower(bytes.TrimSpace(line[:i])))
|
||||
val := string(bytes.ToLower(bytes.TrimSpace(line[i+1:])))
|
||||
|
||||
/*
|
||||
LEGACY
|
||||
*/
|
||||
switch key {
|
||||
case "ifid":
|
||||
if err := validateIFID(val); err == nil {
|
||||
s.legacyIFID = strings.ToUpper(val) // NOTE: Force uppercase for consistency.
|
||||
}
|
||||
obsolete = append(obsolete, `"ifid"`)
|
||||
continue
|
||||
case "zoom":
|
||||
// NOTE: Just drop it.
|
||||
obsolete = append(obsolete, `"zoom"`)
|
||||
continue
|
||||
}
|
||||
/*
|
||||
END LEGACY
|
||||
*/
|
||||
|
||||
s.twine1.settings[key] = val
|
||||
} else {
|
||||
log.Printf(`warning: Malformed "StorySettings" entry; skipping %q.`, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
LEGACY
|
||||
*/
|
||||
if len(obsolete) > 0 {
|
||||
var (
|
||||
entries string
|
||||
pronoun string
|
||||
)
|
||||
if len(obsolete) == 1 {
|
||||
entries = "entry"
|
||||
pronoun = "it"
|
||||
} else {
|
||||
entries = "entries"
|
||||
pronoun = "them"
|
||||
}
|
||||
log.Printf(
|
||||
`warning: Detected obsolete "StorySettings" %s: %s. `+
|
||||
`Please remove %s from the "StorySettings" special passage. If doing `+
|
||||
`so leaves the passage empty, please remove it as well.`,
|
||||
entries,
|
||||
strings.Join(obsolete, ", "),
|
||||
pronoun,
|
||||
)
|
||||
}
|
||||
/*
|
||||
END LEGACY
|
||||
*/
|
||||
|
||||
return nil
|
||||
}
|
443
storyload.go
Normal file
443
storyload.go
Normal file
|
@ -0,0 +1,443 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
// standard packages
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
// internal packages
|
||||
twee2 "bitbucket.org/tmedwards/tweego/internal/twee2compat"
|
||||
twlex "bitbucket.org/tmedwards/tweego/internal/tweelexer"
|
||||
// external packages
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func (s *story) load(filenames []string, c *config) {
|
||||
for _, filename := range filenames {
|
||||
if s.processed[filename] {
|
||||
log.Printf("warning: load %s: Skipping duplicate.", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
switch normalizedFileExt(filename) {
|
||||
// NOTE: The case values here should match those in `filesystem.go:knownFileType()`.
|
||||
case "tw", "twee":
|
||||
if err := s.loadTwee(filename, c.encoding, c.twee2Compat); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "tw2", "twee2":
|
||||
if err := s.loadTwee(filename, c.encoding, true); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "htm", "html":
|
||||
if err := s.loadHTML(filename, c.encoding); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "css":
|
||||
if err := s.loadTagged("stylesheet", filename, c.encoding); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "js":
|
||||
if err := s.loadTagged("script", filename, c.encoding); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "otf", "ttf", "woff", "woff2":
|
||||
if err := s.loadFont(filename); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "gif", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp":
|
||||
if err := s.loadMedia("Twine.image", filename); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "wave", "weba":
|
||||
if err := s.loadMedia("Twine.audio", filename); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "mp4", "ogv", "webm":
|
||||
if err := s.loadMedia("Twine.video", filename); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
case "vtt":
|
||||
if err := s.loadMedia("Twine.vtt", filename); err != nil {
|
||||
log.Fatalf("error: load %s: %s", filename, err.Error())
|
||||
}
|
||||
default:
|
||||
// Simply ignore all other file types.
|
||||
continue
|
||||
}
|
||||
s.processed[filename] = true
|
||||
statsAddProjectFile(filename)
|
||||
}
|
||||
|
||||
/*
|
||||
Postprocessing.
|
||||
*/
|
||||
|
||||
// Prepend the `StoryTitle` special passage, if necessary.
|
||||
if s.name != "" && !s.has("StoryTitle") {
|
||||
s.prepend(newPassage("StoryTitle", []string{}, s.name))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *story) loadTwee(filename, encoding string, twee2Compat bool) error {
|
||||
source, err := fileReadAllWithEncoding(filename, encoding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if twee2Compat {
|
||||
source = twee2.ToV3(source)
|
||||
}
|
||||
|
||||
var (
|
||||
pCount = 0
|
||||
lastType twlex.ItemType
|
||||
lex = twlex.NewTweelexer(source)
|
||||
)
|
||||
|
||||
ParseLoop:
|
||||
for {
|
||||
p := &passage{}
|
||||
for item, ok := lex.NextItem(); ok; item, ok = lex.NextItem() {
|
||||
// switch item.Type {
|
||||
// case twlex.ItemEOF, twlex.ItemHeader:
|
||||
// log.Println()
|
||||
// }
|
||||
// log.Printf("%v\n", item)
|
||||
|
||||
switch item.Type {
|
||||
case twlex.ItemError:
|
||||
return fmt.Errorf("line %d: Malformed twee source; %s.", item.Line, item.Val)
|
||||
|
||||
case twlex.ItemEOF:
|
||||
// Add the final passage, if any.
|
||||
if pCount > 0 {
|
||||
s.add(p)
|
||||
}
|
||||
break ParseLoop
|
||||
|
||||
case twlex.ItemHeader:
|
||||
pCount++
|
||||
if pCount > 1 {
|
||||
s.add(p)
|
||||
p = &passage{}
|
||||
}
|
||||
|
||||
case twlex.ItemName:
|
||||
p.name = string(bytes.TrimSpace(tweeUnescapeBytes(item.Val)))
|
||||
if len(p.name) == 0 {
|
||||
lex.Drain()
|
||||
return fmt.Errorf("line %d: Malformed twee source; passage with no name.", item.Line)
|
||||
}
|
||||
|
||||
case twlex.ItemTags:
|
||||
if lastType != twlex.ItemName {
|
||||
lex.Drain()
|
||||
return fmt.Errorf("line %d: Malformed twee source; optional tags block must immediately follow the passage name.", item.Line)
|
||||
}
|
||||
p.tags = strings.Fields(string(tweeUnescapeBytes(item.Val[1 : len(item.Val)-1])))
|
||||
|
||||
case twlex.ItemMetadata:
|
||||
if lastType != twlex.ItemName && lastType != twlex.ItemTags {
|
||||
lex.Drain()
|
||||
return fmt.Errorf("line %d: Malformed twee source; optional metadata block must immediately follow the passage name or tags block.", item.Line)
|
||||
}
|
||||
if err := p.unmarshalMetadata(item.Val); err != nil {
|
||||
log.Printf("warning: load %s: line %d: Malformed twee source; could not decode metadata (reason: %s).", filename, item.Line, err.Error())
|
||||
}
|
||||
|
||||
case twlex.ItemContent:
|
||||
// p.text = string(bytes.TrimSpace(item.Val))
|
||||
p.text = string(bytes.TrimRightFunc(item.Val, unicode.IsSpace))
|
||||
}
|
||||
|
||||
lastType = item.Type
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *story) loadHTML(filename, encoding string) error {
|
||||
source, err := fileReadAllWithEncoding(filename, encoding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
doc, err := getDocumentTree(bytes.TrimSpace(source))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Malformed HTML source; %s.", err.Error())
|
||||
}
|
||||
|
||||
if storyData := getElementByTag(doc, "tw-storydata"); storyData != nil {
|
||||
// Twine 2 style story data chunk.
|
||||
/*
|
||||
<tw-storydata name="…" startnode="…" creator="…" creator-version="…" ifid="…"
|
||||
zoom="…" format="…" format-version="…" options="…" hidden>…</tw-storydata>
|
||||
*/
|
||||
|
||||
var startnode int
|
||||
|
||||
// Content attribute processing.
|
||||
for _, a := range storyData.Attr {
|
||||
switch a.Key {
|
||||
case "name":
|
||||
s.name = a.Val
|
||||
case "startnode":
|
||||
if iVal, err := strconv.Atoi(a.Val); err == nil {
|
||||
startnode = iVal
|
||||
} else {
|
||||
log.Printf(`warning: Cannot parse "tw-storydata" content attribute "startnode" as an integer; value %q.`, a.Val)
|
||||
}
|
||||
// case "creator": Discard.
|
||||
// case "creator-version": Discard.
|
||||
case "ifid":
|
||||
s.ifid = strings.ToUpper(a.Val) // Force uppercase for consistency.
|
||||
case "zoom":
|
||||
if fVal, err := strconv.ParseFloat(a.Val, 64); err == nil {
|
||||
s.twine2.zoom = fVal
|
||||
} else {
|
||||
log.Printf(`warning: Cannot parse "tw-storydata" content attribute "zoom" as a float; value %q.`, a.Val)
|
||||
}
|
||||
case "format":
|
||||
s.twine2.format = a.Val
|
||||
case "format-version":
|
||||
s.twine2.formatVersion = a.Val
|
||||
case "options":
|
||||
// FIXME: I'm unsure whether the `options` content attribute is
|
||||
// intended to be a space delimited list. That does seem likely,
|
||||
// so we treat it as such for now.
|
||||
for _, opt := range strings.Fields(a.Val) {
|
||||
s.twine2.options[opt] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node processing.
|
||||
for node := storyData.FirstChild; node != nil; node = node.NextSibling {
|
||||
if node.Type != html.ElementNode {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
pid int
|
||||
name string
|
||||
tags []string
|
||||
content string
|
||||
metadata *passageMetadata
|
||||
)
|
||||
|
||||
switch node.Data {
|
||||
case "style", "script":
|
||||
/*
|
||||
<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">…</style>
|
||||
|
||||
<script role="script" id="twine-user-script" type="text/twine-javascript">…</script>
|
||||
*/
|
||||
if node.FirstChild == nil {
|
||||
// skip empty elements
|
||||
continue
|
||||
} else {
|
||||
nodeData := strings.TrimSpace(node.FirstChild.Data)
|
||||
if len(nodeData) == 0 {
|
||||
// NOTE: Skip elements that are empty after trimming; this additional
|
||||
// "empty" check is necessary because most (all?) versions of Twine 2
|
||||
// habitually append newlines to the nodes, so they're almost never
|
||||
// actually empty.
|
||||
continue
|
||||
}
|
||||
if node.Data == "style" {
|
||||
name = "Story Stylesheet"
|
||||
tags = []string{"stylesheet"}
|
||||
} else {
|
||||
name = "Story JavaScript"
|
||||
tags = []string{"script"}
|
||||
}
|
||||
content = nodeData
|
||||
}
|
||||
case "tw-tag":
|
||||
/*
|
||||
<tw-tag name="…" color="…"></tw-tag>
|
||||
*/
|
||||
{
|
||||
var (
|
||||
tagName string
|
||||
tagColor string
|
||||
)
|
||||
for _, a := range node.Attr {
|
||||
switch a.Key {
|
||||
case "name":
|
||||
tagName = a.Val
|
||||
case "color":
|
||||
tagColor = a.Val
|
||||
}
|
||||
}
|
||||
s.twine2.tagColors[tagName] = tagColor
|
||||
}
|
||||
continue
|
||||
case "tw-passagedata":
|
||||
/*
|
||||
<tw-passagedata pid="…" name="…" tags="…" position="…" size="…">…</tw-passagedata>
|
||||
*/
|
||||
metadata = &passageMetadata{}
|
||||
for _, a := range node.Attr {
|
||||
switch a.Key {
|
||||
case "pid":
|
||||
if iVal, err := strconv.Atoi(a.Val); err == nil {
|
||||
pid = iVal
|
||||
} else {
|
||||
log.Printf(`warning: Cannot parse "tw-passagedata" content attribute "pid" as an integer; value %q.`, a.Val)
|
||||
}
|
||||
case "name":
|
||||
name = a.Val
|
||||
case "tags":
|
||||
tags = strings.Fields(a.Val)
|
||||
case "position":
|
||||
metadata.position = a.Val
|
||||
case "size":
|
||||
metadata.size = a.Val
|
||||
}
|
||||
}
|
||||
if pid == startnode {
|
||||
s.twine2.start = name
|
||||
}
|
||||
if node.FirstChild != nil {
|
||||
content = node.FirstChild.Data
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
p := newPassage(name, tags, strings.TrimSpace(content))
|
||||
if metadata != nil {
|
||||
p.metadata = metadata
|
||||
}
|
||||
s.add(p)
|
||||
}
|
||||
|
||||
// Prepend the `StoryData` special passage. Includes the story IFID and Twine 2 metadata.
|
||||
s.prepend(newPassage("StoryData", []string{}, string(s.marshalStoryData())))
|
||||
} else if storyData := getElementByID(doc, "store(?:-a|A)rea"); storyData != nil {
|
||||
// Twine 1 style story data chunk.
|
||||
/*
|
||||
<div id="store-area" data-size="…" hidden>…</div>
|
||||
*/
|
||||
for node := storyData.FirstChild; node != nil; node = node.NextSibling {
|
||||
if node.Type != html.ElementNode || node.Data != "div" || !hasAttr(node, "tiddler") {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
name string
|
||||
tags []string
|
||||
content string
|
||||
metadata = &passageMetadata{}
|
||||
)
|
||||
|
||||
/*
|
||||
<div tiddler="…" tags="…" created="…" modified="…" modifier="…" twine-position="…">…</div>
|
||||
*/
|
||||
for _, a := range node.Attr {
|
||||
// NOTE: Ignore the following content attributes: `created`, `modified`, `modifier`.
|
||||
switch a.Key {
|
||||
case "tiddler":
|
||||
name = a.Val
|
||||
case "tags":
|
||||
tags = strings.Fields(a.Val)
|
||||
case "twine-position":
|
||||
metadata.position = a.Val
|
||||
}
|
||||
}
|
||||
if node.FirstChild != nil {
|
||||
content = tiddlerUnescapeString(node.FirstChild.Data)
|
||||
}
|
||||
|
||||
p := newPassage(name, tags, strings.TrimSpace(content))
|
||||
if metadata != nil {
|
||||
p.metadata = metadata
|
||||
}
|
||||
s.add(p)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("Malformed HTML source; story data not found.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *story) loadTagged(tag, filename, encoding string) error {
|
||||
source, err := fileReadAllWithEncoding(filename, encoding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.add(newPassage(
|
||||
filepath.Base(filename),
|
||||
[]string{tag},
|
||||
string(source),
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *story) loadMedia(tag, filename string) error {
|
||||
source, err := fileReadAllAsBase64(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.add(newPassage(
|
||||
strings.Split(filepath.Base(filename), ".")[0],
|
||||
[]string{tag},
|
||||
"data:"+mediaTypeFromFilename(filename)+";base64,"+string(source),
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *story) loadFont(filename string) error {
|
||||
source, err := fileReadAllAsBase64(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
name = filepath.Base(filename)
|
||||
family = strings.Split(name, ".")[0]
|
||||
ext = normalizedFileExt(filename)
|
||||
mediaType = mediaTypeFromExt(ext)
|
||||
hint string
|
||||
)
|
||||
switch ext {
|
||||
case "ttf":
|
||||
hint = "truetype"
|
||||
case "otf":
|
||||
hint = "opentype"
|
||||
default:
|
||||
hint = ext
|
||||
}
|
||||
|
||||
s.add(newPassage(
|
||||
name,
|
||||
[]string{"stylesheet"},
|
||||
fmt.Sprintf(
|
||||
"@font-face {\n\tfont-family: %q;\n\tsrc: url(\"data:%s;base64,%s\") format(%q);\n}",
|
||||
family,
|
||||
mediaType,
|
||||
source,
|
||||
hint,
|
||||
),
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
360
storyout.go
Normal file
360
storyout.go
Normal file
|
@ -0,0 +1,360 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *story) toTwee(outMode outputMode) []byte {
|
||||
var data []byte
|
||||
for _, p := range s.passages {
|
||||
data = append(data, p.toTwee(outMode)...)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (s *story) toTwine2Archive(startName string) []byte {
|
||||
return append(s.getTwine2DataChunk(startName), '\n')
|
||||
}
|
||||
|
||||
func (s *story) toTwine1Archive(startName string) []byte {
|
||||
var (
|
||||
count uint
|
||||
data []byte
|
||||
template []byte
|
||||
)
|
||||
|
||||
data, count = s.getTwine1PassageChunk()
|
||||
// NOTE: In Twine 1.4, the passage data wrapper is part of the story formats
|
||||
// themselves, so we have to create/forge one here. We use the Twine 1.4 vanilla
|
||||
// `storeArea` ID, rather than SugarCube's preferred `store-area` ID, for maximum
|
||||
// compatibility and interoperability.
|
||||
template = append(template, fmt.Sprintf(`<div id="storeArea" data-size="%d" hidden>`, count)...)
|
||||
template = append(template, data...)
|
||||
template = append(template, "</div>\n"...)
|
||||
return template
|
||||
}
|
||||
|
||||
func (s *story) toTwine2HTML(startName string) []byte {
|
||||
var template = s.format.source()
|
||||
|
||||
// Story instance replacements.
|
||||
if bytes.Contains(template, []byte("{{STORY_NAME}}")) {
|
||||
template = bytes.Replace(template, []byte("{{STORY_NAME}}"), []byte(htmlEscapeString(s.name)), -1)
|
||||
}
|
||||
if bytes.Contains(template, []byte("{{STORY_DATA}}")) {
|
||||
template = bytes.Replace(template, []byte("{{STORY_DATA}}"), s.getTwine2DataChunk(startName), 1)
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
func (s *story) toTwine1HTML(startName string) []byte {
|
||||
var (
|
||||
formatDir = filepath.Dir(s.format.filename)
|
||||
parentDir = filepath.Dir(formatDir)
|
||||
template = s.format.source()
|
||||
count uint
|
||||
data []byte
|
||||
component []byte
|
||||
err error
|
||||
)
|
||||
|
||||
// Get the story data.
|
||||
data, count = s.getTwine1PassageChunk()
|
||||
|
||||
// Story format compiler byline replacement.
|
||||
if search := []byte(`<a href="http://twinery.org/"`); bytes.Contains(template, search) {
|
||||
template = bytes.Replace(template, search, []byte(`<a href="http://www.motoslave.net/tweego/"`), 1)
|
||||
template = bytes.Replace(template, []byte(`>Twine</a>`), []byte(`>Tweego</a>`), 1)
|
||||
}
|
||||
|
||||
// Story format component replacements (SugarCube).
|
||||
if search := []byte(`"USER_LIB"`); bytes.Contains(template, search) {
|
||||
component, err = fileReadAllAsUTF8(filepath.Join(formatDir, "userlib.js"))
|
||||
if err == nil {
|
||||
template = bytes.Replace(template, search, component, 1)
|
||||
} else if !os.IsNotExist(err) {
|
||||
log.Fatalf("error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Story format component replacements (Twine 1.4+ vanilla story formats).
|
||||
if search := []byte(`"ENGINE"`); bytes.Contains(template, search) {
|
||||
component, err = fileReadAllAsUTF8(filepath.Join(parentDir, "engine.js"))
|
||||
if err != nil {
|
||||
log.Fatalf("error: %s", err.Error())
|
||||
}
|
||||
template = bytes.Replace(template, search, component, 1)
|
||||
}
|
||||
for _, pattern := range []string{`"SUGARCANE"`, `"JONAH"`} {
|
||||
if search := []byte(pattern); bytes.Contains(template, search) {
|
||||
component, err = fileReadAllAsUTF8(filepath.Join(formatDir, "code.js"))
|
||||
if err != nil {
|
||||
log.Fatalf("error: %s", err.Error())
|
||||
}
|
||||
template = bytes.Replace(template, search, component, 1)
|
||||
}
|
||||
}
|
||||
if s.twine1.settings["jquery"] == "on" {
|
||||
if search := []byte(`"JQUERY"`); bytes.Contains(template, search) {
|
||||
component, err = fileReadAllAsUTF8(filepath.Join(parentDir, "jquery.js"))
|
||||
if err != nil {
|
||||
log.Fatalf("error: %s", err.Error())
|
||||
}
|
||||
template = bytes.Replace(template, search, component, 1)
|
||||
}
|
||||
}
|
||||
if s.twine1.settings["modernizr"] == "on" {
|
||||
if search := []byte(`"MODERNIZR"`); bytes.Contains(template, search) {
|
||||
component, err = fileReadAllAsUTF8(filepath.Join(parentDir, "modernizr.js"))
|
||||
if err != nil {
|
||||
log.Fatalf("error: %s", err.Error())
|
||||
}
|
||||
template = bytes.Replace(template, search, component, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Story instance replacements.
|
||||
if startName == defaultStartName {
|
||||
startName = ""
|
||||
}
|
||||
template = bytes.Replace(template, []byte(`"VERSION"`),
|
||||
[]byte(fmt.Sprintf("Compiled with %s, %s", tweegoName, tweegoVersion.Version())), 1)
|
||||
template = bytes.Replace(template, []byte(`"TIME"`),
|
||||
[]byte(fmt.Sprintf("Built on %s", time.Now().Format(time.RFC1123Z))), 1)
|
||||
template = bytes.Replace(template, []byte(`"START_AT"`),
|
||||
[]byte(fmt.Sprintf(`%q`, startName)), 1)
|
||||
template = bytes.Replace(template, []byte(`"STORY_SIZE"`),
|
||||
[]byte(fmt.Sprintf(`"%d"`, count)), 1)
|
||||
if bytes.Contains(template, []byte(`"STORY"`)) {
|
||||
// Twine/Twee ≥1.4 style story format.
|
||||
template = bytes.Replace(template, []byte(`"STORY"`), data, 1)
|
||||
} else {
|
||||
// Twine/Twee <1.4 style story format.
|
||||
var footer []byte
|
||||
footer, err = fileReadAllAsUTF8(filepath.Join(formatDir, "footer.html"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
footer = []byte("</div>\n</body>\n</html>\n")
|
||||
} else {
|
||||
log.Fatalf("error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
template = append(template, data...)
|
||||
template = append(template, footer...)
|
||||
}
|
||||
|
||||
// IFID replacement.
|
||||
if s.ifid != "" {
|
||||
if bytes.Contains(template, []byte(`<div id="store-area"`)) {
|
||||
// SugarCube
|
||||
template = bytes.Replace(template, []byte(`<div id="store-area"`),
|
||||
[]byte(fmt.Sprintf(`<!-- UUID://%s// --><div id="store-area"`, s.ifid)), 1)
|
||||
} else {
|
||||
// Twine/Twee vanilla story formats.
|
||||
template = bytes.Replace(template, []byte(`<div id="storeArea"`),
|
||||
[]byte(fmt.Sprintf(`<!-- UUID://%s// --><div id="storeArea"`, s.ifid)), 1)
|
||||
}
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
func (s *story) getTwine2DataChunk(startName string) []byte {
|
||||
var (
|
||||
data []byte
|
||||
startID string
|
||||
options string
|
||||
pid uint
|
||||
)
|
||||
|
||||
// Check the IFID status.
|
||||
if s.ifid == "" {
|
||||
var (
|
||||
ifid string
|
||||
err error
|
||||
)
|
||||
if s.legacyIFID != "" {
|
||||
/*
|
||||
LEGACY
|
||||
*/
|
||||
log.Print(`error: Story IFID not found; reusing "ifid" entry from the "StorySettings" special passage.`)
|
||||
log.Println()
|
||||
ifid = s.legacyIFID
|
||||
/*
|
||||
END LEGACY
|
||||
*/
|
||||
} else {
|
||||
log.Print("error: Story IFID not found; generating one for your project.")
|
||||
log.Println()
|
||||
ifid, err = newIFID()
|
||||
if err != nil {
|
||||
log.Fatalf("error: IFID generation failed; %s", err.Error())
|
||||
}
|
||||
}
|
||||
ifid = fmt.Sprintf(`"ifid": %q`, ifid)
|
||||
base := "Copy the following "
|
||||
if s.has("StoryData") {
|
||||
ifid += ","
|
||||
log.Printf("%sline into the \"StoryData\" special passage's JSON block (at the top):\n\n\t%s\n\n", base, ifid)
|
||||
log.Printf("E.g., it should look something like the following:\n\n:: StoryData\n%s\n\n",
|
||||
bytes.Replace(s.marshalStoryData(), []byte("{"), []byte("{\n\t"+ifid), 1))
|
||||
} else {
|
||||
log.Printf("%s\"StoryData\" special passage into one of your project's twee source files:\n\n:: StoryData\n{\n\t%s\n}", base, ifid)
|
||||
}
|
||||
log.Fatalln()
|
||||
}
|
||||
|
||||
// Gather all script and stylesheet passages.
|
||||
var (
|
||||
scripts = make([]*passage, 0, 4)
|
||||
stylesheets = make([]*passage, 0, 4)
|
||||
)
|
||||
for _, p := range s.passages {
|
||||
if p.tagsHas("Twine.private") {
|
||||
continue
|
||||
}
|
||||
if p.tagsHas("script") {
|
||||
scripts = append(scripts, p)
|
||||
} else if p.tagsHas("stylesheet") {
|
||||
stylesheets = append(stylesheets, p)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the style element.
|
||||
/*
|
||||
<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">…</style>
|
||||
*/
|
||||
data = append(data, `<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">`...)
|
||||
if len(stylesheets) == 1 {
|
||||
data = append(data, stylesheets[0].text...)
|
||||
} else if len(stylesheets) > 1 {
|
||||
pid = 1
|
||||
for _, p := range stylesheets {
|
||||
if pid > 1 && data[len(data)-1] != '\n' {
|
||||
data = append(data, '\n')
|
||||
}
|
||||
data = append(data, fmt.Sprintf("/* twine-user-stylesheet #%d: %q */\n", pid, p.name)...)
|
||||
data = append(data, p.text...)
|
||||
pid++
|
||||
}
|
||||
}
|
||||
data = append(data, `</style>`...)
|
||||
|
||||
// Prepare the script element.
|
||||
/*
|
||||
<script role="script" id="twine-user-script" type="text/twine-javascript">…</script>
|
||||
*/
|
||||
data = append(data, `<script role="script" id="twine-user-script" type="text/twine-javascript">`...)
|
||||
if len(scripts) == 1 {
|
||||
data = append(data, scripts[0].text...)
|
||||
} else if len(scripts) > 1 {
|
||||
pid = 1
|
||||
for _, p := range scripts {
|
||||
if pid > 1 && data[len(data)-1] != '\n' {
|
||||
data = append(data, '\n')
|
||||
}
|
||||
data = append(data, fmt.Sprintf("/* twine-user-script #%d: %q */\n", pid, p.name)...)
|
||||
data = append(data, p.text...)
|
||||
pid++
|
||||
}
|
||||
}
|
||||
data = append(data, `</script>`...)
|
||||
|
||||
// Prepare tw-tag elements.
|
||||
/*
|
||||
<tw-tag name="…" color="…"></tw-tag>
|
||||
*/
|
||||
if s.twine2.tagColors != nil {
|
||||
for tag, color := range s.twine2.tagColors {
|
||||
data = append(data, fmt.Sprintf(`<tw-tag name=%q color=%q></tw-tag>`, tag, color)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare normal passage elements.
|
||||
pid = 1
|
||||
for _, p := range s.passages {
|
||||
if p.name == "StoryTitle" || p.name == "StoryData" || p.tagsHasAny("script", "stylesheet", "Twine.private") {
|
||||
continue
|
||||
}
|
||||
|
||||
/*
|
||||
LEGACY
|
||||
*/
|
||||
// TODO: Should we actually drop an empty StorySettings passage?
|
||||
if p.name == "StorySettings" && len(s.twine1.settings) == 0 {
|
||||
continue
|
||||
}
|
||||
/*
|
||||
END LEGACY
|
||||
*/
|
||||
|
||||
data = append(data, p.toPassagedata(pid)...)
|
||||
if startName == p.name {
|
||||
startID = fmt.Sprint(pid)
|
||||
}
|
||||
pid++
|
||||
}
|
||||
|
||||
// Add the <tw-storydata> wrapper.
|
||||
/*
|
||||
<tw-storydata name="…" startnode="…" creator="…" creator-version="…" ifid="…"
|
||||
zoom="…" format="…" format-version="…" options="…" hidden>…</tw-storydata>
|
||||
*/
|
||||
if optCount := len(s.twine2.options); optCount > 0 {
|
||||
opts := make([]string, 0, optCount)
|
||||
for opt, val := range s.twine2.options {
|
||||
if val {
|
||||
opts = append(opts, opt)
|
||||
}
|
||||
}
|
||||
options = strings.Join(opts, " ")
|
||||
}
|
||||
data = append([]byte(fmt.Sprintf(
|
||||
`<!-- UUID://%s// -->`+
|
||||
`<tw-storydata name=%q startnode=%q creator=%q creator-version=%q ifid=%q zoom=%q format=%q format-version=%q options=%q hidden>`,
|
||||
s.ifid,
|
||||
attrEscapeString(s.name),
|
||||
startID,
|
||||
attrEscapeString(strings.Title(tweegoName)),
|
||||
attrEscapeString(tweegoVersion.Version()),
|
||||
attrEscapeString(s.ifid),
|
||||
attrEscapeString(strconv.FormatFloat(s.twine2.zoom, 'f', -1, 32)),
|
||||
attrEscapeString(s.format.name),
|
||||
attrEscapeString(s.format.version),
|
||||
attrEscapeString(options),
|
||||
)), data...)
|
||||
data = append(data, `</tw-storydata>`...)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (s *story) getTwine1PassageChunk() ([]byte, uint) {
|
||||
var (
|
||||
data []byte
|
||||
count uint
|
||||
)
|
||||
|
||||
for _, p := range s.passages {
|
||||
if p.tagsHas("Twine.private") {
|
||||
continue
|
||||
}
|
||||
|
||||
count++
|
||||
data = append(data, p.toTiddler(count)...)
|
||||
}
|
||||
return data, count
|
||||
}
|
120
tweego.go
Normal file
120
tweego.go
Normal file
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
tweego (a twee compiler in Go)
|
||||
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
const tweegoName = "tweego"
|
||||
|
||||
func init() {
|
||||
// Clear standard logger flags.
|
||||
log.SetFlags(0)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Create a new config instance.
|
||||
c := newConfig()
|
||||
|
||||
// Build the output and, possibly, log various stats.
|
||||
if c.watchFiles {
|
||||
buildName := relPath(c.outFile)
|
||||
watchFilesystem(c.sourcePaths, c.outFile, func() {
|
||||
log.Printf("BUILDING: %s", buildName)
|
||||
buildOutput(c)
|
||||
})
|
||||
} else {
|
||||
buildOutput(c)
|
||||
|
||||
// Logging.
|
||||
if c.logFiles {
|
||||
log.Println()
|
||||
statsLogFiles()
|
||||
log.Println()
|
||||
}
|
||||
if c.logStats {
|
||||
if !c.logFiles {
|
||||
log.Println()
|
||||
}
|
||||
statsLog()
|
||||
log.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildOutput(c *config) *story {
|
||||
// Get the source and module paths.
|
||||
sourcePaths := getFilenames(c.sourcePaths, c.outFile)
|
||||
modulePaths := getFilenames(c.modulePaths, c.outFile)
|
||||
|
||||
// Create a new story instance and load the source files.
|
||||
s := newStory()
|
||||
s.load(sourcePaths, c)
|
||||
|
||||
// Finalize the config with values from the `StoryData` passage, if any.
|
||||
c.mergeStoryConfig(s)
|
||||
|
||||
// Write the output.
|
||||
switch c.outMode {
|
||||
case outModeTwee3, outModeTwee1:
|
||||
// Write out the project as Twee source.
|
||||
if _, err := fileWriteAll(c.outFile, alignRecordSeparators(s.toTwee(c.outMode))); err != nil {
|
||||
log.Fatalf(`error: %s`, err.Error())
|
||||
}
|
||||
case outModeTwine2Archive:
|
||||
// Write out the project as Twine 2 archived HTML.
|
||||
if _, err := fileWriteAll(c.outFile, s.toTwine2Archive(c.startName)); err != nil {
|
||||
log.Fatalf(`error: %s`, err.Error())
|
||||
}
|
||||
case outModeTwine1Archive:
|
||||
// Write out the project as Twine 1 archived HTML.
|
||||
if _, err := fileWriteAll(c.outFile, s.toTwine1Archive(c.startName)); err != nil {
|
||||
log.Fatalf(`error: %s`, err.Error())
|
||||
}
|
||||
default:
|
||||
// Basic sanity checks.
|
||||
if !s.has(c.startName) {
|
||||
log.Fatalf("error: Starting passage %q not found.", c.startName)
|
||||
}
|
||||
if (s.format.isTwine1Style() || s.name == "") && !s.has("StoryTitle") {
|
||||
log.Fatal(`error: Special passage "StoryTitle" not found.`)
|
||||
}
|
||||
|
||||
if s.format.isTwine2Style() {
|
||||
// Write out the project as Twine 2 compiled HTML.
|
||||
if _, err := fileWriteAll(
|
||||
c.outFile,
|
||||
modifyHead(
|
||||
s.toTwine2HTML(c.startName),
|
||||
modulePaths,
|
||||
c.headFile,
|
||||
c.encoding,
|
||||
),
|
||||
); err != nil {
|
||||
log.Fatalf(`error: %s`, err.Error())
|
||||
}
|
||||
} else {
|
||||
// Write out the project as Twine 1 compiled HTML.
|
||||
if _, err := fileWriteAll(
|
||||
c.outFile,
|
||||
modifyHead(
|
||||
s.toTwine1HTML(c.startName),
|
||||
modulePaths,
|
||||
c.headFile,
|
||||
c.encoding,
|
||||
),
|
||||
); err != nil {
|
||||
log.Fatalf(`error: %s`, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
126
usage.go
Normal file
126
usage.go
Normal file
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
// standard packages
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
// external packages
|
||||
"github.com/paulrosania/go-charset/charset"
|
||||
)
|
||||
|
||||
// print basic help
|
||||
func usage() {
|
||||
outFile := defaultOutFile
|
||||
if outFile == "-" {
|
||||
outFile = "<stdout>"
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
Usage: %s [options] sources...
|
||||
|
||||
sources Input sources (repeatable); may consist of supported
|
||||
files and/or directories to recursively search for
|
||||
such files.
|
||||
|
||||
Options:
|
||||
-a, --archive-twine2 Output Twine 2 archive, instead of compiled HTML.
|
||||
--archive-twine1 Output Twine 1 archive, instead of compiled HTML.
|
||||
-c SET, --charset=SET Name of the input character set (default: "utf-8",
|
||||
fallback: %q).
|
||||
-d, --decompile-twee3 Output Twee 3 source code, instead of compiled HTML.
|
||||
--decompile-twee1 Output Twee 1 source code, instead of compiled HTML.
|
||||
-f NAME, --format=NAME ID of the story format (default: %q).
|
||||
-h, --help Print this help, then exit.
|
||||
--head=FILE Name of the file whose contents will be appended
|
||||
as-is to the <head> element of the compiled HTML.
|
||||
--list-charsets List the supported input character sets, then exit.
|
||||
--list-formats List the available story formats, then exit.
|
||||
--log-files Log the processed input files.
|
||||
-l, --log-stats Log various story statistics.
|
||||
-m SRC, --module=SRC Module sources (repeatable); may consist of supported
|
||||
files and/or directories to recursively search for
|
||||
such files.
|
||||
-o FILE, --output=FILE Name of the output file (default: %q).
|
||||
-s NAME, --start=NAME Name of the starting passage (default: the passage
|
||||
set by the story data, elsewise %q).
|
||||
-t, --test Compile in test mode; only for story formats in the
|
||||
Twine 2 style.
|
||||
--twee2-compat Enable Twee2 source compatibility mode; files with
|
||||
the .tw2 or .twee2 extensions automatically have
|
||||
compatibility mode enabled.
|
||||
-v, --version Print version information, then exit.
|
||||
-w, --watch Start watch mode; watch input sources for changes,
|
||||
rebuilding the output as necessary.
|
||||
|
||||
`, tweegoName, fallbackCharset, defaultFormatID, outFile, defaultStartName)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// formats the list of supported character sets/encodings somewhat nicely for the user
|
||||
func usageCharsets() {
|
||||
charsets := charset.Names()
|
||||
sort.Strings(charsets)
|
||||
|
||||
cols := 4
|
||||
rows := int(math.Ceil(float64(len(charsets)) / float64(cols)))
|
||||
|
||||
fmt.Fprintln(os.Stderr, "\nSupported input charsets:")
|
||||
for i, cnt := 0, len(charsets); i < rows; i++ {
|
||||
fmt.Fprintf(os.Stderr, " %-18s", charsets[i])
|
||||
offset := rows
|
||||
for j := 0; j < cols; j++ {
|
||||
if i+offset < cnt {
|
||||
fmt.Fprintf(os.Stderr, " %-18s", charsets[i+offset])
|
||||
offset += rows
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// formats the list of supported story formats somewhat nicely for the user
|
||||
func usageFormats(formats storyFormatsMap) {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if formats.isEmpty() {
|
||||
fmt.Fprintln(os.Stderr, "Story formats not found.")
|
||||
} else {
|
||||
ids := formats.ids()
|
||||
sort.Sort(StringsInsensitively(ids))
|
||||
fmt.Fprintln(os.Stderr, "Available formats:")
|
||||
fmt.Fprintln(os.Stderr, " ID Name (Version) [Details]")
|
||||
fmt.Fprintln(os.Stderr, " -------------------- ------------------------------")
|
||||
for _, id := range ids {
|
||||
f := formats[id]
|
||||
fmt.Fprintf(os.Stderr, " %-20s", f.id)
|
||||
if f.isTwine2Style() {
|
||||
fmt.Fprintf(os.Stderr, " %s (%s)", f.name, f.version)
|
||||
if f.proofing {
|
||||
fmt.Fprint(os.Stderr, " [proofing]")
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func usageVersion() {
|
||||
fmt.Fprintf(os.Stderr, "\n%s, %s\n", tweegoName, tweegoVersion)
|
||||
fmt.Fprint(os.Stderr, `
|
||||
Tweego (a Twee compiler in Go) [http://www.motoslave.net/tweego/]
|
||||
Copyright (c) 2014-2019 Thomas Michael Edwards. All rights reserved.
|
||||
|
||||
`)
|
||||
os.Exit(1)
|
||||
}
|
43
user.go
Normal file
43
user.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func userHomeDir() (string, error) {
|
||||
// Prefer the user's `HOME` environment variable.
|
||||
if homeDir := os.Getenv("HOME"); homeDir != "" {
|
||||
return homeDir, nil
|
||||
}
|
||||
|
||||
// Elsewise, use the user's `.HomeDir` info.
|
||||
if curUser, err := user.Current(); err == nil && curUser.HomeDir != "" {
|
||||
return curUser.HomeDir, nil
|
||||
}
|
||||
|
||||
// Failovers for Windows, though they should be unnecessary in Go ≥v1.7.
|
||||
if runtime.GOOS == "windows" {
|
||||
// Prefer the user's `USERPROFILE` environment variable.
|
||||
if homeDir := os.Getenv("USERPROFILE"); homeDir != "" {
|
||||
return homeDir, nil
|
||||
}
|
||||
|
||||
// Elsewise, use the user's `HOMEDRIVE` and `HOMEPATH` environment variables.
|
||||
homeDrive := os.Getenv("HOMEDRIVE")
|
||||
homePath := os.Getenv("HOMEPATH")
|
||||
if homeDrive != "" && homePath != "" {
|
||||
return homeDrive + homePath, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("Cannot find user home directory.")
|
||||
}
|
117
util.go
Normal file
117
util.go
Normal file
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mediaTypeFromFilename(filename string) string {
|
||||
return mediaTypeFromExt(normalizedFileExt(filename))
|
||||
}
|
||||
|
||||
func mediaTypeFromExt(ext string) string {
|
||||
var mediaType string
|
||||
switch ext {
|
||||
// AUDIO NOTES:
|
||||
//
|
||||
// The preferred media type for WAVE audio is `audio/wave`, however,
|
||||
// some browsers only recognize `audio/wav`, requiring its use instead.
|
||||
case "aac", "flac", "ogg", "wav":
|
||||
mediaType = "audio/" + ext
|
||||
case "mp3":
|
||||
mediaType = "audio/mpeg"
|
||||
case "m4a":
|
||||
mediaType = "audio/mp4"
|
||||
case "oga", "opus":
|
||||
mediaType = "audio/ogg"
|
||||
case "wave":
|
||||
mediaType = "audio/wav"
|
||||
case "weba":
|
||||
mediaType = "audio/webm"
|
||||
|
||||
// FONT NOTES:
|
||||
//
|
||||
// (ca. 2017) The IANA deprecated the various font subtypes of the
|
||||
// "application" type in favor of the new "font" type. While the
|
||||
// standards were new at that point, many browsers had long accepted
|
||||
// such media types due to existing use in the wild—erroneous at
|
||||
// that point or not.
|
||||
// otf : application/font-sfnt → font/otf
|
||||
// ttf : application/font-sfnt → font/ttf
|
||||
// woff : application/font-woff → font/woff
|
||||
// woff2 : application/font-woff2 → font/woff2
|
||||
case "otf", "ttf", "woff", "woff2":
|
||||
mediaType = "font/" + ext
|
||||
|
||||
// IMAGE NOTES:
|
||||
case "gif", "jpeg", "png", "tiff", "webp":
|
||||
mediaType = "image/" + ext
|
||||
case "jpg":
|
||||
mediaType = "image/jpeg"
|
||||
case "svg":
|
||||
mediaType = "image/svg+xml"
|
||||
case "tif":
|
||||
mediaType = "image/tiff"
|
||||
|
||||
// METADATA NOTES:
|
||||
//
|
||||
// Name aside, WebVTT files are generic media cue metadata files
|
||||
// that may be used with either `<audio>` or `<video>` elements.
|
||||
case "vtt": // WebVTT (Web Video Text Tracks)
|
||||
mediaType = "text/vtt"
|
||||
|
||||
// VIDEO NOTES:
|
||||
case "mp4", "webm":
|
||||
mediaType = "video/" + ext
|
||||
case "ogv":
|
||||
mediaType = "video/ogg"
|
||||
}
|
||||
|
||||
return mediaType
|
||||
}
|
||||
|
||||
func normalizedFileExt(filename string) string {
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" {
|
||||
return ext
|
||||
}
|
||||
return strings.ToLower(ext[1:])
|
||||
}
|
||||
|
||||
func slugify(original string) string {
|
||||
// TODO: Maybe expand this to include non-ASCII alphas?
|
||||
invalidRe := regexp.MustCompile(`[^[:word:]-]`)
|
||||
return strings.ToLower(invalidRe.ReplaceAllLiteralString(original, "-"))
|
||||
}
|
||||
|
||||
func stringSliceContains(haystack []string, needle string) bool {
|
||||
if len(haystack) > 0 {
|
||||
for _, val := range haystack {
|
||||
if val == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringSliceDelete(haystack []string, needle string) []string {
|
||||
if len(haystack) > 0 {
|
||||
for i, val := range haystack {
|
||||
if val == needle {
|
||||
copy(haystack[i:], haystack[i+1:])
|
||||
haystack[len(haystack)-1] = ""
|
||||
haystack = haystack[:len(haystack)-1]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return haystack
|
||||
}
|
73
version.go
Normal file
73
version.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
Copyright © 2014–2019 Thomas Michael Edwards. All rights reserved.
|
||||
Use of this source code is governed by a Simplified BSD License which
|
||||
can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// versionInfo contains build version information.
|
||||
type versionInfo struct {
|
||||
major uint64
|
||||
minor uint64
|
||||
patch uint64
|
||||
pre string
|
||||
}
|
||||
|
||||
var (
|
||||
// tweegoVersion holds the current version info.
|
||||
tweegoVersion = versionInfo{
|
||||
major: 2,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
pre: "",
|
||||
}
|
||||
// tweegoVersion holds the build ID.
|
||||
tweegoBuild = ""
|
||||
// tweegoVersion holds the build date.
|
||||
tweegoDate = ""
|
||||
)
|
||||
|
||||
// String returns the full version string (version, date, and platform).
|
||||
func (v versionInfo) String() string {
|
||||
date := tweegoDate
|
||||
if date != "" {
|
||||
date = " (" + date + ")"
|
||||
}
|
||||
return fmt.Sprintf("version %s%s [%s]", v.Version(), date, v.Platform())
|
||||
}
|
||||
|
||||
// Version returns the SemVer version string.
|
||||
func (v versionInfo) Version() string {
|
||||
pre := v.pre
|
||||
if pre != "" {
|
||||
pre = "-" + pre
|
||||
}
|
||||
build := tweegoBuild
|
||||
if build != "" {
|
||||
build = "+" + build
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%d.%d.%d%s%s",
|
||||
v.major,
|
||||
v.minor,
|
||||
v.patch,
|
||||
pre,
|
||||
build,
|
||||
)
|
||||
}
|
||||
|
||||
// Date returns the build date string.
|
||||
func (v versionInfo) Date() string {
|
||||
return tweegoDate
|
||||
}
|
||||
|
||||
// Platform returns the OS/Arch pair.
|
||||
func (v versionInfo) Platform() string {
|
||||
return runtime.GOOS + "/" + runtime.GOARCH
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue