Making a blogging system with Phoenix and React [Part 1]
making-a-blogging-system-with-phoenix-and-react-[part-1]Welcome to this second part of the series.
If you haven't, you might want to read the intro. It will give you a good overview on what's happening here.
PSA: Saving and rendering HTML should only be done if:
A. You are/ you trust the source
B. You sanitize the content before rendering it.
Today, we're diving straight in by following this todo-list :
Setting up Milkdown (core and plugins) (You are here)
Scaffold the blog posts
Deal with image uploads
Create the API routes and functionalities
For the sake of simplifying the examples, I'll scaffold a basic Phoenix (1.7) app with mix phx.new mkdn --no-dashboard --no-mailer --database sqlite3
Setting up Milkdown
I won't go into much depth when it comes to adding node packages to a Phoenix app when the Phoenix Guide will be better at it.
Instead I'll go ahead and install all the packages I need in my assets
directory.
We need the following:
npm i @milkdown/core @milkdown/ctx @milkdown/plugin-clipboard @milkdown/plugin-listener @milkdown/preset-commonmark @milkdown/preset-gfm @milkdown/prose @milkdown/theme-nord
Okay that's a lot, but remember (from the intro) that Milkdown is bare-bones and requires plugins to add functionality.
Here's what we need each of them for :
First of all core
, ctx
and preset-commonmark
are required to get anything out of Milkdown.
Next, plugin-listener
will allow us to listen for change events on our Milkdown editor; preset-gfm
(gfm stands for "Github Flavored Markup") gives us extra formatting, plugin-clipboard
will enable us to copy paste our markdown from another program into our Milkdown editor and parse the text (I don't know about you but I have a fear of hitting <kbd>ctrl+w</kbd> in the browser and losing all progress)
Finally, theme-nord
will give us basic styling.
Okay, so. Our packages are installed, our Phoenix app is running. It is time to implement Milkdown.
For re-usability, it is going to live in a component. Since my usage demanded it, I modified the basic Phoenix core components textarea input to fit my needs.
In our app/lib/app_web/components/core_components.ex
, we're finding the textarea input and changing the following:
#app/lib/app_web/components/core_components.ex
def input(%{type: "textarea"} = assigns) do
~H"""
- <div phx-feedback-for={@name}>
+ <div phx-feedback-for={@name} phx-hook="MarkdownEditor" id={@id <> "-wrapper"}>
<.label for={@id}><%= @label %></.label>
- <textarea ... />
+ <div id={@id <> "-mkdown"}></div>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
We added a Phoenix hook named "MarkdownEditor" to our div and gave it an id, deleted the old textarea tag and added a div with an id of id-mkdown
. "For why?"
For the next step!
Let's create a markdown.js
in our assets/js/
folder. This is where the logic of our editor lives. For now it needs to handle basic things such as creating an editor where it is needed.
# app/assets/js/markdown.js
//Markdown editor
import {
Editor,
rootCtx,
defaultValueCtx,
} from "@milkdown/core";
import { commonmark } from "@milkdown/preset-commonmark";
import "@milkdown/theme-nord/style.css";
import { gfm } from "@milkdown/preset-gfm";
import { clipboard } from "@milkdown/plugin-clipboard";
/*
* Creates a Milkdown editor.
* @param {HTMLNode} - dom - the node or the selector of the element that needs to receive our editor
* @param {string} - defaultValue - The defaultValue, to be injected in the editor. Defaults to a blank string
*/
function makeEditor(dom, defaultValue = "") {
const MakeEditor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, dom);
ctx.set(defaultValueCtx, defaultValue);
})
.use(commonmark)
.use(gfm)
.use(clipboard)
.create();
}
export {makeEditor}
Here we define a function that will create a Milkdown editor on a DOM node, with a default value. This function is exported and used in our app.js
#app.js
// your other imports
import { makeEditor } from "./markdown";
// ... the rest of your code
let Hooks = {};
Hooks.MarkdownEditor = {
mounted() {
// We register a Milkdown editor for every textarea in our form.
// We also instantiate a value if there is one.
const mkdownId = this.el.children[2].id;
makeEditor(document.querySelector(`#${mkdownId}`), "");
},
};
We use the hook we defined in our component to tell our app.js what functions to run when our div is mounted.
For the moment, it creates a blank editor where a textarea should be. We can test this out by inserting a textarea
in an existing page.
We're adding this snippet of code to our mkdn_web/controllers/page_html/home.html.heex
:
<.input type="textarea" name="test" value="test"/>
The values we're giving the component do not matter, we just want to make sure our system works.
If you squint really hard, or if you use the inspector, you'll notice that the markdown is in fact here, it just has no style what-so-ever.
This is the issue that made me quit trying to get markdown to work when I first tried. I didn't look too much into it because I needed something that works like, yesterday.
So, where is the style ? After all we imported the CSS sheet and we told Markdown to use the nord theme. By all means it should show up, correct ? Wrong ! Since the default app that Phoenix gives you is bundled with Esbuild and Tailwind, any CSS you give it will get overwritten during asset deployment.
You can go have a look the stylesheet in mkdn/priv/static/assets/app.css
and see how nothing gets shipped except what's in your mkdn/assets/css/app.css
.
Yes, lazy voice in my head, we could copy and paste the entire nord theme into the entry app.css file, but that would not be elegant.
We could set the ignorePreflight
to true in our tailwind.config.js
but this will break your styling during development.
What I found to work the best is to simply tell tailwind to compile to its own tailwind.css
file in the static assets folder, and add a link to it in our root.html.heex
file.
Open your mkdn/config/config.exs
file, and change the contents of your tailwind config:
#mkdn/config/config.ex
# Configure tailwind (the version is required)
config :tailwind,
version: "3.4.3",
mkdn: [
args: ~w(
--config=tailwind.config.js
--input=css/tailwind.css
--output=../priv/static/assets/tailwind.css
),
cd: Path.expand("../assets", __DIR__)
]
Now either rename mkdn/assets/app.css
to mkdn/assets/tailwind.css
or create a new file called tailwind.css
and copy paste the contents.
Esbuild will now correctly bundle your style in priv/static/assets/app.css
and tailwind will keep to its tailwind.css
file, for both input and output.
Now that we have basic styling (tailwind will still override most of them, for ex none of your h1, h2, h3 etc will be styled), let's at least make the input look like the rest of our inputs.
That's easily done by adding the same classes when creating the markdown editors in our makeEditor
function in js/markdown.js
.
Modify it to this :
function makeEditor(dom, defaultValue = "") {
const MakeEditor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, dom);
ctx.update(editorViewOptionsCtx, (prev) => ({
...prev,
attributes: {
class:
"min-h-[128px] p-2 mt-2 block border w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 border-zinc-300 focus:border-zinc-400",
},
}));
ctx.set(defaultValueCtx, defaultValue);
})
.use(commonmark)
.use(gfm)
.use(clipboard)
.create();
}
These are the basic tailwind classes that Phoenix uses.
Good! Our markdown areas now look like our previous textareas. The next thing we need to do is actually make some blog posts.
We have done a lot so we'll take a quick break and scaffold our blog in the next post