spatie / schema-org

A fluent builder Schema.org types and ld+json generator
https://freek.dev/2016/12/package-fluently-generate-schema-org-markup/
MIT License
1.35k stars 135 forks source link

Graphing Multiple Schemas #43

Closed nolros closed 5 years ago

nolros commented 6 years ago

First thank you for an excellent library. I cannot tell you what a help this has been. One item I don't see that you might want to consider is graphing of multiple schemas according to the JSON LD spec. Ive included very basic example, which btw works fine. I think that the ""@context":"http:\/\/schema.org"" context needs to moved up to the @graph level but you get the idea.

    /**
     * * Return string stripped of script tags
     * 
     * @param $string
     * @return string
     */
    public function stripScriptTags($string) :string 
    {
        return substr($string, 35, strlen($string)-44);
    }

    /**
     * Return string appended with script tags
     * 
     * @param $string
     * @return string
     */
    public function appendScriptTags($string) : string 
    {
        return "<script type=\"application/ld+json\">" . $string . "</script>";
    }

    /**
     * @param array $schemas
     * @param string $schemaString
     * @return string
     */
    public function makeGraph($schemas = [], $schemaString = "") : string 
    {
        foreach ($schemas as $key => $schema) {
            $schemaString = $schemaString .  
                // strip the scrip tags 
                $this->stripScriptTags($this->$schema()->toScript()) . 
                // append seperator unless last item 
                ( (count($schemas)-1) !== $key ?  ",": "");
        }

        return  $this->appendScriptTags('{"@graph":[' . $schemaString  ."]}");
    }

    /**
     * @return string
     */
    public function graphMultipleSchemas() : string 
    {
        return $this->makeGraph([
            'orgExample',
            'blogExample'
        ]);
    }

    /**
     * Example org schema
     * 
     * @return mixed
     */
    public function orgExample()
    {
        return Schema::organization()
             (...);
    }

    /**
     * Example blog schema 
     * 
     * @return mixed
     */
    public function blogExample()
    {
        return Schema::blog()
        (...);
    }
sebastiandedeyne commented 6 years ago

So this would allow us to make a "combined" schema with one script tag right?

I'd like to keep the previous implementation working though, so I don't think we can simply move context. I think it's okay to duplicate it though.

Otherwise, I'd accept a PR that adds this :)

nolros commented 6 years ago

Purpose

Provide Schema::graph() @graph (concat) functionality. Current approach requires load each root level node schema is loaded individually e.g. Schema::organization() and . Schema::blog(). Although Google states this is accepted practice in reality there are a number of issues associated with that approach:

  1. Poor coding practice.
  2. JSON-LD becomes static versus dynamic data which is the power vs micro etc..
  3. SEO penalties for multiple script tags or rogue tags
  4. Bleeding of schema when combing php, javascript with defer / async loads. Example below:
  5. Development and management overhead

Solution:

The JSON-lD 1.1 spec provides for a @graph object defined as

A graph object represents a named graph represented as the value of a property within a node object. When expanded, a graph object MUST have an @graph member, and may also have @context, @id, and @index members. A simple graph object is a graph object which does not have an @id member. Note that node objects may have a @graph member, but are not considered graph objects if they include any other properties. A top-level object consisting of @graph is also not a graph object.

JSON 1-1 Specification:

4.15 Named Graphs - https://json-ld.org/spec/latest/json-ld/#named-graphs

At times, it is necessary to make statements about a graph itself, rather than just a single node. This can be done by grouping a set of nodes using the @graph keyword. A developer may also name data expressed using the @graph keyword by pairing it with an @id keyword as shown in the following example:

EXAMPLE 57: Using @graph to explicitly express the default graph

    {
      "@context": {...},
      "@graph": [{
                   "@type": "WebSite",
                   "name": "My Website Name",
                   "url": "http://www.example.com"
              }, {
                   "@type": "WebPage",
                   "name": "My Website Name",
                   "url": "http://www.example.com"
              }, {
                   "@type": "Organization",
                   "name": "My Website Name",
                   "url": "http://www.example.com",
                   "sameAs": [
                        "http://www.facebook.com/example-com",
                        "http://www.instagram.com/example-com"
                   ]
              }]
    }

Real world example:

