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.
@@ -0,0 +1,14 @@
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
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "files.associations": {
3
+ "*.story": "markdown"
4
+ }
5
+ }
package/README.md CHANGED
@@ -1,3 +1,110 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -0,0 +1,3 @@
1
+ day: 1
2
+ title: The Awakening
3
+ weather: Rain