@strav/view 0.2.7 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/view",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "View layer for the Strav framework — template engine, Vue SFC islands, and SPA client router",
6
6
  "license": "MIT",
package/src/cache.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface RenderResult {
2
2
  output: string
3
3
  blocks: Record<string, string>
4
+ stacks: Record<string, string[]>
4
5
  }
5
6
 
6
7
  export type RenderFunction = (
package/src/compiler.ts CHANGED
@@ -7,9 +7,10 @@ export interface CompilationResult {
7
7
  }
8
8
 
9
9
  interface StackEntry {
10
- type: 'if' | 'each' | 'section'
10
+ type: 'if' | 'each' | 'section' | 'push' | 'prepend'
11
11
  line: number
12
12
  blockName?: string
13
+ stackName?: string
13
14
  }
14
15
 
15
16
  function escapeJs(str: string): string {
@@ -94,6 +95,7 @@ export function compile(tokens: Token[]): CompilationResult {
94
95
 
95
96
  lines.push('let __out = "";')
96
97
  lines.push('const __blocks = {};')
98
+ lines.push('const __stacks = {};')
97
99
 
98
100
  for (const token of tokens) {
99
101
  switch (token.type) {
@@ -147,7 +149,7 @@ export function compile(tokens: Token[]): CompilationResult {
147
149
  throw new TemplateError(`Unclosed @${unclosed.type} block (opened at line ${unclosed.line})`)
148
150
  }
149
151
 
150
- lines.push('return { output: __out, blocks: __blocks };')
152
+ lines.push('return { output: __out, blocks: __blocks, stacks: __stacks };')
151
153
 
152
154
  return { code: lines.join('\n'), layout }
153
155
  }
@@ -274,6 +276,40 @@ function compileDirective(
274
276
  )
275
277
  break
276
278
 
279
+ case 'push': {
280
+ if (!token.args) throw new TemplateError(`@push requires a name at line ${token.line}`)
281
+ const name = token.args.replace(/^['"]|['"]$/g, '').trim()
282
+ const nameStr = JSON.stringify(name)
283
+ // Initialize stack array if it doesn't exist, then capture content and push
284
+ lines.push(`if (!__stacks[${nameStr}]) __stacks[${nameStr}] = [];`)
285
+ lines.push(`__stacks[${nameStr}].push((function() { let __out = "";`)
286
+ stack.push({ type: 'push', line: token.line, stackName: name })
287
+ break
288
+ }
289
+
290
+ case 'prepend': {
291
+ if (!token.args) throw new TemplateError(`@prepend requires a name at line ${token.line}`)
292
+ const name = token.args.replace(/^['"]|['"]$/g, '').trim()
293
+ const nameStr = JSON.stringify(name)
294
+ // Initialize stack array if it doesn't exist, then capture content and unshift
295
+ lines.push(`if (!__stacks[${nameStr}]) __stacks[${nameStr}] = [];`)
296
+ lines.push(`__stacks[${nameStr}].unshift((function() { let __out = "";`)
297
+ stack.push({ type: 'prepend', line: token.line, stackName: name })
298
+ break
299
+ }
300
+
301
+ case 'stack': {
302
+ if (!token.args) throw new TemplateError(`@stack requires a name at line ${token.line}`)
303
+ const name = token.args.replace(/^['"]|['"]$/g, '').trim()
304
+ const nameStr = JSON.stringify(name)
305
+ // Output joined stack content - merge local and passed stacks
306
+ lines.push(`{`)
307
+ lines.push(` const __mergedStack = [...((__data.__stacks && __data.__stacks[${nameStr}]) || []), ...(__stacks[${nameStr}] || [])];`)
308
+ lines.push(` __out += __mergedStack.join('');`)
309
+ lines.push(`}`)
310
+ break
311
+ }
312
+
277
313
  case 'end': {
278
314
  if (!stack.length) {
279
315
  throw new TemplateError(`Unexpected @end at line ${token.line} — no open block`)
@@ -281,6 +317,8 @@ function compileDirective(
281
317
  const top = stack.pop()!
282
318
  if (top.type === 'section') {
283
319
  lines.push(` return __out; })();`)
320
+ } else if (top.type === 'push' || top.type === 'prepend') {
321
+ lines.push(` return __out; })());`)
284
322
  } else if (top.type === 'each') {
285
323
  lines.push(` }`) // close for loop
286
324
  lines.push(`}`) // close block scope
package/src/engine.ts CHANGED
@@ -48,7 +48,8 @@ export default class ViewEngine {
48
48
  private async renderWithDepth(
49
49
  name: string,
50
50
  data: Record<string, unknown>,
51
- depth: number
51
+ depth: number,
52
+ parentStacks: Record<string, string[]> = {}
52
53
  ): Promise<string> {
53
54
  if (depth > MAX_INCLUDE_DEPTH) {
54
55
  throw new TemplateError(
@@ -58,21 +59,82 @@ export default class ViewEngine {
58
59
 
59
60
  const entry = await this.resolve(name)
60
61
 
61
- const includeFn: IncludeFn = (includeName, includeData) => {
62
- return this.renderWithDepth(includeName, { ...data, ...includeData }, depth + 1)
62
+ // Create an include function that merges stacks from includes
63
+ const includeFn: IncludeFn = async (includeName, includeData) => {
64
+ const includeResult = await this.renderWithDepthInternal(
65
+ includeName,
66
+ { ...data, ...includeData },
67
+ depth + 1,
68
+ {}
69
+ )
70
+ // Merge include's stacks into parent stacks
71
+ this.mergeStacks(parentStacks, includeResult.stacks)
72
+ return includeResult.output
63
73
  }
64
74
 
65
- const result = await entry.fn(data, includeFn)
75
+ // Pass parent stacks to the template
76
+ const dataWithStacks = { ...data, __stacks: { ...parentStacks } }
77
+ const result = await entry.fn(dataWithStacks, includeFn)
78
+
79
+ // Merge current template's stacks with parent stacks
80
+ this.mergeStacks(parentStacks, result.stacks)
66
81
 
67
- // Layout inheritance: render child first, then render layout with blocks merged
82
+ // Layout inheritance: render child first, then render layout with blocks and stacks merged
68
83
  if (entry.layout) {
69
- const layoutData = { ...data, ...result.blocks }
70
- return this.renderWithDepth(entry.layout, layoutData, depth + 1)
84
+ const layoutData = { ...data, ...result.blocks, __stacks: parentStacks }
85
+ return this.renderWithDepth(entry.layout, layoutData, depth + 1, parentStacks)
71
86
  }
72
87
 
73
88
  return result.output
74
89
  }
75
90
 
91
+ private async renderWithDepthInternal(
92
+ name: string,
93
+ data: Record<string, unknown>,
94
+ depth: number,
95
+ parentStacks: Record<string, string[]> = {}
96
+ ): Promise<{ output: string; stacks: Record<string, string[]> }> {
97
+ if (depth > MAX_INCLUDE_DEPTH) {
98
+ throw new TemplateError(
99
+ `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded — possible circular include`
100
+ )
101
+ }
102
+
103
+ const entry = await this.resolve(name)
104
+
105
+ const includeFn: IncludeFn = async (includeName, includeData) => {
106
+ const includeResult = await this.renderWithDepthInternal(
107
+ includeName,
108
+ { ...data, ...includeData },
109
+ depth + 1,
110
+ {}
111
+ )
112
+ this.mergeStacks(parentStacks, includeResult.stacks)
113
+ return includeResult.output
114
+ }
115
+
116
+ const dataWithStacks = { ...data, __stacks: { ...parentStacks } }
117
+ const result = await entry.fn(dataWithStacks, includeFn)
118
+ this.mergeStacks(parentStacks, result.stacks)
119
+
120
+ if (entry.layout) {
121
+ const layoutData = { ...data, ...result.blocks, __stacks: parentStacks }
122
+ const layoutOutput = await this.renderWithDepth(entry.layout, layoutData, depth + 1, parentStacks)
123
+ return { output: layoutOutput, stacks: parentStacks }
124
+ }
125
+
126
+ return { output: result.output, stacks: parentStacks }
127
+ }
128
+
129
+ private mergeStacks(target: Record<string, string[]>, source: Record<string, string[]>): void {
130
+ for (const [key, values] of Object.entries(source)) {
131
+ if (!target[key]) {
132
+ target[key] = []
133
+ }
134
+ target[key].push(...values)
135
+ }
136
+ }
137
+
76
138
  private async resolve(name: string): Promise<CacheEntry> {
77
139
  const cached = this.cache.get(name)
78
140
 
package/src/tokenizer.ts CHANGED
@@ -30,6 +30,9 @@ const DIRECTIVES = new Set([
30
30
  'csrf',
31
31
  'class',
32
32
  'style',
33
+ 'push',
34
+ 'prepend',
35
+ 'stack',
33
36
  ])
34
37
 
35
38
  export function tokenize(source: string): Token[] {