{
  "@context": "http://schema.org",
  "@graph": [
    {
      "name": "Cybersecurity Software and Consulting services solutions targeting midmarket and enterprise sized companies in the US. We specialize in Distributed Denial of Service Prevention, Application Security, and Data Protection.",
      "address": {
        "postalCode": "84010",
        "addressLocality": "Bountiful",
        "addressRegion": "UT",
        "addressCountry": "US",
        "@type": "PostalAddress",
        "streetAddress": "585 W 500 S #110"
      },
      "openingHours": "Mo,Tu,We,Th,Fr 08:00-17:00",
      "@type": "LocalBusiness",
      "image": [
        "https:\/\/cdn.wallpapersafari.com\/71\/14\/Uk7QKa.jpg",
        "https:\/\/media.istockphoto.com\/photos\/salt-lake-city-panoramic-picture-id518556450?k=6&m=518556450&s=612x612&w=0&h=OpX0jR-Mc4h9MDHfzawGEh5wHm-KjkvZocTUk_vwe2M=",
        "http:\/\/www.ddos-911.com\/images\/social\/facebook\/solutions-facebook.png",
        "http:\/\/www.roseman.edu\/wp-content\/uploads\/2014\/02\/1-salt-lake-city-utah-usa-utah-images.jpg",
        "http:\/\/www.ddos-911.com\/images\/social\/facebook\/articles-facebook.png",
        "http:\/\/www.ddos-911.com\/images\/social\/facebook\/company-facebook.png",
        "http:\/\/www.ddos-911.com\/images\/social\/facebook\/home-facebook.png",
        "http:\/\/www.ddos-911.com\/images\/social\/facebook\/blog-facebook.png"
      ],
      "telephone": "+18012957555",
      "geo": {
        "@type": "GeoCoordinates",
        "longitude": -111.894683,
        "latitude": 40.887610000000002
      },
      "@id": "http:\/\/www.ddos-911.com\/",
      "@context": "http:\/\/schema.org"
    },
    {
      "url": "http:\/\/www.ddos-911.com\/",
      "author": {
        "@type": "Person",
        "name": "Nolan"
      },
      "@type": "WebSite",
      "publisher": "NCSi Inc.",
      "description": "Cybersecurity Software and Consulting services solutions targeting midmarket and enterprise sized companies in the US. We specialize in Distributed Denial of Service Prevention, Application Security, and Data Protection.",
      "name": "Cybersecurity Technology Solutions | Distributed Denial of Service Prevention | Application Security | Data Protection",
      "@context": "http:\/\/schema.org"
    }
  ]
}

Proposal

Extend the Schema library to include graphing functionality:

<?php
use Spatie\SchemaOrg\Schema;
use ExampleInterface; // public graph

class ExampleClass implements ExampleInterface
{

   /**
     * Example schema graph
     * 
     * @return mixed
     */
    public function graph()
    {
         /**
         * Return graphed schema objects 
         */
        $this->graphExample = Schema::graph([
            $this->orgExample(...),
            $this->blogExample(...),
            ...
        ])

         /**
         * Maintain current parsing 
         */
        $this->graphExample->toScript();
        $this->graphExample->toArray();  

        /**
         * Potential additional features 
         */

        /**
         * Return true if node child type exists on the graph parent 
         */
         $this->graphExample->has()->organization();  

        /**
         * Return true the node child type on the graph parent 
         */
         $this->graphExample->get()->organization();  

        /**
         * Ierative method on the collection. Example 
         */
         $this->graphExample->each()->toScript();  

        /**
         * Bonus = iteractive callback on the collection. Example 
         */
         $this->graphExample->each(function($v,$k) {
            return $v->isOrganization()->hasUrl()
         });  
    }

   /**
     * Example org schema
     * 
     * @return mixed
     */
    protected function orgExample(...)
    {
        return $this->orgSchemaName = Schema::organization()
             (...);
    }

    /**
     * Example blog schema 
     * 
     * @return mixed
     */
    protected function blogExample(...)
    {
        return Schema::blog()
        (...);
    }
}
sebastiandedeyne commented 6 years ago

Thanks for the detailed proposal!

If you're interested in contributing this, feel free to send a PR because I definitely want something like this. Not sure how I'd like to see it though, but that's gonna be easier to discuss over a PR :)

Wouldn't it suffice though to simple add a Schema::graph method, that accepts an array of types? Or do you see other downsides there?

nolros commented 6 years ago

@sebastiandedeyne sorry been moving. I will go take a look to ensure I have this right, but so far all my @graph is passing google snippet tests. All that needs to be done for a version 0.1 is as you said pass an array of unique schema objects which is then encapsulated in @graph array so foreach() :

  1. Example no @graph. You have 2 schemas e.g. Org and Blog. You would most likely insert snippets on the page individually
   // Snippet 1
    {
      "@context": "http:\/\/schema.org",
      "@type": "Organization",
      // ... remove for brevity sake
    }
   // Snippet 2
   {
      "@context": "http:\/\/schema.org",
      "@type": "Blog",
        // ... remove for brevity sake

      "blogPosts": [
        {
          "@type": "BlogPosting",
        // ... remove for brevity sake
         }
     ]
   }
  1. with@graph we are really just inserting x number of snippet objects (Schemas) into a@graph array. So to answer your question, yes an array of unique schema types

