gatsby-starter-ghost icon indicating copy to clipboard operation
gatsby-starter-ghost copied to clipboard

Include codeinjection for site and posts

Open aileen opened this issue 5 years ago • 6 comments

Problem description

codeinjection_head and codeinjection_foot for the site as well as for posts is currently not used in the starter. The reason for that is

a) it's a bit tricky with scripts in React (hence our dirty little script here b) code injection can contain almost anything, but most likely <script> or <style> tags, which we need to differentiate for reasons that I'll describe further down

  • For site codeinjection we could use a custom html.js but would need to have the Ghost settings available at that point already (needs https://github.com/TryGhost/gatsby-starter-ghost/issues/20)
  • For posts we would need to use React Helmet and can’t edit our html.js, as we need the codeinjection per post and not global and we’d prefer to add it to the <head> section, like we do in Ghost frontend → https://www.gatsbyjs.org/docs/custom-html/#react-helmet
  • Using React Helmet or html.js requires that we know already if it’s a <script>, <style>, or any other tag we can think of, as it needs to be specified → https://github.com/nfl/react-helmet#reference-guide
  • Injecting it the way we inject the body (dangerouslySetInnerHTML) of a post doesn’t work either, as it needs to be wrapped in a parent element, which then prevents any of the injected tags from being executed. Furthermore, we would have it in our <body> instead of the <head>

Proposal

Differentiate the codeinjection content

One possible solution is to create a parser util that differentiates between <script> and <style> tags and returns e. g. an Object with those values like this:

"script": ["<script>...</script>", "<script>...</script>"],
"style": ["<style>...</style>", "<style>...</style>"]

☝️ This is just one idea and open for discussions/suggestions!

Site codeinjection (settings.codeinjection_head & settings.codeinjection_foot) in html.js

The site codeinjection can be injected into the html.js file. 👉 https://www.gatsbyjs.org/docs/custom-html/ but there is - of course - a tricky part here too: the html.js generation happens before the schema generation and doesn't allow GraphQL queries. This relies therefore on the outcome of https://github.com/TryGhost/gatsby-starter-ghost/issues/20 is implemented, which is supposed to make those Ghost settings available on pre build time.

Post codeinjection (post.codeinjection_head & post.codeinjection_foot) in post.js and page.js

The codeinjection for the <head> can be solved using React Helmet like described here.

The codeinjection_foot is not clear where to add it, as the recommended way to inject scripts and styles is using React Helmet. If there's no better way possible to insert the codeinjection_foot before the closing </body> tag, it's probably best to use the same implementation as for the head.

Todos

  • [x] Write a parser util that differentiates between the HTML tags delivered in code injection
  • [x] Find a solution for post codeinjection_foot
  • [x] Implement codeinjection for posts
  • [x] Implement codinjection for the site (needs https://github.com/TryGhost/gatsby-starter-ghost/issues/20)
  • [ ] properly parse script tags and make available as codeinjection_scripts

aileen avatar Jan 11 '19 05:01 aileen

Any news on this ?

ScreamZ avatar May 13 '19 11:05 ScreamZ

Code injection is now working for <style> tags (both for site and post code injection). I tried to do the same for <script>, but the html parser was having issues with the content and cut it off. I tested this with a normal Google analytics scripts, which is the most common use case for site code injection.

The code for that parses the content is here.

If anyone has an idea, how to solve this, please let me know, or - even better - submit a PR 😊

aileen avatar Jun 10 '19 03:06 aileen

@AileenCGN Personally, I have implemented this in my own Fork of this repository. What I have found, as a workaround, is to place the functions themselves inside of the Ghost CMS without <script> tags. Here is how I have it implemented in the Layout Component. Code Injection Ghost CMS Header If you visit my website you can see how it is interpolated. ZionDials Website Code

ZionDials avatar Aug 30 '19 01:08 ZionDials

Thank you @ZionDials for this! This might be a good solution for some users already. The problem is, that we have to cover the case that both, <script> and <style> tags are used in codeinjection and your solution would not work in this case. But it would work for users who know exactly that they'll only use scripts in their codeinjection, so thank you very much for sharing this 🤗

aileen avatar Sep 02 '19 00:09 aileen

I'm wondering how this issue became stale as it seems to be a major feature and therefore I expected this to be needed by many ... but anyhow, I just came to be in need of this too so I implemented it in a way that I think could serve as a possible solution, even while it's still a little bit hacky some might say but actually works like a charm.

First, we build ourselves some kind of function that can extract our wanted script tags from the data delivered by gatsby-source-ghost. We do this by utilizing a little "hack" that helps us to convert the plain HTML string available under codeinjection_{head|foot} to "real" HTML elements so we can make use of the native HTML functions, queries, etc. Why? Because it is easy, reliable, and dependency-free. Also, it allows us to manipulate the elements in any way we want, for example adding special ids, attributes, classes, and so on:

const extractCodeInjectionElements = (
  type: "script" | "style",
  scriptString: string
) => {
  let scriptElements = []

  if (documentGlobal) {
    // We create a script wrapper to render the scriptString to real HTML
    const scriptWrapper = documentGlobal.createElement("div")
    scriptWrapper.innerHTML = scriptString

    // Now we can use native HTML queries to get our script|style elements (in this case a
    // collection)
    const scriptsCollection = scriptWrapper.getElementsByTagName(type)

    // Lets map the collection to a "normal" array of script elements
    for (let i = 0; i < scriptsCollection.length; i++) {
      scriptElements.push(scriptsCollection.item(i))
    }
  }

  // And return them ...
  return scriptElements
}

Note that I created that function to work with 2 types of tags, script and style. This is because somehow for me, the codeinjection_styles property is just always undefined, no matter how many style tags I put into the head or foot section of the code injection in my ghost instance. Don't know why this is, maybe @aileen can enlighten me? Anyhow instead of trying to figure out why it doesn't work for me I just wrote the function to work for both:

// Transform the codeinjection string to an array of "real" HTML script elements
const headScriptElements = extractCodeInjectionElements(
  "script",
  data.ghostPage?.codeinjection_head || ""
)

const footScriptElements = extractCodeInjectionElements(
  "script",
  data.ghostPage?.codeinjection_foot || ""
)

// As `codeinjection_styles` prop is always undefined, we just extract the style elements
// from head and foot on our own. 
const styleElements = extractCodeInjectionElements(
  "style",
  "".concat(
    data.ghostPage?.codeinjection_head || "",
    data.ghostPage?.codeinjection_foot || ""
  )
)

So that's basically it. Now we just map over the returned elements in our JSX, create the respecting script or style tags and "fill" them with the raw code by accessing the element.innerText property. This method works both in development, production, and with or without Helmet. The full implementation looks like this:

import { graphql } from "gatsby"
import React from "react"
import { Helmet } from "react-helmet"
import Layout from "../components/Layout"
import { documentGlobal } from "../utils/helpers"

const Page: React.FC<{ data: GatsbyTypes.GhostPageQuery }> = ({ data }) => {
  const extractCodeInjectionElements = (
    type: "script" | "style",
    scriptString: string
  ) => {
    let scriptElements = []

    if (documentGlobal) {
      // We create a script wrapper to render the scriptString to real HTML
      const scriptWrapper = documentGlobal.createElement("div")
      scriptWrapper.innerHTML = scriptString

      // Now we can use native HTML queries to get our script elements (in this case a
      // collection)
      const scriptsCollection = scriptWrapper.getElementsByTagName(type)

      // Lets map the collection to a "normal" array of script elements
      for (let i = 0; i < scriptsCollection.length; i++) {
        scriptElements.push(scriptsCollection.item(i))
      }
    }

    // And return them ...
    return scriptElements
  }

  // Transform the codeinjection string to an array of "real" HTML script elements
  const headScriptElements = extractCodeInjectionElements(
    "script",
    data.ghostPage?.codeinjection_head || ""
  )

  const footScriptElements = extractCodeInjectionElements(
    "script",
    data.ghostPage?.codeinjection_foot || ""
  )

  // Same for the style elements
  const styleElements = extractCodeInjectionElements(
    "style",
    "".concat(
      data.ghostPage?.codeinjection_head || "",
      data.ghostPage?.codeinjection_foot || ""
    )
  )

  return (
    <Layout>
      <Helmet>
        {/* Now we just iterate over the elements and create a script tag for each
        of them inside Helmet to exactly replicate our script tags from Ghost
        (ghost_head in this case). */}
        {headScriptElements.map((script, index) => (
          <script
            key={`ci_h_${index}`}
            type="text/javascript"
            src={script?.getAttribute("src") || undefined}
            async={script?.hasAttribute("async")}
          >{`${script?.innerText}`}</script>
        ))}

        {/* Or, if we want it clean, we just join multiple elements into one. */}
        <style type="text/css">{`${styleElements
          .map((e) => e?.innerText)
          .join("")}`}</style>
      </Helmet>

      {/* Our normal html from Ghost. */}
      <div className="tw-px-6 sm:tw-px-8 lg:tw-px-10 tw-py-8 tw-content-page">
        <div
          dangerouslySetInnerHTML={{ __html: data.ghostPage?.html || "" }}
        ></div>
      </div>

      {/* Finally, add the scripts from the ghost_foot section */}
      {footScriptElements.map((script, index) => (
        <script
          key={`ci_f_${index}`}
          type="text/javascript"
          src={script?.getAttribute("src") || undefined}
          async={script?.hasAttribute("async")}
        >{`${script?.innerText}`}</script>
      ))}
    </Layout>
  )
}

export default Page

// This page query loads all posts sorted descending by published date
// The `limit` and `skip` values are used for pagination
export const pageQuery = graphql`
  query GhostPage($slug: String!) {
    ghostPage(slug: { eq: $slug }) {
      ...GhostPageFields
    }
  }
`

So there you go, that's my implementation. If you guys like it you could basically implement an improved version of the extractCodeInjectionElements, maybe inside the @ghost/helpers package or so to provide a unified version. Let me know what you think! 😃

EDIT: forgot to add the src and async attributes to the final head and foot script elements

jsbrain avatar Aug 07 '20 14:08 jsbrain

Any updates on this @aileen, is there a particular reason why @jsbrain solution wasn't implemented in Ghost?

Nevensky avatar Jun 07 '21 12:06 Nevensky