story-format-parser 0.0.0-stage → 1.0.2
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/.vscode/launch.json +14 -0
- package/.vscode/settings.json +5 -0
- package/README.md +110 -3
- package/open-storyfiles.py +179 -0
- package/package.json +17 -6
- package/parseStory.js +146 -0
- package/sample.story +3 -0
package/README.md
CHANGED
|
@@ -1,3 +1,110 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# .STORY Format Parser & Serializer
|
|
2
|
+
|
|
3
|
+
A lightweight JavaScript parser and serializer for the custom `.STORY` plain text file format. Built with zero dependencies and cross-environment compatibility for **Node.js** and **Browser** environments.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Format Overview
|
|
8
|
+
|
|
9
|
+
A `.STORY` document structures narrative data into days containing optional headers and body interactions.
|
|
10
|
+
|
|
11
|
+
```story
|
|
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
|
+
---
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Zero Dependencies:** Standard, vanilla JavaScript.
|
|
33
|
+
- **Universal Export:** Works seamlessly across Node.js (`require`) and Browser (`<script>` tag) runtimes.
|
|
34
|
+
- **Non-Throwing Parser:** Returns detailed `warnings` with line numbers for malformed lines instead of throwing syntax runtime errors.
|
|
35
|
+
- **Full Serialization:** Losslessly converts structured story objects back into standard `.STORY` formatted text.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Syntax Rules
|
|
40
|
+
|
|
41
|
+
1. **Day Separator (`===`):** Separates days within a story file.
|
|
42
|
+
2. **Header-Body Separator (`---`):** Divides header metadata from dialogue/narration lines within each day block.
|
|
43
|
+
3. **Headers (`key: value`):** Reserved keys (`day`, `title`, `scene`, `mood`) map directly onto the day object. Custom/unknown keys are captured in `extra`.
|
|
44
|
+
4. **Dialogue (`speaker: text`):** Dialogue lines are converted to lower-cased speaker identifiers (`speaker`) with body text (`text`).
|
|
45
|
+
5. **Narration (`* text`):** Lines beginning with an asterisk are parsed as `type: "narration"`.
|
|
46
|
+
6. **Comments (`# comment`):** Lines starting with `#` are ignored during parsing.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Installation & Setup
|
|
51
|
+
|
|
52
|
+
Clone the repository to your local machine:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/keane3029-lab/.story-format.git
|
|
56
|
+
cd .story-format
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
### Node.js
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
const { parseStory, serializeStory } = require('./parseStory.js');
|
|
67
|
+
|
|
68
|
+
const storyText = `
|
|
69
|
+
day: 1
|
|
70
|
+
title: Prologue
|
|
71
|
+
---
|
|
72
|
+
* The adventure begins.
|
|
73
|
+
ALICE: Are you ready?
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// Parse text to object structure
|
|
77
|
+
const { days, warnings } = parseStory(storyText);
|
|
78
|
+
console.log(days);
|
|
79
|
+
|
|
80
|
+
// Serialize object structure back to .STORY format
|
|
81
|
+
const rawText = serializeStory(days);
|
|
82
|
+
console.log(rawText);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Browser
|
|
86
|
+
|
|
87
|
+
```html
|
|
88
|
+
<script src="parseStory.js"></script>
|
|
89
|
+
<script>
|
|
90
|
+
const input = "day: 1\n---\nalice: Ready when you are.";
|
|
91
|
+
const { days, warnings } = parseStory(input);
|
|
92
|
+
console.log(days);
|
|
93
|
+
</script>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Running Tests
|
|
99
|
+
|
|
100
|
+
Run the built-in self-test script in Node.js:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npm test
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,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
|
+
try:
|
|
61
|
+
day_obj["day"] = int(value)
|
|
62
|
+
except ValueError:
|
|
63
|
+
day_obj["day"] = value
|
|
64
|
+
else:
|
|
65
|
+
day_obj[key] = value
|
|
66
|
+
else:
|
|
67
|
+
day_obj["extra"][key] = value
|
|
68
|
+
else:
|
|
69
|
+
# Body parsing (dialogue vs narration)
|
|
70
|
+
if trimmed.startswith("*"):
|
|
71
|
+
narration_text = trimmed[1:].strip()
|
|
72
|
+
day_obj["entries"].append({
|
|
73
|
+
"type": "narration",
|
|
74
|
+
"speaker": None,
|
|
75
|
+
"text": narration_text
|
|
76
|
+
})
|
|
77
|
+
else:
|
|
78
|
+
if ":" not in line:
|
|
79
|
+
warnings.append({
|
|
80
|
+
"line": line_num,
|
|
81
|
+
"message": f'Invalid dialogue syntax: missing speaker colon in line "{trimmed}"'
|
|
82
|
+
})
|
|
83
|
+
# Fallback as narration
|
|
84
|
+
day_obj["entries"].append({
|
|
85
|
+
"type": "narration",
|
|
86
|
+
"speaker": None,
|
|
87
|
+
"text": trimmed
|
|
88
|
+
})
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
speaker, entry_text = line.split(":", 1)
|
|
92
|
+
speaker = speaker.strip().lower()
|
|
93
|
+
entry_text = entry_text.strip()
|
|
94
|
+
|
|
95
|
+
day_obj["entries"].append({
|
|
96
|
+
"type": "dialogue",
|
|
97
|
+
"speaker": speaker,
|
|
98
|
+
"text": entry_text
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if day_obj["day"] is not None or len(day_obj["entries"]) > 0 or len(day_obj["extra"]) > 0:
|
|
102
|
+
days.append(day_obj)
|
|
103
|
+
|
|
104
|
+
return {"days": days, "warnings": warnings}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_story_file(file_path: str) -> dict:
|
|
108
|
+
"""
|
|
109
|
+
Opens and parses a .STORY file from disk.
|
|
110
|
+
"""
|
|
111
|
+
if not os.path.exists(file_path):
|
|
112
|
+
raise FileNotFoundError(f"Story file not found at path: {file_path}")
|
|
113
|
+
|
|
114
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
115
|
+
content = f.read()
|
|
116
|
+
|
|
117
|
+
return parse_story(content)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def serialize_story(days: list) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Serializes a list of day dictionaries back into .STORY formatted plain text.
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(days, list):
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
day_blocks = []
|
|
128
|
+
for day_obj in days:
|
|
129
|
+
header_lines = []
|
|
130
|
+
|
|
131
|
+
if day_obj.get("day") is not None:
|
|
132
|
+
header_lines.append(f"day: {day_obj['day']}")
|
|
133
|
+
if day_obj.get("title"):
|
|
134
|
+
header_lines.append(f"title: {day_obj['title']}")
|
|
135
|
+
if day_obj.get("scene"):
|
|
136
|
+
header_lines.append(f"scene: {day_obj['scene']}")
|
|
137
|
+
if day_obj.get("mood"):
|
|
138
|
+
header_lines.append(f"mood: {day_obj['mood']}")
|
|
139
|
+
|
|
140
|
+
extra = day_obj.get("extra", {})
|
|
141
|
+
if isinstance(extra, dict):
|
|
142
|
+
for k, v in extra.items():
|
|
143
|
+
header_lines.append(f"{k}: {v}")
|
|
144
|
+
|
|
145
|
+
body_lines = []
|
|
146
|
+
for entry in day_obj.get("entries", []):
|
|
147
|
+
if entry.get("type") == "narration" or not entry.get("speaker"):
|
|
148
|
+
body_lines.append(f"* {entry.get('text', '')}")
|
|
149
|
+
else:
|
|
150
|
+
body_lines.append(f"{entry.get('speaker', '')}: {entry.get('text', '')}")
|
|
151
|
+
|
|
152
|
+
full_block = "\n".join(header_lines) + "\n---\n" + "\n".join(body_lines)
|
|
153
|
+
day_blocks.append(full_block)
|
|
154
|
+
|
|
155
|
+
return "\n===\n".join(day_blocks)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Quick runner test when executed directly
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
sample_file = "sample.story"
|
|
161
|
+
|
|
162
|
+
if os.path.exists(sample_file):
|
|
163
|
+
print(f"=== READING {sample_file} ===")
|
|
164
|
+
result = load_story_file(sample_file)
|
|
165
|
+
print("Parsed JSON Output:")
|
|
166
|
+
print(json.dumps(result["days"], indent=2))
|
|
167
|
+
print("\nWarnings:", result["warnings"])
|
|
168
|
+
else:
|
|
169
|
+
print(f"File {sample_file} not found. Running inline test.")
|
|
170
|
+
sample_data = """day: 1
|
|
171
|
+
title: The Python Awakening
|
|
172
|
+
scene: Code Room
|
|
173
|
+
---
|
|
174
|
+
# Python parser test
|
|
175
|
+
python_bot: Hello from Python land!
|
|
176
|
+
* The script executes flawlessly.
|
|
177
|
+
"""
|
|
178
|
+
result = parse_story(sample_data)
|
|
179
|
+
print(json.dumps(result["days"], indent=2))
|
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "story-format-parser",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "story-format-parser",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Parser and serializer for the .STORY plain text file format.",
|
|
5
|
+
"main": "parseStory.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node parseStory.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"story",
|
|
11
|
+
"parser",
|
|
12
|
+
"interactive-fiction",
|
|
13
|
+
"text-format"
|
|
14
|
+
],
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|
package/parseStory.js
ADDED
|
@@ -0,0 +1,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
|
+
const parsedDay = parseInt(value, 10);
|
|
61
|
+
dayObj.day = isNaN(parsedDay) ? value : parsedDay;
|
|
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
|
+
if (dayObj.day !== null || dayObj.entries.length > 0 || Object.keys(dayObj.extra).length > 0) {
|
|
108
|
+
days.push(dayObj);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return { days, warnings };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serializeStory(days) {
|
|
116
|
+
if (!Array.isArray(days)) return "";
|
|
117
|
+
|
|
118
|
+
return days.map(dayObj => {
|
|
119
|
+
const headerLines = [];
|
|
120
|
+
|
|
121
|
+
if (dayObj.day !== null && dayObj.day !== undefined) headerLines.push(`day: ${dayObj.day}`);
|
|
122
|
+
if (dayObj.title) headerLines.push(`title: ${dayObj.title}`);
|
|
123
|
+
if (dayObj.scene) headerLines.push(`scene: ${dayObj.scene}`);
|
|
124
|
+
if (dayObj.mood) headerLines.push(`mood: ${dayObj.mood}`);
|
|
125
|
+
|
|
126
|
+
if (dayObj.extra && typeof dayObj.extra === "object") {
|
|
127
|
+
Object.entries(dayObj.extra).forEach(([k, v]) => {
|
|
128
|
+
headerLines.push(`\({k}:\){v}`);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const bodyLines = (dayObj.entries || []).map(entry => {
|
|
133
|
+
if (entry.type === "narration" || !entry.speaker) {
|
|
134
|
+
return `* ${entry.text}`;
|
|
135
|
+
}
|
|
136
|
+
return `\({entry.speaker}:\){entry.text}`;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return [...headerLines, "---", ...bodyLines].join("\n");
|
|
140
|
+
}).join("\n===\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Reads and parses a .STORY file asynchronously in Node.js.
|
|
145
|
+
* @param {string} filePath - Path to the .STORY file.
|
|
146
|
+
* @returns {Promise<{ days: Array
|
package/sample.story
ADDED