{
  "@context": "http:\/\/schema.org", //load single content for all @graph
  "@graph": [
    {
      "@type": "Organization"
      // ... remove for brevity sake
    }, // end of Org
    {
      "@type": "Blog",
      // ... remove for brevity sake
      "blogPosts": [
        {
          "@type": "BlogPosting"
          // ... remove for brevity sake
        },
        {
          "@type": "BlogPosting"
          // ... remove for brevity sake
        }
      ] 
    } // end of Blog
  ] // end of @graph
} //  encapsulated as an obj
  1. Here is an @graph example I have running on an actual live site. All snippets pass in google search console and google search parses out each snippet contained within. I should add that the site ranks #1 on google and bing with zero dollars spent to date on Adwords / PPC. The entire SEO backend runs on spatie/schema-org (with customization). The site is still undergoing a rewrite, but we have site gets a 90 - 95% performance grade on google ad GTMetrix i.e. you code works great!

I should add I still @context on every schema versus at graph level but none of the search engines complain and read it just fine.


{"@graph":[{"@context":"http:\/\/schema.org","@type":"Organization","address":{"streetAddress":"585 W 500 S #110","postalCode":"84010","addressCountry":"USA","addressRegion":"Utah"},"logo":"http:\/\/ddos-911.com\/storage\/_images\/_brand\/ncsi-security-logo-2018.png","email":"sales@ncsi.us","legalName":"+1-801-295-7555","areaServed":"USA","telephone":"+1-801-295-7555","url":"http:\/\/ddos-911.com","image":"\/images\/social\/facebook\/home-facebook.png","description":"Cybersecurity Software and Consulting services solutions targeting midmarket and enterprise sized companies in the US. We specialize in Distributed Denial of Service Prevention, Application Security, and Data Protection.","name":"NCSi, Inc. Cyber Security Software & Consulting Solutions","sameAs":["https:\/\/twitter.com\/GoNCSi","https:\/\/www.facebook.com\/goncsi\/","https:\/\/www.youtube.com\/channel\/UCVmaC_U5vRj4wPE5eSsKCkg","https:\/\/www.linkedin.com\/company\/3793997\/"],"contactPoint":[{"@type":"ContactPoint","telephone":"+1-801-295-7555","availableLanguage":"us-EN, English","areaServed":"USA","hoursAvailable":{"dayOfWeek":["Monday","Tuesday","Wednesday","Thursday","Friday"],"opens":"08:00","closes":"17:00"},"contactType":"Customer service"},{"@type":"ContactPoint","telephone":"+1-801-295-7555","availableLanguage":"us-EN, English","areaServed":"USA","hoursAvailable":{"dayOfWeek":["Monday","Tuesday","Wednesday","Thursday","Friday"],"opens":"08:00","closes":"17:00"},"contactType":"Sales"},{"@type":"ContactPoint","telephone":"+1-801-677-2497","availableLanguage":"us-EN, English","areaServed":"USA","hoursAvailable":{"dayOfWeek":["Monday","Tuesday","Wednesday","Thursday","Friday"],"opens":"08:00","closes":"17:00"},"contactType":"Tech Support"},{"@type":"ContactPoint","telephone":"+1-855-864-3734","availableLanguage":"English","areaServed":"USA","hoursAvailable":{"dayOfWeek":["Monday","Tuesday","Wednesday","Thursday","Friday"],"opens":"08:00","closes":"17:00"},"contactType":"Customer Support"}]}]}
Gummibeer commented 6 years ago

I like the idea of has() and would extend it by get(). Most schemas allow to use other schemas like product.brand instead of duplicating code it would be easier to first define organization and just do $graph->get(Organization::class) after this to get the created organization as product.brand.

Because I have to do this tomorrow in my company I would be fine to create a PR here.

Gummibeer commented 6 years ago

And just as an idea - in big applications with multiple submodules it could be that a lot of schemas will be added from different packages. This could make it hard to get a schema from another package in real-time if it is executed later. So how about something like a Promis that will resolve everything from graph during toArray() so it will have access to all available schemas!? This won't be too hard to get it working and will make the graph the most powerful part here.

Gummibeer commented 6 years ago

For the Promise we just need a new class that knows about the whole graph and a new if in the serializeProperty(). And the graph should be solvable be a new type with some fancy logic like the has, get and so on. The current serializer already handles all the things.

Gummibeer commented 6 years ago

@nolros I've opened a PR - can you check if it solves all your problems? For us it solves all open wishes! 😃 The show/hide and link ability is the thing we missed the most - so I've added it on top to the normal graph-features (collect schemas).

bellwood commented 6 years ago

Any input on this? Would be great to see this functionality included.

Gummibeer commented 6 years ago

@bellwood can you test #62 !? It should solve this but testing by others would be great. 😊

bellwood commented 6 years ago

I've had no issues with it combining BreadcrumbList, LocalBusiness and Review on L5.6

Gummibeer commented 6 years ago

So I bet that @sebastiandedeyne or someone else of @spatie should review the PR to release it!? For me it also works like charm. Primary the relation resolving in a singleton instance is the best thing! :)

wassim commented 5 years ago

@Gummibeer seems @sebastiandedeyne is afk 😭. Maybe @freekmurze could review #62?

sebastiandedeyne commented 5 years ago

Hi! 👋😄

Reviewed the PR, will merge after the comments are resolved 👍

sebastiandedeyne commented 5 years ago

Available in 2.1.0!