story-format-parser 1.0.2 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +177 -110
- package/about.md +55 -0
- package/creators-note.md +31 -0
- package/open-storyfiles.py +181 -179
- package/package.json +29 -17
- package/parseStory.js +147 -146
- package/sample.story +2 -2
- package/story-logo.png +0 -0
- package/.vscode/launch.json +0 -14
- package/.vscode/settings.json +0 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keane3029-lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,110 +1,177 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="story-logo.png" alt=".STORY Format Logo" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# .STORY Format Parser & Serializer
|
|
6
|
+
|
|
7
|
+
A lightweight JavaScript parser and serializer for the custom `.STORY` plain text file format, plus a Python port. Zero dependencies, works in **Node.js** and the **browser**.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Format Overview
|
|
12
|
+
|
|
13
|
+
A `.STORY` document is a list of days. Each day has optional headers, then a body of dialogue and narration.
|
|
14
|
+
|
|
15
|
+
```story
|
|
16
|
+
day: 1
|
|
17
|
+
title: The Awakening
|
|
18
|
+
weather: Rain
|
|
19
|
+
---
|
|
20
|
+
# First scene
|
|
21
|
+
ALICE: Hello world. Time is 10:30 AM
|
|
22
|
+
* Thunder rumbles in the distance.
|
|
23
|
+
===
|
|
24
|
+
day: 2
|
|
25
|
+
title: Shadows
|
|
26
|
+
mood: Tense
|
|
27
|
+
---
|
|
28
|
+
bob: What was that noise
|
|
29
|
+
* Steps echo down the hall.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **Zero Dependencies:** vanilla JavaScript.
|
|
37
|
+
- **Universal Export:** works with `require` in Node and a `<script>` tag in the browser.
|
|
38
|
+
- **Non-Throwing Parser:** bad lines produce `warnings` with line numbers instead of exceptions.
|
|
39
|
+
- **Full Serialization:** turns the parsed objects back into `.STORY` text.
|
|
40
|
+
- **Python Port:** `open-storyfiles.py` does the same parse and serialize in Python and can load files from disk.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Syntax Rules
|
|
45
|
+
|
|
46
|
+
1. **Day Separator (`===`):** on its own line, splits the file into days.
|
|
47
|
+
2. **Header-Body Separator (`---`):** on its own line, ends the header and starts the body of a day.
|
|
48
|
+
3. **Headers (`key: value`):** keys are lowercased. The reserved keys `day`, `title`, `scene` and `mood` map onto the day object. Any other key (like `weather` in the example) is stored in `extra`.
|
|
49
|
+
4. **Day Number:** `day` becomes a number when it parses as one, otherwise it stays a string.
|
|
50
|
+
5. **Dialogue (`speaker: text`):** speaker names are lowercased, so `ALICE:` becomes `alice`.
|
|
51
|
+
6. **Narration (`* text`):** lines starting with `*` become narration entries.
|
|
52
|
+
7. **Comments (`# comment`):** lines starting with `#` are ignored, and so are blank lines.
|
|
53
|
+
8. **Bad Lines:** a header line with no colon is skipped with a warning. A body line with no colon is kept as narration and also gives a warning.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Output Shape
|
|
58
|
+
|
|
59
|
+
`parseStory(text)` returns `{ days, warnings }`.
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
{
|
|
63
|
+
days: [
|
|
64
|
+
{
|
|
65
|
+
day: 1, // number, or string if not numeric, or null
|
|
66
|
+
title: "The Awakening", // string or null
|
|
67
|
+
scene: null, // string or null
|
|
68
|
+
mood: null, // string or null
|
|
69
|
+
extra: { weather: "Rain" },
|
|
70
|
+
entries: [
|
|
71
|
+
{ type: "dialogue", speaker: "alice", text: "Hello world. Time is 10:30 AM" },
|
|
72
|
+
{ type: "narration", speaker: null, text: "Thunder rumbles in the distance." }
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
warnings: [
|
|
77
|
+
{ line: 3, message: "Invalid header syntax: missing colon in line \"...\"" }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`warnings[].line` is the line number inside that day's block, counting from 1.
|
|
83
|
+
|
|
84
|
+
`serializeStory(days)` takes the `days` array and returns `.STORY` text. Parsing then serializing keeps the data, but comments and blank lines are dropped, and speakers come out lowercase.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Installation & Setup
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/keane3029-lab/.story-format.git
|
|
92
|
+
cd .story-format
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Usage
|
|
98
|
+
|
|
99
|
+
### Node.js
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
const { parseStory, serializeStory } = require('./parseStory.js');
|
|
103
|
+
|
|
104
|
+
const storyText = `
|
|
105
|
+
day: 1
|
|
106
|
+
title: Prologue
|
|
107
|
+
---
|
|
108
|
+
* The adventure begins.
|
|
109
|
+
ALICE: Are you ready
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const { days, warnings } = parseStory(storyText);
|
|
113
|
+
console.log(days);
|
|
114
|
+
|
|
115
|
+
const rawText = serializeStory(days);
|
|
116
|
+
console.log(rawText);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Browser
|
|
120
|
+
|
|
121
|
+
```html
|
|
122
|
+
<script src="parseStory.js"></script>
|
|
123
|
+
<script>
|
|
124
|
+
const input = "day: 1\n---\nalice: Ready when you are.";
|
|
125
|
+
const { days, warnings } = parseStory(input);
|
|
126
|
+
console.log(days);
|
|
127
|
+
</script>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Python
|
|
131
|
+
|
|
132
|
+
`open-storyfiles.py` has `parse_story`, `load_story_file` and `serialize_story`, with the same output shape as the JS version.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
import importlib.util
|
|
136
|
+
|
|
137
|
+
spec = importlib.util.spec_from_file_location("story", "open-storyfiles.py")
|
|
138
|
+
story = importlib.util.module_from_spec(spec)
|
|
139
|
+
spec.loader.exec_module(story)
|
|
140
|
+
|
|
141
|
+
result = story.load_story_file("sample.story") # raises FileNotFoundError if missing
|
|
142
|
+
print(result["days"])
|
|
143
|
+
print(result["warnings"])
|
|
144
|
+
|
|
145
|
+
print(story.serialize_story(result["days"]))
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Run it directly to parse `sample.story` and print the JSON:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
python open-storyfiles.py
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If `sample.story` is missing, it runs a built-in inline test instead.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Running Tests
|
|
159
|
+
|
|
160
|
+
Run the built-in self-test in Node.js:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npm test
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
[MIT](LICENSE)
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## About
|
|
175
|
+
|
|
176
|
+
- [About .STORY](about.md)
|
|
177
|
+
- [Creator's Note](creators-note.md)
|
package/about.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# About .STORY
|
|
2
|
+
|
|
3
|
+
.STORY is a lightweight plain text format for writing narrative as a series of days. Each day has optional metadata at the top and a body of dialogue and narration underneath. It is meant to be easy to write by hand, easy to read, and easy to parse.
|
|
4
|
+
|
|
5
|
+
## Why it exists
|
|
6
|
+
|
|
7
|
+
Most story tools lock your writing into a heavy editor or a complicated markup language. A .STORY file is just text. You can write one on a phone, in a terminal, or in any editor, and a small parser turns it into structured data your code can use.
|
|
8
|
+
|
|
9
|
+
## What a file looks like
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
day: 1
|
|
13
|
+
title: The Awakening
|
|
14
|
+
weather: Rain
|
|
15
|
+
---
|
|
16
|
+
# First scene
|
|
17
|
+
ALICE: Hello world. Time is 10:30 AM
|
|
18
|
+
* Thunder rumbles in the distance.
|
|
19
|
+
===
|
|
20
|
+
day: 2
|
|
21
|
+
title: Shadows
|
|
22
|
+
mood: Tense
|
|
23
|
+
---
|
|
24
|
+
bob: What was that noise
|
|
25
|
+
* Steps echo down the hall.
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Syntax at a glance
|
|
29
|
+
|
|
30
|
+
| Syntax | Meaning |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `===` | Separates one day from the next |
|
|
33
|
+
| `---` | Separates a day's headers from its body |
|
|
34
|
+
| `key: value` | A header. `day`, `title`, `scene`, and `mood` are reserved. Other keys are kept in `extra` |
|
|
35
|
+
| `speaker: text` | A line of dialogue. Speaker names are lower-cased by the parser |
|
|
36
|
+
| `* text` | A line of narration |
|
|
37
|
+
| `# text` | A comment, ignored by the parser |
|
|
38
|
+
|
|
39
|
+
## What is in this repo
|
|
40
|
+
|
|
41
|
+
- `parseStory.js` is the parser and serializer, with zero dependencies
|
|
42
|
+
- `sample.story` is an example file
|
|
43
|
+
- `open-storyfiles.py` is a helper script for opening .story files
|
|
44
|
+
- `package.json` holds the project and test config
|
|
45
|
+
|
|
46
|
+
## Design goals
|
|
47
|
+
|
|
48
|
+
- **Zero dependencies.** Plain vanilla JavaScript.
|
|
49
|
+
- **Works everywhere.** Runs in Node.js with `require` and in the browser with a script tag.
|
|
50
|
+
- **Forgiving.** The parser never throws on bad input. It returns `warnings` with line numbers instead.
|
|
51
|
+
- **Lossless.** Parsed stories can be serialized back into standard .STORY text.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT. See the LICENSE file.
|
package/creators-note.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Creator's Note
|
|
2
|
+
|
|
3
|
+
Hi, I'm Keane, the person behind .STORY.
|
|
4
|
+
|
|
5
|
+
I am a student and indie developer based in Cagayan de Oro, Philippines. I do most of my building from my phone, so I care a lot about tools that are small, plain, and easy to use without a big setup. That is the idea behind this format.
|
|
6
|
+
|
|
7
|
+
## Why I made it
|
|
8
|
+
|
|
9
|
+
There are a lot of file formats out there for story game developers to choose from, and picking one can be a project on its own. Some are powerful but heavy, and some take a while to learn before you can write a single line. I wanted one simple option that stays out of the way: write your days, your dialogue, and your narration in plain text, and let a tiny parser handle the rest.
|
|
10
|
+
|
|
11
|
+
## What I want it to be
|
|
12
|
+
|
|
13
|
+
A format that anyone can write by hand and anything can read. No lock-in, no special editor, no heavy tooling. If you can type text, you can write a .STORY file.
|
|
14
|
+
|
|
15
|
+
## Where it is going
|
|
16
|
+
|
|
17
|
+
I plan to publish the parser to npm so anyone can install it with one command. Beyond that, I want to see what other people build with it, like story players, visual novel tools, and games.
|
|
18
|
+
|
|
19
|
+
## Contributing
|
|
20
|
+
|
|
21
|
+
Ideas, bug reports, and pull requests are welcome. If you find a line the parser handles badly, open an issue and include the line and the warning it gave you.
|
|
22
|
+
|
|
23
|
+
## Support the project
|
|
24
|
+
|
|
25
|
+
The best way to support .STORY is to spread the word. Star the repo, share it with other story game devs, and tell a friend who writes dialogue-heavy games or visual novels. The more people who know it exists, the more it grows, and the more it can be improved by people who actually use it.
|
|
26
|
+
|
|
27
|
+
## Thanks
|
|
28
|
+
|
|
29
|
+
Thank you for checking out .STORY. If you make something with it, I would love to see it.
|
|
30
|
+
|
|
31
|
+
Keane
|
package/open-storyfiles.py
CHANGED
|
@@ -1,179 +1,181 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import re
|
|
3
|
-
import json
|
|
4
|
-
|
|
5
|
-
def parse_story(text: str) -> dict:
|
|
6
|
-
"""
|
|
7
|
-
Parses raw .STORY format string into a structured dictionary.
|
|
8
|
-
Returns a dict containing 'days' list and 'warnings' list.
|
|
9
|
-
"""
|
|
10
|
-
days = []
|
|
11
|
-
warnings = []
|
|
12
|
-
|
|
13
|
-
if not isinstance(text, str):
|
|
14
|
-
warnings.append({"line": 0, "message": "Input must be a string."})
|
|
15
|
-
return {"days": days, "warnings": warnings}
|
|
16
|
-
|
|
17
|
-
# Split by day delimiter '===' on its own line
|
|
18
|
-
raw_days = re.split(r'^===\s*$', text, flags=re.MULTILINE)
|
|
19
|
-
|
|
20
|
-
for day_text in raw_days:
|
|
21
|
-
lines = day_text.splitlines()
|
|
22
|
-
day_obj = {
|
|
23
|
-
"day": None,
|
|
24
|
-
"title": None,
|
|
25
|
-
"scene": None,
|
|
26
|
-
"mood": None,
|
|
27
|
-
"extra": {},
|
|
28
|
-
"entries": []
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
in_header = True
|
|
32
|
-
|
|
33
|
-
for line_idx, line in enumerate(lines):
|
|
34
|
-
line_num = line_idx + 1
|
|
35
|
-
trimmed = line.strip()
|
|
36
|
-
|
|
37
|
-
# Ignore empty lines and comments
|
|
38
|
-
if not trimmed or trimmed.startswith("#"):
|
|
39
|
-
continue
|
|
40
|
-
|
|
41
|
-
# Check for header/body separator '---'
|
|
42
|
-
if in_header and re.match(r'^---\s*$', trimmed):
|
|
43
|
-
in_header = False
|
|
44
|
-
continue
|
|
45
|
-
|
|
46
|
-
if in_header:
|
|
47
|
-
if ":" not in line:
|
|
48
|
-
warnings.append({
|
|
49
|
-
"line": line_num,
|
|
50
|
-
"message": f'Invalid header syntax: missing colon in line "{trimmed}"'
|
|
51
|
-
})
|
|
52
|
-
continue
|
|
53
|
-
|
|
54
|
-
key, value = line.split(":", 1)
|
|
55
|
-
key = key.strip().lower()
|
|
56
|
-
value = value.strip()
|
|
57
|
-
|
|
58
|
-
if key in ["day", "title", "scene", "mood"]:
|
|
59
|
-
if key == "day":
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
speaker =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"""
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
"""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if day_obj.get("
|
|
134
|
-
header_lines.append(f"
|
|
135
|
-
if day_obj.get("
|
|
136
|
-
header_lines.append(f"
|
|
137
|
-
if day_obj.get("
|
|
138
|
-
header_lines.append(f"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
body_lines.append(f"
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
print("
|
|
166
|
-
|
|
167
|
-
print("
|
|
168
|
-
|
|
169
|
-
print(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
def parse_story(text: str) -> dict:
|
|
6
|
+
"""
|
|
7
|
+
Parses raw .STORY format string into a structured dictionary.
|
|
8
|
+
Returns a dict containing 'days' list and 'warnings' list.
|
|
9
|
+
"""
|
|
10
|
+
days = []
|
|
11
|
+
warnings = []
|
|
12
|
+
|
|
13
|
+
if not isinstance(text, str):
|
|
14
|
+
warnings.append({"line": 0, "message": "Input must be a string."})
|
|
15
|
+
return {"days": days, "warnings": warnings}
|
|
16
|
+
|
|
17
|
+
# Split by day delimiter '===' on its own line
|
|
18
|
+
raw_days = re.split(r'^===\s*$', text, flags=re.MULTILINE)
|
|
19
|
+
|
|
20
|
+
for day_text in raw_days:
|
|
21
|
+
lines = day_text.splitlines()
|
|
22
|
+
day_obj = {
|
|
23
|
+
"day": None,
|
|
24
|
+
"title": None,
|
|
25
|
+
"scene": None,
|
|
26
|
+
"mood": None,
|
|
27
|
+
"extra": {},
|
|
28
|
+
"entries": []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
in_header = True
|
|
32
|
+
|
|
33
|
+
for line_idx, line in enumerate(lines):
|
|
34
|
+
line_num = line_idx + 1
|
|
35
|
+
trimmed = line.strip()
|
|
36
|
+
|
|
37
|
+
# Ignore empty lines and comments
|
|
38
|
+
if not trimmed or trimmed.startswith("#"):
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
# Check for header/body separator '---'
|
|
42
|
+
if in_header and re.match(r'^---\s*$', trimmed):
|
|
43
|
+
in_header = False
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if in_header:
|
|
47
|
+
if ":" not in line:
|
|
48
|
+
warnings.append({
|
|
49
|
+
"line": line_num,
|
|
50
|
+
"message": f'Invalid header syntax: missing colon in line "{trimmed}"'
|
|
51
|
+
})
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
key, value = line.split(":", 1)
|
|
55
|
+
key = key.strip().lower()
|
|
56
|
+
value = value.strip()
|
|
57
|
+
|
|
58
|
+
if key in ["day", "title", "scene", "mood"]:
|
|
59
|
+
if key == "day":
|
|
60
|
+
# Strict integer check so "2b" stays a string (matches the JS port)
|
|
61
|
+
if re.fullmatch(r'[+-]?[0-9]+', value):
|
|
62
|
+
day_obj["day"] = int(value)
|
|
63
|
+
else:
|
|
64
|
+
day_obj["day"] = value
|
|
65
|
+
else:
|
|
66
|
+
day_obj[key] = value
|
|
67
|
+
else:
|
|
68
|
+
day_obj["extra"][key] = value
|
|
69
|
+
else:
|
|
70
|
+
# Body parsing (dialogue vs narration)
|
|
71
|
+
if trimmed.startswith("*"):
|
|
72
|
+
narration_text = trimmed[1:].strip()
|
|
73
|
+
day_obj["entries"].append({
|
|
74
|
+
"type": "narration",
|
|
75
|
+
"speaker": None,
|
|
76
|
+
"text": narration_text
|
|
77
|
+
})
|
|
78
|
+
else:
|
|
79
|
+
if ":" not in line:
|
|
80
|
+
warnings.append({
|
|
81
|
+
"line": line_num,
|
|
82
|
+
"message": f'Invalid dialogue syntax: missing speaker colon in line "{trimmed}"'
|
|
83
|
+
})
|
|
84
|
+
# Fallback as narration
|
|
85
|
+
day_obj["entries"].append({
|
|
86
|
+
"type": "narration",
|
|
87
|
+
"speaker": None,
|
|
88
|
+
"text": trimmed
|
|
89
|
+
})
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
speaker, entry_text = line.split(":", 1)
|
|
93
|
+
speaker = speaker.strip().lower()
|
|
94
|
+
entry_text = entry_text.strip()
|
|
95
|
+
|
|
96
|
+
day_obj["entries"].append({
|
|
97
|
+
"type": "dialogue",
|
|
98
|
+
"speaker": speaker,
|
|
99
|
+
"text": entry_text
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
has_header = any(day_obj[k] is not None for k in ("day", "title", "scene", "mood"))
|
|
103
|
+
if has_header or len(day_obj["entries"]) > 0 or len(day_obj["extra"]) > 0:
|
|
104
|
+
days.append(day_obj)
|
|
105
|
+
|
|
106
|
+
return {"days": days, "warnings": warnings}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_story_file(file_path: str) -> dict:
|
|
110
|
+
"""
|
|
111
|
+
Opens and parses a .STORY file from disk.
|
|
112
|
+
"""
|
|
113
|
+
if not os.path.exists(file_path):
|
|
114
|
+
raise FileNotFoundError(f"Story file not found at path: {file_path}")
|
|
115
|
+
|
|
116
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
117
|
+
content = f.read()
|
|
118
|
+
|
|
119
|
+
return parse_story(content)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def serialize_story(days: list) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Serializes a list of day dictionaries back into .STORY formatted plain text.
|
|
125
|
+
"""
|
|
126
|
+
if not isinstance(days, list):
|
|
127
|
+
return ""
|
|
128
|
+
|
|
129
|
+
day_blocks = []
|
|
130
|
+
for day_obj in days:
|
|
131
|
+
header_lines = []
|
|
132
|
+
|
|
133
|
+
if day_obj.get("day") is not None:
|
|
134
|
+
header_lines.append(f"day: {day_obj['day']}")
|
|
135
|
+
if day_obj.get("title"):
|
|
136
|
+
header_lines.append(f"title: {day_obj['title']}")
|
|
137
|
+
if day_obj.get("scene"):
|
|
138
|
+
header_lines.append(f"scene: {day_obj['scene']}")
|
|
139
|
+
if day_obj.get("mood"):
|
|
140
|
+
header_lines.append(f"mood: {day_obj['mood']}")
|
|
141
|
+
|
|
142
|
+
extra = day_obj.get("extra", {})
|
|
143
|
+
if isinstance(extra, dict):
|
|
144
|
+
for k, v in extra.items():
|
|
145
|
+
header_lines.append(f"{k}: {v}")
|
|
146
|
+
|
|
147
|
+
body_lines = []
|
|
148
|
+
for entry in day_obj.get("entries", []):
|
|
149
|
+
if entry.get("type") == "narration" or not entry.get("speaker"):
|
|
150
|
+
body_lines.append(f"* {entry.get('text', '')}")
|
|
151
|
+
else:
|
|
152
|
+
body_lines.append(f"{entry.get('speaker', '')}: {entry.get('text', '')}")
|
|
153
|
+
|
|
154
|
+
full_block = "\n".join(header_lines + ["---"] + body_lines)
|
|
155
|
+
day_blocks.append(full_block)
|
|
156
|
+
|
|
157
|
+
return "\n===\n".join(day_blocks)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Quick runner test when executed directly
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
sample_file = "sample.story"
|
|
163
|
+
|
|
164
|
+
if os.path.exists(sample_file):
|
|
165
|
+
print(f"=== READING {sample_file} ===")
|
|
166
|
+
result = load_story_file(sample_file)
|
|
167
|
+
print("Parsed JSON Output:")
|
|
168
|
+
print(json.dumps(result["days"], indent=2))
|
|
169
|
+
print("\nWarnings:", result["warnings"])
|
|
170
|
+
else:
|
|
171
|
+
print(f"File {sample_file} not found. Running inline test.")
|
|
172
|
+
sample_data = """day: 1
|
|
173
|
+
title: The Python Awakening
|
|
174
|
+
scene: Code Room
|
|
175
|
+
---
|
|
176
|
+
# Python parser test
|
|
177
|
+
python_bot: Hello from Python land!
|
|
178
|
+
* The script executes flawlessly.
|
|
179
|
+
"""
|
|
180
|
+
result = parse_story(sample_data)
|
|
181
|
+
print(json.dumps(result["days"], indent=2))
|
package/package.json
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "story-format-parser",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Parser and serializer for the .STORY plain text file format.",
|
|
5
|
-
"main": "parseStory.js",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "story-format-parser",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Parser and serializer for the .STORY plain text file format.",
|
|
5
|
+
"main": "parseStory.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"parseStory.js",
|
|
8
|
+
"open-storyfiles.py",
|
|
9
|
+
"sample.story",
|
|
10
|
+
"about.md",
|
|
11
|
+
"creators-note.md",
|
|
12
|
+
"story-logo.png"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node test.js"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/keane3029-lab/.story-format.git"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"story",
|
|
23
|
+
"parser",
|
|
24
|
+
"interactive-fiction",
|
|
25
|
+
"text-format"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|
package/parseStory.js
CHANGED
|
@@ -1,146 +1,147 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* .STORY Format Parser, Serializer, and File Reader
|
|
3
|
-
* Zero-dependency universal library (Node.js & Browser)
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
function parseStory(text) {
|
|
7
|
-
const days = [];
|
|
8
|
-
const warnings = [];
|
|
9
|
-
|
|
10
|
-
if (typeof text !== "string") {
|
|
11
|
-
warnings.push({ line: 0, message: "Input must be a string." });
|
|
12
|
-
return { days, warnings };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const rawDays = text.split(/^===\s*$/m);
|
|
16
|
-
|
|
17
|
-
rawDays.forEach((dayText, dayIdx) => {
|
|
18
|
-
const lines = dayText.split(/\r?\n/);
|
|
19
|
-
const dayObj = {
|
|
20
|
-
day: null,
|
|
21
|
-
title: null,
|
|
22
|
-
scene: null,
|
|
23
|
-
mood: null,
|
|
24
|
-
extra: {},
|
|
25
|
-
entries: []
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
let inHeader = true;
|
|
29
|
-
|
|
30
|
-
lines.forEach((line, lineIdx) => {
|
|
31
|
-
const lineNum = lineIdx + 1;
|
|
32
|
-
const trimmed = line.trim();
|
|
33
|
-
|
|
34
|
-
// Ignore empty lines and comments
|
|
35
|
-
if (!trimmed || trimmed.startsWith("#")) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Check header/body separator
|
|
40
|
-
if (inHeader && /^---\s*$/.test(trimmed)) {
|
|
41
|
-
inHeader = false;
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (inHeader) {
|
|
46
|
-
const colonIndex = line.indexOf(":");
|
|
47
|
-
if (colonIndex === -1) {
|
|
48
|
-
warnings.push({
|
|
49
|
-
line: lineNum,
|
|
50
|
-
message: `Invalid header syntax: missing colon in line "${trimmed}"`
|
|
51
|
-
});
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const key = line.slice(0, colonIndex).trim().toLowerCase();
|
|
56
|
-
const value = line.slice(colonIndex + 1).trim();
|
|
57
|
-
|
|
58
|
-
if (["day", "title", "scene", "mood"].includes(key)) {
|
|
59
|
-
if (key === "day") {
|
|
60
|
-
|
|
61
|
-
dayObj.day =
|
|
62
|
-
} else {
|
|
63
|
-
dayObj[key] = value;
|
|
64
|
-
}
|
|
65
|
-
} else {
|
|
66
|
-
dayObj.extra[key] = value;
|
|
67
|
-
}
|
|
68
|
-
} else {
|
|
69
|
-
// Parsing body (dialogue and narration)
|
|
70
|
-
if (trimmed.startsWith("*")) {
|
|
71
|
-
// Narration
|
|
72
|
-
const narrationText = trimmed.slice(1).trim();
|
|
73
|
-
dayObj.entries.push({
|
|
74
|
-
type: "narration",
|
|
75
|
-
speaker: null,
|
|
76
|
-
text: narrationText
|
|
77
|
-
});
|
|
78
|
-
} else {
|
|
79
|
-
// Dialogue
|
|
80
|
-
const colonIndex = line.indexOf(":");
|
|
81
|
-
if (colonIndex === -1) {
|
|
82
|
-
warnings.push({
|
|
83
|
-
line: lineNum,
|
|
84
|
-
message: `Invalid dialogue syntax: missing speaker colon in line "${trimmed}"`
|
|
85
|
-
});
|
|
86
|
-
// Fallback as unformatted narration
|
|
87
|
-
dayObj.entries.push({
|
|
88
|
-
type: "narration",
|
|
89
|
-
speaker: null,
|
|
90
|
-
text: trimmed
|
|
91
|
-
});
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const speaker = line.slice(0, colonIndex).trim().toLowerCase();
|
|
96
|
-
const text = line.slice(colonIndex + 1).trim();
|
|
97
|
-
|
|
98
|
-
dayObj.entries.push({
|
|
99
|
-
type: "dialogue",
|
|
100
|
-
speaker,
|
|
101
|
-
text
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (dayObj.
|
|
124
|
-
if (dayObj.
|
|
125
|
-
|
|
126
|
-
if (dayObj.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
1
|
+
/**
|
|
2
|
+
* .STORY Format Parser, Serializer, and File Reader
|
|
3
|
+
* Zero-dependency universal library (Node.js & Browser)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function parseStory(text) {
|
|
7
|
+
const days = [];
|
|
8
|
+
const warnings = [];
|
|
9
|
+
|
|
10
|
+
if (typeof text !== "string") {
|
|
11
|
+
warnings.push({ line: 0, message: "Input must be a string." });
|
|
12
|
+
return { days, warnings };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const rawDays = text.split(/^===\s*$/m);
|
|
16
|
+
|
|
17
|
+
rawDays.forEach((dayText, dayIdx) => {
|
|
18
|
+
const lines = dayText.split(/\r?\n/);
|
|
19
|
+
const dayObj = {
|
|
20
|
+
day: null,
|
|
21
|
+
title: null,
|
|
22
|
+
scene: null,
|
|
23
|
+
mood: null,
|
|
24
|
+
extra: {},
|
|
25
|
+
entries: []
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let inHeader = true;
|
|
29
|
+
|
|
30
|
+
lines.forEach((line, lineIdx) => {
|
|
31
|
+
const lineNum = lineIdx + 1;
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
|
|
34
|
+
// Ignore empty lines and comments
|
|
35
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check header/body separator
|
|
40
|
+
if (inHeader && /^---\s*$/.test(trimmed)) {
|
|
41
|
+
inHeader = false;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (inHeader) {
|
|
46
|
+
const colonIndex = line.indexOf(":");
|
|
47
|
+
if (colonIndex === -1) {
|
|
48
|
+
warnings.push({
|
|
49
|
+
line: lineNum,
|
|
50
|
+
message: `Invalid header syntax: missing colon in line "${trimmed}"`
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const key = line.slice(0, colonIndex).trim().toLowerCase();
|
|
56
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
57
|
+
|
|
58
|
+
if (["day", "title", "scene", "mood"].includes(key)) {
|
|
59
|
+
if (key === "day") {
|
|
60
|
+
// Strict integer check so "2b" stays a string (matches the Python port)
|
|
61
|
+
dayObj.day = /^[+-]?\d+$/.test(value) ? parseInt(value, 10) : value;
|
|
62
|
+
} else {
|
|
63
|
+
dayObj[key] = value;
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
dayObj.extra[key] = value;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// Parsing body (dialogue and narration)
|
|
70
|
+
if (trimmed.startsWith("*")) {
|
|
71
|
+
// Narration
|
|
72
|
+
const narrationText = trimmed.slice(1).trim();
|
|
73
|
+
dayObj.entries.push({
|
|
74
|
+
type: "narration",
|
|
75
|
+
speaker: null,
|
|
76
|
+
text: narrationText
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
// Dialogue
|
|
80
|
+
const colonIndex = line.indexOf(":");
|
|
81
|
+
if (colonIndex === -1) {
|
|
82
|
+
warnings.push({
|
|
83
|
+
line: lineNum,
|
|
84
|
+
message: `Invalid dialogue syntax: missing speaker colon in line "${trimmed}"`
|
|
85
|
+
});
|
|
86
|
+
// Fallback as unformatted narration
|
|
87
|
+
dayObj.entries.push({
|
|
88
|
+
type: "narration",
|
|
89
|
+
speaker: null,
|
|
90
|
+
text: trimmed
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const speaker = line.slice(0, colonIndex).trim().toLowerCase();
|
|
96
|
+
const text = line.slice(colonIndex + 1).trim();
|
|
97
|
+
|
|
98
|
+
dayObj.entries.push({
|
|
99
|
+
type: "dialogue",
|
|
100
|
+
speaker,
|
|
101
|
+
text
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const hasHeader = dayObj.day !== null || dayObj.title !== null ||
|
|
108
|
+
dayObj.scene !== null || dayObj.mood !== null;
|
|
109
|
+
if (hasHeader || dayObj.entries.length > 0 || Object.keys(dayObj.extra).length > 0) {
|
|
110
|
+
days.push(dayObj);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return { days, warnings };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function serializeStory(days) {
|
|
118
|
+
if (!Array.isArray(days)) return "";
|
|
119
|
+
|
|
120
|
+
return days.map(dayObj => {
|
|
121
|
+
const headerLines = [];
|
|
122
|
+
|
|
123
|
+
if (dayObj.day !== null && dayObj.day !== undefined) headerLines.push(`day: ${dayObj.day}`);
|
|
124
|
+
if (dayObj.title) headerLines.push(`title: ${dayObj.title}`);
|
|
125
|
+
if (dayObj.scene) headerLines.push(`scene: ${dayObj.scene}`);
|
|
126
|
+
if (dayObj.mood) headerLines.push(`mood: ${dayObj.mood}`);
|
|
127
|
+
|
|
128
|
+
if (dayObj.extra && typeof dayObj.extra === "object") {
|
|
129
|
+
Object.entries(dayObj.extra).forEach(([k, v]) => {
|
|
130
|
+
headerLines.push(`${k}: ${v}`);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const bodyLines = (dayObj.entries || []).map(entry => {
|
|
135
|
+
if (entry.type === "narration" || !entry.speaker) {
|
|
136
|
+
return `* ${entry.text}`;
|
|
137
|
+
}
|
|
138
|
+
return `${entry.speaker}: ${entry.text}`;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return [...headerLines, "---", ...bodyLines].join("\n");
|
|
142
|
+
}).join("\n===\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof module === "object" && module.exports) {
|
|
146
|
+
module.exports = { parseStory, serializeStory };
|
|
147
|
+
}
|
package/sample.story
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
day: 1
|
|
2
|
-
title: The Awakening
|
|
1
|
+
day: 1
|
|
2
|
+
title: The Awakening
|
|
3
3
|
weather: Rain
|
package/story-logo.png
ADDED
|
Binary file
|
package/.vscode/launch.json
DELETED