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 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
- # .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)
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.
@@ -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
@@ -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
- 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))
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.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
- }
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
- 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
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
@@ -1,14 +0,0 @@
1
- {
2
- "version": "0.2.0",
3
- "configurations": [
4
- {
5
- "type": "node",
6
- "request": "launch",
7
- "name": "Debug parseStory.js",
8
- "program": "${workspaceFolder}/parseStory.js",
9
- "skipFiles": [
10
- "/**"
11
- ]
12
- }
13
- ]
14
- }
@@ -1,5 +0,0 @@
1
- {
2
- "files.associations": {
3
- "*.story": "markdown"
4
- }
5
- }