1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6use use_js_identifier::{JsIdentifier, JsIdentifierError};
7
8#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct SvelteComponentName(String);
11
12impl SvelteComponentName {
13 pub fn new(input: &str) -> Result<Self, SvelteNameError> {
19 validate_pascal_case(input).map(Self)
20 }
21
22 #[must_use]
24 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27}
28
29impl fmt::Display for SvelteComponentName {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 formatter.write_str(self.as_str())
32 }
33}
34
35impl FromStr for SvelteComponentName {
36 type Err = SvelteNameError;
37
38 fn from_str(input: &str) -> Result<Self, Self::Err> {
39 Self::new(input)
40 }
41}
42
43impl TryFrom<&str> for SvelteComponentName {
44 type Error = SvelteNameError;
45
46 fn try_from(value: &str) -> Result<Self, Self::Error> {
47 Self::new(value)
48 }
49}
50
51#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
53pub struct SvelteDirectiveName(String);
54
55impl SvelteDirectiveName {
56 pub fn new(input: &str) -> Result<Self, SvelteNameError> {
62 let trimmed = input.trim();
63 if trimmed.is_empty() {
64 return Err(SvelteNameError::Empty);
65 }
66 if trimmed.chars().any(char::is_whitespace) {
67 return Err(SvelteNameError::ContainsWhitespace);
68 }
69 if !trimmed.chars().all(is_directive_character) || trimmed.split(':').any(str::is_empty) {
70 return Err(SvelteNameError::InvalidDirective);
71 }
72 Ok(Self(trimmed.to_string()))
73 }
74
75 #[must_use]
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80}
81
82impl fmt::Display for SvelteDirectiveName {
83 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84 formatter.write_str(self.as_str())
85 }
86}
87
88impl FromStr for SvelteDirectiveName {
89 type Err = SvelteNameError;
90
91 fn from_str(input: &str) -> Result<Self, Self::Err> {
92 Self::new(input)
93 }
94}
95
96impl TryFrom<&str> for SvelteDirectiveName {
97 type Error = SvelteNameError;
98
99 fn try_from(value: &str) -> Result<Self, Self::Error> {
100 Self::new(value)
101 }
102}
103
104#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
106pub enum SvelteFileKind {
107 Component,
108 Page,
109 Layout,
110 Error,
111 Server,
112 Config,
113}
114
115impl SvelteFileKind {
116 #[must_use]
118 pub const fn as_str(self) -> &'static str {
119 match self {
120 Self::Component => "component",
121 Self::Page => "page",
122 Self::Layout => "layout",
123 Self::Error => "error",
124 Self::Server => "server",
125 Self::Config => "config",
126 }
127 }
128}
129
130impl fmt::Display for SvelteFileKind {
131 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132 formatter.write_str(self.as_str())
133 }
134}
135
136impl FromStr for SvelteFileKind {
137 type Err = SvelteNameError;
138
139 fn from_str(input: &str) -> Result<Self, Self::Err> {
140 match normalized_label(input)?.as_str() {
141 "component" => Ok(Self::Component),
142 "page" => Ok(Self::Page),
143 "layout" => Ok(Self::Layout),
144 "error" => Ok(Self::Error),
145 "server" => Ok(Self::Server),
146 "config" => Ok(Self::Config),
147 _ => Err(SvelteNameError::UnknownLabel),
148 }
149 }
150}
151
152#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
154pub enum SvelteKitDirectoryKind {
155 Routes,
156 Lib,
157 Static,
158 Params,
159 Hooks,
160 Server,
161}
162
163impl SvelteKitDirectoryKind {
164 #[must_use]
166 pub const fn as_str(self) -> &'static str {
167 match self {
168 Self::Routes => "routes",
169 Self::Lib => "lib",
170 Self::Static => "static",
171 Self::Params => "params",
172 Self::Hooks => "hooks",
173 Self::Server => "server",
174 }
175 }
176}
177
178impl fmt::Display for SvelteKitDirectoryKind {
179 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
180 formatter.write_str(self.as_str())
181 }
182}
183
184impl FromStr for SvelteKitDirectoryKind {
185 type Err = SvelteNameError;
186
187 fn from_str(input: &str) -> Result<Self, Self::Err> {
188 match normalized_label(input)?.as_str() {
189 "routes" => Ok(Self::Routes),
190 "lib" => Ok(Self::Lib),
191 "static" => Ok(Self::Static),
192 "params" => Ok(Self::Params),
193 "hooks" => Ok(Self::Hooks),
194 "server" => Ok(Self::Server),
195 _ => Err(SvelteNameError::UnknownLabel),
196 }
197 }
198}
199
200#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
202pub enum SvelteKitRenderingMode {
203 Ssr,
204 Spa,
205 Static,
206 Hybrid,
207}
208
209impl SvelteKitRenderingMode {
210 #[must_use]
212 pub const fn as_str(self) -> &'static str {
213 match self {
214 Self::Ssr => "ssr",
215 Self::Spa => "spa",
216 Self::Static => "static",
217 Self::Hybrid => "hybrid",
218 }
219 }
220}
221
222impl fmt::Display for SvelteKitRenderingMode {
223 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224 formatter.write_str(self.as_str())
225 }
226}
227
228impl FromStr for SvelteKitRenderingMode {
229 type Err = SvelteNameError;
230
231 fn from_str(input: &str) -> Result<Self, Self::Err> {
232 match normalized_label(input)?.as_str() {
233 "ssr" => Ok(Self::Ssr),
234 "spa" => Ok(Self::Spa),
235 "static" => Ok(Self::Static),
236 "hybrid" => Ok(Self::Hybrid),
237 _ => Err(SvelteNameError::UnknownLabel),
238 }
239 }
240}
241
242#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
244pub enum SvelteConfigFile {
245 SvelteConfigJs,
246 SvelteConfigTs,
247}
248
249impl SvelteConfigFile {
250 #[must_use]
252 pub const fn as_str(self) -> &'static str {
253 match self {
254 Self::SvelteConfigJs => "svelte.config.js",
255 Self::SvelteConfigTs => "svelte.config.ts",
256 }
257 }
258}
259
260impl fmt::Display for SvelteConfigFile {
261 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
262 formatter.write_str(self.as_str())
263 }
264}
265
266impl FromStr for SvelteConfigFile {
267 type Err = SvelteNameError;
268
269 fn from_str(input: &str) -> Result<Self, Self::Err> {
270 match normalized_label(input)?.as_str() {
271 "svelteconfigjs" | "svelte.config.js" => Ok(Self::SvelteConfigJs),
272 "svelteconfigts" | "svelte.config.ts" => Ok(Self::SvelteConfigTs),
273 _ => Err(SvelteNameError::UnknownLabel),
274 }
275 }
276}
277
278#[derive(Clone, Debug, Eq, PartialEq)]
280pub enum SvelteNameError {
281 Empty,
282 ContainsWhitespace,
283 Identifier(JsIdentifierError),
284 NotPascalCase,
285 InvalidDirective,
286 UnknownLabel,
287}
288
289impl fmt::Display for SvelteNameError {
290 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291 match self {
292 Self::Empty => formatter.write_str("Svelte metadata text cannot be empty"),
293 Self::ContainsWhitespace => {
294 formatter.write_str("Svelte metadata text cannot contain whitespace")
295 }
296 Self::Identifier(error) => write!(formatter, "{error}"),
297 Self::NotPascalCase => {
298 formatter.write_str("Svelte component name must be `PascalCase`-shaped")
299 }
300 Self::InvalidDirective => formatter.write_str("invalid Svelte directive name"),
301 Self::UnknownLabel => formatter.write_str("unknown Svelte metadata label"),
302 }
303 }
304}
305
306impl Error for SvelteNameError {
307 fn source(&self) -> Option<&(dyn Error + 'static)> {
308 match self {
309 Self::Identifier(error) => Some(error),
310 Self::Empty
311 | Self::ContainsWhitespace
312 | Self::NotPascalCase
313 | Self::InvalidDirective
314 | Self::UnknownLabel => None,
315 }
316 }
317}
318
319fn validate_pascal_case(input: &str) -> Result<String, SvelteNameError> {
320 let identifier = JsIdentifier::new(input).map_err(SvelteNameError::Identifier)?;
321 if !identifier
322 .as_str()
323 .chars()
324 .next()
325 .is_some_and(|character| character.is_ascii_uppercase())
326 {
327 return Err(SvelteNameError::NotPascalCase);
328 }
329 Ok(identifier.as_str().to_string())
330}
331
332const fn is_directive_character(character: char) -> bool {
333 character.is_ascii_alphanumeric() || matches!(character, ':' | '_' | '-')
334}
335
336fn normalized_label(input: &str) -> Result<String, SvelteNameError> {
337 let trimmed = input.trim();
338 if trimmed.is_empty() {
339 return Err(SvelteNameError::Empty);
340 }
341 Ok(trimmed
342 .chars()
343 .filter(|character| !matches!(character, '-' | '_' | ' '))
344 .flat_map(char::to_lowercase)
345 .collect())
346}
347
348#[cfg(test)]
349mod tests {
350 use super::{
351 SvelteComponentName, SvelteConfigFile, SvelteDirectiveName, SvelteFileKind,
352 SvelteKitDirectoryKind, SvelteKitRenderingMode, SvelteNameError,
353 };
354
355 #[test]
356 fn validates_component_names() -> Result<(), SvelteNameError> {
357 let component = SvelteComponentName::new("AppShell")?;
358 assert_eq!(component.as_str(), "AppShell");
359 assert_eq!(
360 SvelteComponentName::new("appShell"),
361 Err(SvelteNameError::NotPascalCase)
362 );
363 assert!(SvelteComponentName::new("app-shell").is_err());
364 Ok(())
365 }
366
367 #[test]
368 fn validates_directive_names() -> Result<(), SvelteNameError> {
369 let directive = SvelteDirectiveName::new("on:click")?;
370 assert_eq!(directive.as_str(), "on:click");
371 assert_eq!(SvelteDirectiveName::new(""), Err(SvelteNameError::Empty));
372 assert_eq!(
373 SvelteDirectiveName::new("on click"),
374 Err(SvelteNameError::ContainsWhitespace)
375 );
376 assert_eq!(
377 SvelteDirectiveName::new("on:"),
378 Err(SvelteNameError::InvalidDirective)
379 );
380 Ok(())
381 }
382
383 #[test]
384 fn parses_labels() -> Result<(), SvelteNameError> {
385 assert_eq!("page".parse::<SvelteFileKind>()?, SvelteFileKind::Page);
386 assert_eq!(
387 "routes".parse::<SvelteKitDirectoryKind>()?,
388 SvelteKitDirectoryKind::Routes
389 );
390 assert_eq!(
391 "ssr".parse::<SvelteKitRenderingMode>()?,
392 SvelteKitRenderingMode::Ssr
393 );
394 assert_eq!(
395 "svelte.config.ts".parse::<SvelteConfigFile>()?,
396 SvelteConfigFile::SvelteConfigTs
397 );
398 assert_eq!(SvelteKitRenderingMode::Hybrid.to_string(), "hybrid");
399 Ok(())
400 }
401}