Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Codec for article slugs #128

Merged
merged 3 commits into from
Mar 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Api/Endpoint.purs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ import Conduit.Data.Username (Username)
import Data.Generic.Rep (class Generic)
import Data.Lens.Iso.Newtype (_Newtype)
import Data.Maybe (Maybe(..))
import Data.Slug (Slug)
import Routing.Duplex (RouteDuplex', int, optional, prefix, root, segment, string)
import Routing.Duplex.Generic (noArgs, sum)
import Routing.Duplex.Generic.Syntax ((/), (?))
import Slug (Slug)

-- | First, let's define a few types necessary for our larger `Endpoint` type.

Expand Down
2 changes: 1 addition & 1 deletion src/Capability/Resource/Article.purs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import Conduit.Api.Endpoint (ArticleParams, Pagination)
import Conduit.Data.Article (Article, ArticleWithMetadata)
import Conduit.Data.PaginatedArray (PaginatedArray)
import Data.Maybe (Maybe)
import Data.Slug (Slug)
import Halogen (HalogenM, lift)
import Slug (Slug)

-- | This capability represents the ability to manage articles in our system. Each function
-- | represents a simple process to read, write, update, delete, favorite, or take some other action
Expand Down
2 changes: 1 addition & 1 deletion src/Capability/Resource/Comment.purs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import Prelude

import Conduit.Data.Comment (Comment, CommentId, CommentWithMetadata)
import Data.Maybe (Maybe)
import Data.Slug (Slug)
import Halogen (HalogenM, lift)
import Slug (Slug)

-- | This capability represents the ability to manage comments in our system. Currently we only
-- | need to get all comments, create a comment, or delete a comment, all of which must be
Expand Down
2 changes: 1 addition & 1 deletion src/Component/Part/FavoriteButton.purs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import Conduit.Data.Article (ArticleWithMetadata)
import Data.Foldable (for_)
import Data.Lens (Traversal', preview, set)
import Data.Maybe (Maybe)
import Data.Slug (Slug)
import Halogen as H
import Halogen.HTML as HH
import Halogen.HTML.Events as HE
import Slug (Slug)

-- A simple way to control button sizes

Expand Down
4 changes: 2 additions & 2 deletions src/Data/Article.purs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import Data.Codec.Argonaut.Migration as CAM
import Data.Codec.Argonaut.Record as CAR
import Data.Maybe (Maybe)
import Data.PreciseDateTime (PreciseDateTime)
import Slug (Slug)
import Slug as Slug
import Data.Slug (Slug)
import Data.Slug as Slug
import Type.Row (type (+))

-- | First, we'll describe the core fields that are always present when we have an `Article`. In
Expand Down
4 changes: 2 additions & 2 deletions src/Data/Route.purs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ import Conduit.Data.Username (Username)
import Conduit.Data.Username as Username
import Data.Either (note)
import Data.Generic.Rep (class Generic)
import Data.Slug (Slug)
import Data.Slug as Slug
import Routing.Duplex (RouteDuplex', as, root, segment)
import Routing.Duplex.Generic (noArgs, sum)
import Routing.Duplex.Generic.Syntax ((/))
import Slug (Slug)
import Slug as Slug

-- | We'll represent routes in our application with a simple sum type. As the application grows,
-- | you might want to swap this out with an extensible sum type with `Variant` and have several
Expand Down
120 changes: 120 additions & 0 deletions src/Data/Slug.purs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
module Data.Slug
( Slug
, generate
, parse
, toString
, truncate
) where

import Prelude

import Data.Argonaut.Decode (class DecodeJson, JsonDecodeError(..), decodeJson)
import Data.Argonaut.Encode (class EncodeJson, encodeJson)
import Data.Array as Array
import Data.CodePoint.Unicode (isLatin1)
import Data.Either (note)
import Data.Maybe (Maybe(..))
import Data.String as String
import Data.String.CodePoints (codePointFromChar, fromCodePointArray, toCodePointArray)
import Data.String.Pattern (Pattern(..), Replacement(..))

-- | A `Slug` represents a string value which is guaranteed to have the
-- | following qualities:
-- |
-- | - it is not empty
-- | - it consists of groups of characters separated by `-` dashes,
-- | where the slug cannot begin or end with a dash, and there can
-- | never be two dashes in a row.
-- |
-- | Example: `Slug "this-is-an-article-slug"`
newtype Slug = Slug String
Copy link
Contributor Author

@pete-murphy pete-murphy Mar 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was copied from https://github.com/thomashoneyman/purescript-slug/blob/0142716b49758829d5b48244fe1e9f87ea935b77/src/Slug.purs#L21-L31, removing the following guarantees: "every character with a defined notion of case is lower-cased" and "it consists of alphanumeric groups of characters".


derive newtype instance Eq Slug
derive newtype instance Ord Slug
derive newtype instance Semigroup Slug

instance Show Slug where
show (Slug str) = "(Slug " <> str <> ")"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should ditch these instances since they aren’t used here (I generally don’t use Show, and we use codecs instead of JSON instances)

Copy link
Contributor Author

@pete-murphy pete-murphy Mar 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the *Json, Show, and Semigroup instances (leaving Eq and Ord since those are used in instance ordRoute :: Ord Route).


instance EncodeJson Slug where
encodeJson (Slug s) = encodeJson s

instance DecodeJson Slug where
decodeJson = note (TypeMismatch "Slug") <<< parse <=< decodeJson

-- | Create a `Slug` from a string. This will transform the input string
-- | to be a valid slug (if it is possible to do so) by separating words
-- | with `-` dashes, ensuring the string does not begin or end with a
-- | dash, and ensuring there are never two dashes in a row.
-- |
-- | Slugs are usually created for article titles and other resources
-- | which need a human-readable resource name in a URL.
-- |
-- | ```purescript
-- | > Slug.generate "My article title!"
-- | > Just (Slug "My-article-title")
-- |
-- | > Slug.generate "¬¬¬{}¬¬¬"
-- | > Nothing
-- | ```
generate :: String -> Maybe Slug
generate s = do
let arr = words $ onlyLatin1 $ stripApostrophes s
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if Array.null arr then
Nothing
else
Just $ Slug $ String.joinWith "-" arr
where
-- Strip apostrophes to avoid unnecessary word breaks
stripApostrophes = String.replaceAll (Pattern "'") (Replacement "")

-- Replace non-Latin 1 characters with spaces to be stripped later.
onlyLatin1 =
fromCodePointArray
<<< map (\x -> if isLatin1 x then x else codePointFromChar ' ')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<<< toCodePointArray

-- Split on whitespace
words = Array.filter (not String.null) <<< String.split (Pattern " ")

-- | Parse a valid slug (as a string) into a `Slug`. This will fail if the
-- | string is not a valid slug and does not provide the same behavior as
-- | `generate`.
-- |
-- | ```purescript
-- | > Slug.parse "my-article-title"
-- | > Just (Slug "my-article-title")
-- |
-- | > Slug.parse "My article"
-- | > Nothing
-- | ```
parse :: String -> Maybe Slug
parse str = generate str >>= check
where
check slug@(Slug s)
| s == str = Just slug
| otherwise = Nothing

-- | Unwrap a `Slug` into the string contained within, without performing
-- | any transformations.
-- |
-- | ```purescript
-- | > Slug.toString (mySlug :: Slug)
-- | > "my-slug-i-generated"
-- | ```
toString :: Slug -> String
toString (Slug s) = s

-- | Ensure a `Slug` is no longer than a given number of characters. If the last
-- | character is a dash, it will also be removed. Providing a non-positive
-- | number as the length will return `Nothing`.
-- |
-- | ```purescript
-- | > Slug.create "My article title is long!" >>= Slug.truncate 3
-- | > Just (Slug "My")
-- | ```
truncate :: Int -> Slug -> Maybe Slug
truncate n (Slug s)
| n < 1 = Nothing
| n >= String.length s = Just (Slug s)
| otherwise = generate $ String.take n s
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| otherwise = generate $ String.take n s
| otherwise = generate $ String.take n s

2 changes: 1 addition & 1 deletion src/Page/Editor.purs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import Data.Either (either)
import Data.Foldable (for_)
import Data.Maybe (Maybe(..))
import Data.Set as Set
import Data.Slug (Slug)
import Effect.Aff.Class (class MonadAff)
import Formless as F
import Halogen as H
Expand All @@ -34,7 +35,6 @@ import Halogen.Store.Monad (class MonadStore)
import Halogen.Store.Select (selectEq)
import Network.RemoteData (RemoteData(..), fromMaybe, toMaybe)
import Safe.Coerce (coerce)
import Slug (Slug)
import Type.Proxy (Proxy(..))

type Input = Maybe Slug
Expand Down
2 changes: 1 addition & 1 deletion src/Page/ViewArticle.purs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import Data.Lens (Traversal', preview)
import Data.Lens.Record (prop)
import Data.Maybe (Maybe(..))
import Data.Maybe as Maybe
import Data.Slug (Slug)
import Effect.Aff.Class (class MonadAff)
import Halogen as H
import Halogen.HTML as HH
Expand All @@ -39,7 +40,6 @@ import Halogen.Store.Connect (Connected, connect)
import Halogen.Store.Monad (class MonadStore)
import Halogen.Store.Select (selectEq)
import Network.RemoteData (RemoteData(..), _Success, fromMaybe)
import Slug (Slug)
import Type.Proxy (Proxy(..))
import Web.Event.Event (Event)
import Web.Event.Event as Event
Expand Down
Loading