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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
// Script and Style section formatting for Svelte components
//
// Handles the top-level <script> and <style> sections in .svelte files.
// These sections contain TypeScript/JS and CSS respectively,
// which are formatted using their dedicated printers.
use crate::ast;
use crate::printer::Printer;
use tsv_lang::TAB_WIDTH;
use tsv_lang::doc::{self, DocBuf, DocContext, arena::DocId};
impl<'a> Printer<'a> {
/// Format a Script tag
///
/// Formats both regular `<script>` and `<script context="module">` tags.
/// The TypeScript content is formatted using the TypeScript printer with indentation.
///
/// Uses Doc-based integration instead of string post-processing. The Doc system
/// naturally handles template literals correctly: `indent(doc)` only affects `Line` docs
/// (hardline, softline, line), not newlines inside `text()` which output as-is.
pub(super) fn print_script(&mut self, script: &ast::Script<'_>) {
// Check if script had any original content (including whitespace)
let had_content = script.content.span.start != script.content.span.end;
// Opening tag with doc-based attribute wrapping
self.write_section_opening_tag("script", script.attributes, had_content);
// A frozen-language body (`lang="coffee"`, `type="application/json"`, …) freezes:
// the author's bytes ride out verbatim between the two delimiter newlines —
// prettier's `preformattedBody` shape exactly, which is what it emits for its own
// frozen scripts. The AST is ignored, but the body still PARSED (canonical Svelte
// runs acorn on every script body regardless of `lang`, and so does tsv), so its JS
// comments are registered on the print-once ledger and the slice must declare
// them. See `ast::EmbeddedLang` for the allowlist.
if ast::EmbeddedLang::Script.is_frozen(script.attributes, self.source) {
return self.write_frozen_section("script", script.content.span);
}
if had_content {
self.write("\n");
}
// Build Doc for script content
// Width calculations are handled by:
// - start_column for the first line
// - start_indent_level for subsequent lines after hardline
// Template indent fallback (when source has no whitespace) is handled
// separately in the TypeScript printer with a hardcoded default of 1 for Svelte
// context.
// A `<script>` body is the one Svelte island whose printer owns whole LINES: its
// statements end with breaks of their own and the closing `</script>` takes a fresh
// line, so a `//` deferred to a line end still lands in JS. Every TEMPLATE island
// takes the safe default instead — past a `{…}` the rest of the line is markup, and
// a deferred comment would come out as page text.
let embed = tsv_lang::EmbedContext::line_owning();
let script_doc_id = tsv_ts::__private::build_program_doc(
self.d(),
&script.content,
self.source(),
self.line_table(),
embed,
);
// Render with indent
// The Doc system naturally handles template literals: text() newlines are NOT indented
// We render at indent_level=1 so hardlines produce proper indentation
// The first line needs manual indentation since there's no hardline before it
//
// start_column = tab_width (2) to account for the initial indent we'll add
// start_indent_level = 1 to account for the Svelte wrapper indent
// Render into the arena-parked scratch (one warm buffer, no per-block alloc).
let mut output = self.d().take_render_scratch();
// Pass the document source: the embedded TS doc carries `DocText::SourceSpan`
// leaves (comments etc.) whose spans are absolute into the host source.
doc::arena_print_doc_with_indent_resolved_into(
self.d(),
script_doc_id,
&embed,
TAB_WIDTH, // start column = 1 tab's visual width
1, // start indent level = 1 (accounts for Svelte wrapper)
self.source(),
&mut output,
);
// Only write content if there is any (skip indent for empty scripts)
// The output always ends with a hardline (\n), so non-empty content is at least "\n"
// For empty content (just comments or empty body), output is just "\n"
if !output.trim().is_empty() {
// Write first line's indent manually (doc only indents after hardlines)
self.indent_level += 1;
self.write_indent();
self.indent_level -= 1;
self.write(&output);
}
self.d().park_render_scratch(output);
// Closing tag
self.write("</script>\n");
}
/// Format a Style tag
///
/// Formats the `<style>` tag with its CSS content.
/// The CSS is formatted using the CSS printer with indentation.
/// For non-CSS languages (less, scss, etc.), content is preserved raw.
pub(super) fn print_style(&mut self, style: &ast::Style<'_>) {
// Check if there was any original content (including whitespace)
let had_content = style.content_span.start != style.content_span.end;
// Opening tag with doc-based attribute wrapping
self.write_section_opening_tag("style", style.attributes, had_content);
// A frozen-language body freezes: the author's bytes ride out verbatim between the
// two delimiter lines — the same shape the `<script>` and `<template>` freezes give
// their bodies, and prettier's `preformattedBody`. The shared gate reads the DECODED
// value (`lang="css"` is css) — see `ast::EmbeddedLang`.
if ast::EmbeddedLang::Style.is_frozen(style.attributes, self.source) {
return self.write_frozen_section("style", style.content_span);
}
// Format CSS content if present (nodes or comments)
if !style.css_stylesheet.nodes.is_empty() || !style.css_stylesheet.comments.is_empty() {
self.write("\n");
// Pass the entire source to the CSS printer (CSS node spans are
// absolute, which keeps blank-line detection correct) and share the
// printer's whole-source line_breaks table rather than rebuilding it
// per <style> island. `base_indent_offset` is the wrapper indent level
// the CSS renders at directly (the CSS printer folds it into its
// starting `indent_level`), so the result needs no post-hoc re-indent.
let embed = tsv_lang::EmbedContext {
base_indent_offset: self.indent_level + 1,
..tsv_lang::EmbedContext::default()
};
// Share the host document's doc arena rather than allocating a fresh
// whole-host-sized one per `<style>` block. The embedded CSS renders
// to its own string; only doc-node storage is shared, and the arena is
// not reset, so the host's in-flight nodes stay valid.
let formatted_css = tsv_css::__private::format_embedded_in(
&style.css_stylesheet,
self.source(),
self.line_table(),
embed,
self.css_host_scan(),
self.d(),
);
// The CSS is already at its final indentation, so it's written
// verbatim — no per-line re-indent. The old post-hoc re-indenter had to
// special-case verbatim content (block-comment interiors) because it
// otherwise compounded a preserved newline one indent level per format
// pass; rendering at the wrapper level upfront removes that hazard
// uniformly for every verbatim form (raw at-rule preludes, comment
// interiors, `Invalid` selector text). Trim the CSS's own trailing
// newline; the closing tag supplies its line break.
self.write(formatted_css.trim_end());
self.write("\n");
} else if had_content {
// Preserve block structure when original had whitespace-only content
self.write("\n");
}
// Closing tag
self.write("</style>\n");
}
/// Build the attribute-list doc for the one head this applies to: `<script>` / `<style>`.
///
/// Returns `[line, attr1, line, attr2, …]` — un-indented, so the caller can ask
/// [`will_break`](tsv_lang::doc::arena::DocArena::will_break) of it before wrapping it,
/// the same way `print_svelte_options` asks of its own list. Two builders, one question,
/// one way of asking it: accumulating a `has_multiline` flag here instead answered the
/// same thing by a second route, and a second route is a second thing to keep in step.
///
/// ⚠️ **Comment-blind by construction** — it walks `attributes` and never probes the gaps
/// between them, so a comment in such a head would be dropped. That is sound *here and
/// only here*: a top-level `<script>` / `<style>` head is read by Svelte's
/// `read_static_attribute`, which has no comment loop, so a `/* c */` in it makes the
/// required `>` missing and the component is **rejected** — the shape never reaches this
/// builder. Every other tag head, including `<svelte:options>`, goes through
/// `Printer::push_attrs_with_comments`, which probes the gaps. Do not reach for this one
/// from a head whose attributes come from the ordinary `read_attribute`.
fn build_section_attrs_doc(&self, attributes: &[ast::AttributeNode<'_>]) -> DocId {
let d = self.d();
let mut parts: DocBuf = DocBuf::with_capacity(attributes.len() * 2);
for attr in attributes {
parts.push(d.line());
parts.push(self.build_attribute_node_doc(attr, false));
}
d.concat(&parts)
}
/// Build and render an opening tag for `<script>` or `<style>` with doc-based
/// attribute wrapping. Attributes wrap when any value contains embedded newlines
/// or the total line width exceeds print_width.
///
/// `has_content`: when false, the closing tag follows on the same line
/// (e.g., `<script lang="ts"></script>`), so the render reserves its width on the
/// tag's last line.
///
/// Flat: `<tag attr1 attr2>`
/// Break: `<tag\n\tattr1\n\tattr2\n>`
fn write_section_opening_tag(
&mut self,
tag_name: &str,
attributes: &[ast::AttributeNode<'_>],
has_content: bool,
) {
if attributes.is_empty() {
self.write("<");
self.write(tag_name);
self.write(">");
return;
}
let d = self.d();
let attrs = self.build_section_attrs_doc(attributes);
let has_multiline = d.will_break(attrs);
let attr_indent = d.indent(attrs);
let softline = d.softline();
let tag_text = {
let mut w = d.pool_writer();
w.push('<');
w.push_str(tag_name);
w.finish_text()
};
let inner = d.concat(&[tag_text, attr_indent, softline, d.text(">")]);
let group = if has_multiline {
d.group_break(inner)
} else {
d.group(inner)
};
// When empty (no content), the closing tag follows on the same line as the tag's
// `>`: `<script lang="ts"></script>`. A measurement-only trailing node (it renders
// nothing) reserves its width, so the group breaks when that whole line exceeds
// print_width.
let doc = if has_content {
group
} else {
// "</tag>" = 3 + tag_name.len()
let closing_tag_width = 3 + tag_name.len();
let marker = d.with_context(d.empty(), DocContext::reserving(closing_tag_width));
d.concat(&[group, marker])
};
let col = self.buffer.current_column(TAB_WIDTH);
// Render into the arena-parked scratch (one warm buffer, no per-tag alloc).
let mut output = self.arena.take_render_scratch();
doc::arena_print_doc_with_indent_resolved_preserve_whitespace_into(
self.arena,
doc,
&self.embed,
col,
self.indent_level,
self.source(),
&mut output,
);
self.write(&output);
self.arena.park_render_scratch(output);
}
}