1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum RemixVersionFamily {
10 Remix1,
11 Remix2,
12}
13
14impl RemixVersionFamily {
15 #[must_use]
17 pub const fn as_str(self) -> &'static str {
18 match self {
19 Self::Remix1 => "remix1",
20 Self::Remix2 => "remix2",
21 }
22 }
23}
24
25impl fmt::Display for RemixVersionFamily {
26 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27 formatter.write_str(self.as_str())
28 }
29}
30
31impl FromStr for RemixVersionFamily {
32 type Err = RemixNameError;
33
34 fn from_str(input: &str) -> Result<Self, Self::Err> {
35 match normalized_label(input)?.as_str() {
36 "remix1" | "1" => Ok(Self::Remix1),
37 "remix2" | "2" => Ok(Self::Remix2),
38 _ => Err(RemixNameError::UnknownLabel),
39 }
40 }
41}
42
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub enum RemixRouteKind {
46 PageRoute,
47 ResourceRoute,
48 LayoutRoute,
49 IndexRoute,
50 PathlessLayoutRoute,
51}
52
53impl RemixRouteKind {
54 #[must_use]
56 pub const fn as_str(self) -> &'static str {
57 match self {
58 Self::PageRoute => "page-route",
59 Self::ResourceRoute => "resource-route",
60 Self::LayoutRoute => "layout-route",
61 Self::IndexRoute => "index-route",
62 Self::PathlessLayoutRoute => "pathless-layout-route",
63 }
64 }
65
66 #[must_use]
68 pub const fn is_index_route(self) -> bool {
69 matches!(self, Self::IndexRoute)
70 }
71
72 #[must_use]
74 pub const fn is_resource_route(self) -> bool {
75 matches!(self, Self::ResourceRoute)
76 }
77
78 #[must_use]
80 pub const fn is_layout_route(self) -> bool {
81 matches!(self, Self::LayoutRoute | Self::PathlessLayoutRoute)
82 }
83}
84
85impl fmt::Display for RemixRouteKind {
86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87 formatter.write_str(self.as_str())
88 }
89}
90
91impl FromStr for RemixRouteKind {
92 type Err = RemixNameError;
93
94 fn from_str(input: &str) -> Result<Self, Self::Err> {
95 match normalized_label(input)?.as_str() {
96 "pageroute" | "page" => Ok(Self::PageRoute),
97 "resourceroute" | "resource" => Ok(Self::ResourceRoute),
98 "layoutroute" | "layout" => Ok(Self::LayoutRoute),
99 "indexroute" | "index" => Ok(Self::IndexRoute),
100 "pathlesslayoutroute" | "pathlesslayout" => Ok(Self::PathlessLayoutRoute),
101 _ => Err(RemixNameError::UnknownLabel),
102 }
103 }
104}
105
106#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub enum RemixFileKind {
109 Route,
110 Root,
111 EntryClient,
112 EntryServer,
113 Config,
114 Styles,
115 ErrorBoundary,
116}
117
118impl RemixFileKind {
119 #[must_use]
121 pub const fn as_str(self) -> &'static str {
122 match self {
123 Self::Route => "route",
124 Self::Root => "root",
125 Self::EntryClient => "entry-client",
126 Self::EntryServer => "entry-server",
127 Self::Config => "config",
128 Self::Styles => "styles",
129 Self::ErrorBoundary => "error-boundary",
130 }
131 }
132}
133
134impl fmt::Display for RemixFileKind {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 formatter.write_str(self.as_str())
137 }
138}
139
140impl FromStr for RemixFileKind {
141 type Err = RemixNameError;
142
143 fn from_str(input: &str) -> Result<Self, Self::Err> {
144 match normalized_label(input)?.as_str() {
145 "route" => Ok(Self::Route),
146 "root" => Ok(Self::Root),
147 "entryclient" => Ok(Self::EntryClient),
148 "entryserver" => Ok(Self::EntryServer),
149 "config" => Ok(Self::Config),
150 "styles" | "style" => Ok(Self::Styles),
151 "errorboundary" => Ok(Self::ErrorBoundary),
152 _ => Err(RemixNameError::UnknownLabel),
153 }
154 }
155}
156
157#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
159pub enum RemixDirectoryKind {
160 App,
161 Routes,
162 Components,
163 Styles,
164 Public,
165 Server,
166}
167
168impl RemixDirectoryKind {
169 #[must_use]
171 pub const fn as_str(self) -> &'static str {
172 match self {
173 Self::App => "app",
174 Self::Routes => "routes",
175 Self::Components => "components",
176 Self::Styles => "styles",
177 Self::Public => "public",
178 Self::Server => "server",
179 }
180 }
181}
182
183impl fmt::Display for RemixDirectoryKind {
184 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185 formatter.write_str(self.as_str())
186 }
187}
188
189impl FromStr for RemixDirectoryKind {
190 type Err = RemixNameError;
191
192 fn from_str(input: &str) -> Result<Self, Self::Err> {
193 match normalized_label(input)?.as_str() {
194 "app" => Ok(Self::App),
195 "routes" => Ok(Self::Routes),
196 "components" => Ok(Self::Components),
197 "styles" | "style" => Ok(Self::Styles),
198 "public" => Ok(Self::Public),
199 "server" => Ok(Self::Server),
200 _ => Err(RemixNameError::UnknownLabel),
201 }
202 }
203}
204
205#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
207pub enum RemixRenderingMode {
208 Ssr,
209 Spa,
210 Static,
211 Hybrid,
212}
213
214impl RemixRenderingMode {
215 #[must_use]
217 pub const fn as_str(self) -> &'static str {
218 match self {
219 Self::Ssr => "ssr",
220 Self::Spa => "spa",
221 Self::Static => "static",
222 Self::Hybrid => "hybrid",
223 }
224 }
225}
226
227impl fmt::Display for RemixRenderingMode {
228 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229 formatter.write_str(self.as_str())
230 }
231}
232
233impl FromStr for RemixRenderingMode {
234 type Err = RemixNameError;
235
236 fn from_str(input: &str) -> Result<Self, Self::Err> {
237 match normalized_label(input)?.as_str() {
238 "ssr" => Ok(Self::Ssr),
239 "spa" => Ok(Self::Spa),
240 "static" => Ok(Self::Static),
241 "hybrid" => Ok(Self::Hybrid),
242 _ => Err(RemixNameError::UnknownLabel),
243 }
244 }
245}
246
247#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
249pub enum RemixConfigFile {
250 RemixConfigJs,
251 RemixConfigMjs,
252 ViteConfigTs,
253 ViteConfigJs,
254}
255
256impl RemixConfigFile {
257 #[must_use]
259 pub const fn as_str(self) -> &'static str {
260 match self {
261 Self::RemixConfigJs => "remix.config.js",
262 Self::RemixConfigMjs => "remix.config.mjs",
263 Self::ViteConfigTs => "vite.config.ts",
264 Self::ViteConfigJs => "vite.config.js",
265 }
266 }
267}
268
269impl fmt::Display for RemixConfigFile {
270 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
271 formatter.write_str(self.as_str())
272 }
273}
274
275impl FromStr for RemixConfigFile {
276 type Err = RemixNameError;
277
278 fn from_str(input: &str) -> Result<Self, Self::Err> {
279 match normalized_label(input)?.as_str() {
280 "remixconfigjs" => Ok(Self::RemixConfigJs),
281 "remixconfigmjs" => Ok(Self::RemixConfigMjs),
282 "viteconfigts" => Ok(Self::ViteConfigTs),
283 "viteconfigjs" => Ok(Self::ViteConfigJs),
284 _ => Err(RemixNameError::UnknownLabel),
285 }
286 }
287}
288
289#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
291pub enum RemixDataFunctionKind {
292 Loader,
293 Action,
294 ClientLoader,
295 ClientAction,
296}
297
298impl RemixDataFunctionKind {
299 #[must_use]
301 pub const fn as_str(self) -> &'static str {
302 match self {
303 Self::Loader => "loader",
304 Self::Action => "action",
305 Self::ClientLoader => "client-loader",
306 Self::ClientAction => "client-action",
307 }
308 }
309}
310
311impl fmt::Display for RemixDataFunctionKind {
312 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
313 formatter.write_str(self.as_str())
314 }
315}
316
317impl FromStr for RemixDataFunctionKind {
318 type Err = RemixNameError;
319
320 fn from_str(input: &str) -> Result<Self, Self::Err> {
321 match normalized_label(input)?.as_str() {
322 "loader" => Ok(Self::Loader),
323 "action" => Ok(Self::Action),
324 "clientloader" => Ok(Self::ClientLoader),
325 "clientaction" => Ok(Self::ClientAction),
326 _ => Err(RemixNameError::UnknownLabel),
327 }
328 }
329}
330
331#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
333pub struct RemixRoutePath(String);
334
335impl RemixRoutePath {
336 pub fn new(input: &str) -> Result<Self, RemixNameError> {
342 validate_non_empty_text(input).map(Self)
343 }
344
345 #[must_use]
347 pub fn as_str(&self) -> &str {
348 &self.0
349 }
350}
351
352impl fmt::Display for RemixRoutePath {
353 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
354 formatter.write_str(self.as_str())
355 }
356}
357
358impl FromStr for RemixRoutePath {
359 type Err = RemixNameError;
360
361 fn from_str(input: &str) -> Result<Self, Self::Err> {
362 Self::new(input)
363 }
364}
365
366impl TryFrom<&str> for RemixRoutePath {
367 type Error = RemixNameError;
368
369 fn try_from(value: &str) -> Result<Self, Self::Error> {
370 Self::new(value)
371 }
372}
373
374#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
376pub struct RemixRouteFileName(String);
377
378impl RemixRouteFileName {
379 pub fn new(input: &str) -> Result<Self, RemixNameError> {
385 let trimmed = input.trim();
386 if trimmed.is_empty() {
387 return Err(RemixNameError::Empty);
388 }
389 if trimmed.chars().any(char::is_whitespace) {
390 return Err(RemixNameError::ContainsWhitespace);
391 }
392 if let Some(character) = trimmed
393 .chars()
394 .find(|character| !is_route_file_character(*character))
395 {
396 return Err(RemixNameError::InvalidCharacter { character });
397 }
398 Ok(Self(trimmed.to_string()))
399 }
400
401 #[must_use]
403 pub fn as_str(&self) -> &str {
404 &self.0
405 }
406}
407
408impl fmt::Display for RemixRouteFileName {
409 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
410 formatter.write_str(self.as_str())
411 }
412}
413
414impl FromStr for RemixRouteFileName {
415 type Err = RemixNameError;
416
417 fn from_str(input: &str) -> Result<Self, Self::Err> {
418 Self::new(input)
419 }
420}
421
422impl TryFrom<&str> for RemixRouteFileName {
423 type Error = RemixNameError;
424
425 fn try_from(value: &str) -> Result<Self, Self::Error> {
426 Self::new(value)
427 }
428}
429
430#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
432pub struct RemixResourceRouteName(String);
433
434impl RemixResourceRouteName {
435 pub fn new(input: &str) -> Result<Self, RemixNameError> {
441 validate_non_empty_text(input).map(Self)
442 }
443
444 #[must_use]
446 pub fn as_str(&self) -> &str {
447 &self.0
448 }
449}
450
451impl fmt::Display for RemixResourceRouteName {
452 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
453 formatter.write_str(self.as_str())
454 }
455}
456
457impl FromStr for RemixResourceRouteName {
458 type Err = RemixNameError;
459
460 fn from_str(input: &str) -> Result<Self, Self::Err> {
461 Self::new(input)
462 }
463}
464
465impl TryFrom<&str> for RemixResourceRouteName {
466 type Error = RemixNameError;
467
468 fn try_from(value: &str) -> Result<Self, Self::Error> {
469 Self::new(value)
470 }
471}
472
473#[derive(Clone, Copy, Debug, Eq, PartialEq)]
475pub enum RemixNameError {
476 Empty,
477 ContainsWhitespace,
478 InvalidCharacter { character: char },
479 UnknownLabel,
480}
481
482impl fmt::Display for RemixNameError {
483 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
484 match self {
485 Self::Empty => formatter.write_str("Remix metadata text cannot be empty"),
486 Self::ContainsWhitespace => {
487 formatter.write_str("Remix metadata text cannot contain whitespace")
488 }
489 Self::InvalidCharacter { character } => {
490 write!(formatter, "invalid Remix metadata character `{character}`")
491 }
492 Self::UnknownLabel => formatter.write_str("unknown Remix metadata label"),
493 }
494 }
495}
496
497impl Error for RemixNameError {}
498
499fn validate_non_empty_text(input: &str) -> Result<String, RemixNameError> {
500 let trimmed = input.trim();
501 if trimmed.is_empty() {
502 return Err(RemixNameError::Empty);
503 }
504 if let Some(character) = trimmed.chars().find(|character| character.is_control()) {
505 return Err(RemixNameError::InvalidCharacter { character });
506 }
507 Ok(trimmed.to_string())
508}
509
510const fn is_route_file_character(character: char) -> bool {
511 character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | '$')
512}
513
514fn normalized_label(input: &str) -> Result<String, RemixNameError> {
515 let trimmed = input.trim();
516 if trimmed.is_empty() {
517 return Err(RemixNameError::Empty);
518 }
519 Ok(trimmed
520 .chars()
521 .filter(|character| !matches!(character, '-' | '_' | ' ' | '.'))
522 .flat_map(char::to_lowercase)
523 .collect())
524}
525
526#[cfg(test)]
527mod tests {
528 use super::{
529 RemixConfigFile, RemixDataFunctionKind, RemixDirectoryKind, RemixFileKind, RemixNameError,
530 RemixRenderingMode, RemixResourceRouteName, RemixRouteFileName, RemixRouteKind,
531 RemixRoutePath, RemixVersionFamily,
532 };
533
534 #[test]
535 fn validates_route_paths() -> Result<(), RemixNameError> {
536 let route = RemixRoutePath::new("/products/$productId")?;
537 assert_eq!(route.as_str(), "/products/$productId");
538 assert_eq!(RemixRoutePath::new(""), Err(RemixNameError::Empty));
539 assert_eq!(
540 RemixRoutePath::new("/bad\nroute"),
541 Err(RemixNameError::InvalidCharacter { character: '\n' })
542 );
543 Ok(())
544 }
545
546 #[test]
547 fn validates_route_file_names() -> Result<(), RemixNameError> {
548 assert_eq!(
549 RemixRouteFileName::new("products.$id")?.as_str(),
550 "products.$id"
551 );
552 assert_eq!(RemixRouteFileName::new("_index")?.as_str(), "_index");
553 assert_eq!(
554 RemixRouteFileName::new("products id"),
555 Err(RemixNameError::ContainsWhitespace)
556 );
557 assert_eq!(
558 RemixRouteFileName::new("routes/products"),
559 Err(RemixNameError::InvalidCharacter { character: '/' })
560 );
561 Ok(())
562 }
563
564 #[test]
565 fn validates_resource_route_names() -> Result<(), RemixNameError> {
566 let resource = RemixResourceRouteName::new("sitemap.xml")?;
567 assert_eq!(resource.as_str(), "sitemap.xml");
568 assert_eq!(RemixResourceRouteName::new(""), Err(RemixNameError::Empty));
569 Ok(())
570 }
571
572 #[test]
573 fn route_kind_helpers_work() {
574 assert!(RemixRouteKind::IndexRoute.is_index_route());
575 assert!(RemixRouteKind::ResourceRoute.is_resource_route());
576 assert!(RemixRouteKind::LayoutRoute.is_layout_route());
577 assert!(RemixRouteKind::PathlessLayoutRoute.is_layout_route());
578 assert!(!RemixRouteKind::PageRoute.is_resource_route());
579 }
580
581 #[test]
582 fn parses_labels() -> Result<(), RemixNameError> {
583 assert_eq!(
584 "remix2".parse::<RemixVersionFamily>()?,
585 RemixVersionFamily::Remix2
586 );
587 assert_eq!(
588 "resource-route".parse::<RemixRouteKind>()?,
589 RemixRouteKind::ResourceRoute
590 );
591 assert_eq!(
592 "entry-client".parse::<RemixFileKind>()?,
593 RemixFileKind::EntryClient
594 );
595 assert_eq!(
596 "routes".parse::<RemixDirectoryKind>()?,
597 RemixDirectoryKind::Routes
598 );
599 assert_eq!(
600 "ssr".parse::<RemixRenderingMode>()?,
601 RemixRenderingMode::Ssr
602 );
603 assert_eq!(
604 "vite.config.ts".parse::<RemixConfigFile>()?,
605 RemixConfigFile::ViteConfigTs
606 );
607 assert_eq!(
608 "client-loader".parse::<RemixDataFunctionKind>()?,
609 RemixDataFunctionKind::ClientLoader
610 );
611 assert_eq!(RemixRouteKind::PageRoute.to_string(), "page-route");
612 Ok(())
613 }
614}