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

DOP-4916: Implement SoftwareSourceCode structured data #1258

Merged
merged 16 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
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/components/Breadcrumbs/BreadcrumbContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const crumbObjectShape = {
};

BreadcrumbContainer.propTypes = {
breadcrumbs: PropTypes.shape(crumbObjectShape).isRequired,
breadcrumbs: PropTypes.arrayOf(PropTypes.shape(crumbObjectShape)).isRequired,
};

export default BreadcrumbContainer;
139 changes: 76 additions & 63 deletions src/components/Code/Code.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { css } from '@emotion/react';
import React, { useCallback, useContext } from 'react';
import React, { useCallback, useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { default as CodeBlock } from '@leafygreen-ui/code';
Expand All @@ -12,6 +12,7 @@ import { TabContext } from '../Tabs/tab-context';
import { reportAnalytics } from '../../utils/report-analytics';
import { getLanguage } from '../../utils/get-language';
import { DRIVER_ICON_MAP } from '../icons/DriverIconMap';
import { SoftwareSourceCodeSd } from '../../utils/structured-data';
import { baseCodeStyle, borderCodeStyle, lgStyles } from './styles/codeStyle';
import { CodeContext } from './code-context';

Expand Down Expand Up @@ -85,77 +86,89 @@ const Code = ({
reportAnalytics('CodeblockCopied', { code });
}, [code]);

const softwareSourceCodeSd = useMemo(() => new SoftwareSourceCodeSd({ code, lang }), [code, lang]);
Copy link
Collaborator

Choose a reason for hiding this comment

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

same minor point here for checking validity and returning !


return (
<div
css={css`
${baseCodeStyle}

// Remove whitespace when copyable false
> div > div {
display: grid;
grid-template-columns: ${!copyable && (languageOptions?.length === 0 || language === 'none')
? 'auto 0px !important'
: 'code panel'};
}

> div {
border-top-left-radius: ${captionBorderRadius};
border-top-right-radius: ${captionBorderRadius};
display: grid;
border-color: ${palette.gray.light2};

.dark-theme & {
border-color: ${palette.gray.dark2};
<>
{softwareSourceCodeSd.isValid() && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: softwareSourceCodeSd.toString(),
}}
/>
)}
<div
css={css`
${baseCodeStyle}

// Remove whitespace when copyable false
> div > div {
display: grid;
grid-template-columns: ${!copyable && (languageOptions?.length === 0 || language === 'none')
? 'auto 0px !important'
: 'code panel'};
}
}

pre {
background-color: ${palette.gray.light3};
color: ${palette.black};
> div {
border-top-left-radius: ${captionBorderRadius};
border-top-right-radius: ${captionBorderRadius};
display: grid;
border-color: ${palette.gray.light2};

.dark-theme & {
background-color: ${palette.black};
color: ${palette.gray.light3};
.dark-theme & {
border-color: ${palette.gray.dark2};
Copy link
Collaborator Author

@rayangler rayangler Sep 27, 2024

Choose a reason for hiding this comment

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

No CSS changes actually applied here

}
}
}

[data-testid='leafygreen-code-panel'] {
background-color: ${palette.white};
border-color: ${palette.gray.light2};
pre {
background-color: ${palette.gray.light3};
color: ${palette.black};

.dark-theme & {
background-color: ${palette.gray.dark2};
border-color: ${palette.gray.dark2};
.dark-theme & {
background-color: ${palette.black};
color: ${palette.gray.light3};
}
}
}

${lgStyles}
`}
>
{captionSpecified && (
<div>
<CaptionContainer style={{ '--border-color': darkMode ? palette.gray.dark2 : palette.gray.light2 }}>
<Caption style={{ '--color': darkMode ? palette.gray.light2 : palette.gray.dark1 }}>{caption}</Caption>
</CaptionContainer>
</div>
)}
<CodeBlock
copyable={copyable}
highlightLines={emphasizeLines}
language={language}
languageOptions={languageOptions}
onChange={(selectedOption) => {
setActiveTab({ drivers: selectedOption.id });
}}
onCopy={reportCodeCopied}
showLineNumbers={linenos}
showCustomActionButtons={sourceSpecified}
customActionButtons={customActionButtonList}
lineNumberStart={lineno_start}

[data-testid='leafygreen-code-panel'] {
background-color: ${palette.white};
border-color: ${palette.gray.light2};

.dark-theme & {
background-color: ${palette.gray.dark2};
border-color: ${palette.gray.dark2};
}
}

${lgStyles}
`}
>
{code}
</CodeBlock>
</div>
{captionSpecified && (
<div>
<CaptionContainer style={{ '--border-color': darkMode ? palette.gray.dark2 : palette.gray.light2 }}>
<Caption style={{ '--color': darkMode ? palette.gray.light2 : palette.gray.dark1 }}>{caption}</Caption>
</CaptionContainer>
</div>
)}
<CodeBlock
copyable={copyable}
highlightLines={emphasizeLines}
language={language}
languageOptions={languageOptions}
onChange={(selectedOption) => {
setActiveTab({ drivers: selectedOption.id });
}}
onCopy={reportCodeCopied}
showLineNumbers={linenos}
showCustomActionButtons={sourceSpecified}
customActionButtons={customActionButtonList}
lineNumberStart={lineno_start}
>
{code}
</CodeBlock>
</div>
</>
);
};

Expand Down
2 changes: 1 addition & 1 deletion src/components/StructuredData/DocsLandingSD.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { baseUrl } from '../../utils/base-url';

const DocsLandingSD = () => (
<script id="structured data" type="application/ld+json">
<script type="application/ld+json">
{JSON.stringify({
'@context': 'http://schema.org',
'@type': 'WebSite',
Expand Down
33 changes: 13 additions & 20 deletions src/components/Video/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import ReactPlayerYT from 'react-player/youtube';
import ReactPlayerWistia from 'react-player/wistia';
import PropTypes from 'prop-types';
Expand All @@ -7,6 +7,7 @@ import { css } from '@emotion/react';
import { palette } from '@leafygreen-ui/palette';
import { withPrefix } from 'gatsby';
import { theme } from '../../theme/docsTheme';
import { VideoObjectSd } from '../../utils/structured-data';
import VideoPlayButton from './VideoPlayButton';

// Imported both players to keep bundle size low and rendering the one associated to the URL being passed in
Expand Down Expand Up @@ -73,21 +74,10 @@ const Video = ({ nodeData: { argument, options = {} } }) => {
// use placeholder image for video thumbnail if invalid URL provided
const [previewImage, setPreviewImage] = useState(withPrefix('assets/meta_generic.png'));
const { title, description, 'upload-date': uploadDate, 'thumbnail-url': thumbnailUrl } = options;
// Required fields based on https://developers.google.com/search/docs/appearance/structured-data/video#video-object
const hasAllReqFields = [url, title, uploadDate, thumbnailUrl].every((val) => !!val);

const structuredData = {
'@context': 'https://schema.org',
'@type': 'VideoObject',
embedUrl: url,
name: title,
uploadDate,
thumbnailUrl,
};

if (description) {
structuredData['description'] = description;
}
const videoObjectSd = useMemo(
() => new VideoObjectSd({ embedUrl: url, name: title, uploadDate, thumbnailUrl, description }),
[url, title, uploadDate, thumbnailUrl, description]
);

useEffect(() => {
// handles URL validity checking for well-formed YT links
Expand Down Expand Up @@ -121,10 +111,13 @@ const Video = ({ nodeData: { argument, options = {} } }) => {

return (
<>
{hasAllReqFields && (
<script id={`video-object-sd-${url}`} type="application/ld+json">
{JSON.stringify(structuredData)}
</script>
{videoObjectSd.isValid() && (
<script
Copy link
Collaborator

Choose a reason for hiding this comment

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

Matt had suggested doing this in the memo and returning ! 👍

Copy link
Collaborator Author

@rayangler rayangler Sep 27, 2024

Choose a reason for hiding this comment

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

Makes sense. Done. Thanks for flagging (and to Matt for suggesting)

type="application/ld+json"
dangerouslySetInnerHTML={{
__html: videoObjectSd.toString(),
}}
Copy link
Collaborator

Choose a reason for hiding this comment

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

ditto

/>
)}
<ReactPlayerWrapper>
<ReactPlayer
Expand Down
49 changes: 49 additions & 0 deletions src/utils/get-language.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,44 @@
*/
import { Language } from '@leafygreen-ui/code';

// Mapping of formal language names based on loose inputs
const LANGUAGE_NAMES = {
bash: 'Bash',
c: 'C',
cpp: 'C++',
'c#': 'C#',
cs: 'C#',
csharp: 'C#',
dart: 'Dart',
diff: 'Diff',
go: 'Golang',
graphql: 'GraphQL',
html: 'HTML',
http: 'HTTP',
ini: 'Ini',
java: 'Java',
javascript: 'JavaScript',
js: 'JavaScript',
json: 'JSON',
kotlin: 'Kotlin',
objectivec: 'Objective-C',
perl: 'Perl',
php: 'PHP',
properties: 'Properties',
python: 'Python',
ruby: 'Ruby',
rust: 'Rust',
scala: 'Scala',
sh: 'Shell',
shell: 'Shell',
sql: 'SQL',
swift: 'Swift',
ts: 'TypeScript',
typescript: 'TypeScript',
xml: 'XML',
yaml: 'YAML',
};

export const getLanguage = (lang) => {
if (Object.values(Language).includes(lang)) {
return lang;
Expand All @@ -15,3 +53,14 @@ export const getLanguage = (lang) => {
}
return 'none';
};

/**
* @param {string} lang The language passed to the code block directive
* @returns {string | undefined} The formal name of the language, if it exists
*/
export const getFullLanguageName = (lang) => {
if (!lang) {
return undefined;
}
return LANGUAGE_NAMES[lang];
};
87 changes: 87 additions & 0 deletions src/utils/structured-data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Classes to construct Structured Data JSON.
* Required props should be read in constructor function (to fail validity).
* Optional props can be set conditionally.
* Constant values should be set in the constructor function.
* Optional overwrites can be set in params as default values
*/

import { getFullLanguageName } from './get-language';

export class StructuredData {
constructor(type) {
this['@context'] = 'https://schema.org';
this['@type'] = type;
}

isValid() {
function recursiveValidity(param) {
// array
if (Array.isArray(param)) {
return param.every((e) => recursiveValidity(e));
}
// object
else if (param && typeof param === 'object') {
return Object.keys(param).every((e) => {
if (param.hasOwnProperty(e)) return recursiveValidity(param[e]);
return true;
});
}

// string or number
return String(param).length > 0;
}

return recursiveValidity(this);
}

toString() {
return JSON.stringify(this);
}

static addCompanyToName(name) {
if (!name) {
return name;
}
if (Array.isArray(name)) {
return name.map(this.addCompanyToName);
}
if (name.toLowerCase().includes('mongodb')) {
return name;
}
return `MongoDB ` + name;
}
}

export class SoftwareSourceCodeSd extends StructuredData {
constructor({ code, lang }) {
super('SoftwareSourceCode');
this.codeSampleType = 'code snippet';
this.text = code;

const programmingLanguage = getFullLanguageName(lang);
if (programmingLanguage) {
this.programmingLanguage = programmingLanguage;
}
}
}

export class VideoObjectSd extends StructuredData {
constructor({ embedUrl, name, uploadDate, thumbnailUrl, description }) {
super('VideoObject');

this.embedUrl = embedUrl;
this.name = name;
this.uploadDate = uploadDate;
this.thumbnailUrl = thumbnailUrl;

if (description) {
this.description = description;
}
}

isValid() {
const hasAllReqFields = [this.embedUrl, this.name, this.uploadDate, this.thumbnailUrl].every((val) => !!val);
return hasAllReqFields && super.isValid();
}
}
Loading
Loading