Closes #176: Deprecate native google analytics support; Add support for custom JS and HTML

This commit is contained in:
thatmattlove 2021-12-08 16:23:59 -07:00
parent 8aeb8036ff
commit 0ec3086c67
14 changed files with 69 additions and 174 deletions

View file

@ -36,31 +36,6 @@ Side = constr(regex=r"left|right")
LocationDisplayMode = t.Literal["auto", "dropdown", "gallery"] LocationDisplayMode = t.Literal["auto", "dropdown", "gallery"]
class Analytics(HyperglassModel):
"""Validation model for Google Analytics."""
enable: StrictBool = False
id: t.Optional[StrictStr]
@validator("id")
def validate_id(cls, value, values):
"""Ensure ID is set if analytics is enabled.
Arguments:
value {str|None} -- Google Analytics ID
values {[type]} -- Already-validated model parameters
Raises:
ValueError: Raised if analytics is enabled but no ID is set.
Returns:
{str|None} -- Google Analytics ID if enabled.
"""
if values["enable"] and value is None:
raise ValueError("Analytics is enabled, but no ID is set.")
return value
class Credit(HyperglassModel): class Credit(HyperglassModel):
"""Validation model for developer credit.""" """Validation model for developer credit."""
@ -270,6 +245,8 @@ class Web(HyperglassModel):
text: Text = Text() text: Text = Text()
theme: Theme = Theme() theme: Theme = Theme()
location_display_mode: LocationDisplayMode = "auto" location_display_mode: LocationDisplayMode = "auto"
custom_javascript: t.Optional[FilePath]
custom_html: t.Optional[FilePath]
class WebPublic(Web): class WebPublic(Web):

View file

@ -2,3 +2,4 @@ node_modules
dist dist
.next/ .next/
favicon-formats.ts favicon-formats.ts
custom.*[js, html]

View file

@ -1,5 +1,6 @@
.DS_Store .DS_Store
.env* .env*
custom.*[js, html]
*.tsbuildinfo *.tsbuildinfo
# dev/test files # dev/test files
TODO.txt TODO.txt

View file

@ -7,3 +7,4 @@ package-lock.json
tsconfig.json tsconfig.json
.next/ .next/
favicon-formats.ts favicon-formats.ts
custom.*[js, html]

View file

@ -0,0 +1,25 @@
/**
* Render a generic script tag in the `<head/>` that contains any custom-defined Javascript, if
* defined. It no custom JS is defined, an empty fragment is rendered, which will not appear in
* the DOM.
*/
export const CustomJavascript = (props: React.PropsWithChildren<Dict>): JSX.Element => {
const { children } = props;
if (typeof children === 'string' && children !== '') {
return <script id="custom-javascript" dangerouslySetInnerHTML={{ __html: children }} />;
}
return <></>;
};
/**
* Render a generic div outside of the main application that contains any custom-defined HTML, if
* defined. If no custom HTML is defined, an empty fragment is rendered, which will not appear in
* the DOM.
*/
export const CustomHtml = (props: React.PropsWithChildren<Dict>): JSX.Element => {
const { children } = props;
if (typeof children === 'string' && children !== '') {
return <div id="custom-html" dangerouslySetInnerHTML={{ __html: children }} />;
}
return <></>;
};

View file

@ -1,6 +1,7 @@
export * from './card'; export * from './card';
export * from './codeBlock'; export * from './codeBlock';
export * from './countdown'; export * from './countdown';
export * from './custom';
export * from './debugger'; export * from './debugger';
export * from './favicon'; export * from './favicon';
export * from './footer'; export * from './footer';

View file

@ -1,23 +1,19 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useRouter } from 'next/router';
import { Flex } from '@chakra-ui/react'; import { Flex } from '@chakra-ui/react';
import { isSafari } from 'react-device-detect'; import { isSafari } from 'react-device-detect';
import { If, Debugger, Greeting, Footer, Header } from '~/components'; import { If, Debugger, Greeting, Footer, Header } from '~/components';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { useGoogleAnalytics, useFormState } from '~/hooks'; import { useFormState } from '~/hooks';
import { ResetButton } from './resetButton'; import { ResetButton } from './resetButton';
import type { TFrame } from './types'; import type { TFrame } from './types';
export const Frame = (props: TFrame): JSX.Element => { export const Frame = (props: TFrame): JSX.Element => {
const router = useRouter(); const { developerMode } = useConfig();
const { developerMode, googleAnalytics } = useConfig();
const { setStatus, reset } = useFormState( const { setStatus, reset } = useFormState(
useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []), useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []),
); );
const { initialize, trackPage } = useGoogleAnalytics();
const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement); const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement);
function handleReset(): void { function handleReset(): void {
@ -26,15 +22,6 @@ export const Frame = (props: TFrame): JSX.Element => {
reset(); reset();
} }
useEffect(() => {
initialize(googleAnalytics, developerMode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
trackPage(router.pathname);
}, [router.pathname, trackPage]);
return ( return (
<> <>
<Flex <Flex

View file

@ -4,7 +4,6 @@ export * from './useDevice';
export * from './useDirective'; export * from './useDirective';
export * from './useDNSQuery'; export * from './useDNSQuery';
export * from './useFormState'; export * from './useFormState';
export * from './useGoogleAnalytics';
export * from './useGreeting'; export * from './useGreeting';
export * from './useHyperglassConfig'; export * from './useHyperglassConfig';
export * from './useLGQuery'; export * from './useLGQuery';

View file

@ -1,7 +1,6 @@
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { fetchWithTimeout } from '~/util'; import { fetchWithTimeout } from '~/util';
import { useGoogleAnalytics } from './useGoogleAnalytics';
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query'; import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
import type { DnsOverHttps } from '~/types'; import type { DnsOverHttps } from '~/types';
@ -52,11 +51,6 @@ export function useDNSQuery(
family: 4 | 6, family: 4 | 6,
): QueryObserverResult<DnsOverHttps.Response> { ): QueryObserverResult<DnsOverHttps.Response> {
const { cache, web } = useConfig(); const { cache, web } = useConfig();
const { trackEvent } = useGoogleAnalytics();
if (typeof target === 'string') {
trackEvent({ category: 'DNS', action: 'Query', label: target, dimension1: `IPv${family}` });
}
return useQuery<DnsOverHttps.Response, unknown, DnsOverHttps.Response, DNSQueryKey>({ return useQuery<DnsOverHttps.Response, unknown, DnsOverHttps.Response, DNSQueryKey>({
queryKey: [web.dnsProvider.url, { target, family }], queryKey: [web.dnsProvider.url, { target, family }],

View file

@ -1,110 +0,0 @@
import create from 'zustand';
import { useCallback } from 'react';
import * as ReactGA from 'react-ga';
import type { GAEffect, GAReturn } from './types';
interface EnabledState {
enabled: boolean;
enable(): void;
disable(): void;
}
const useEnabled = create<EnabledState>(set => ({
enabled: false,
enable() {
set({ enabled: true });
},
disable() {
set({ enabled: false });
},
}));
export function useGoogleAnalytics(): GAReturn {
const { enabled, enable } = useEnabled(({ enable, enabled }) => ({ enable, enabled }));
const runEffect = useCallback(
(effect: GAEffect): void => {
if (typeof window !== 'undefined' && enabled) {
if (typeof effect === 'function') {
effect(ReactGA);
}
}
},
[enabled],
);
const trackEvent = useCallback(
(e: ReactGA.EventArgs) => {
runEffect(ga => {
if (process.env.NODE_ENV === 'production') {
ga.event(e);
} else {
console.log(
`%cEvent %c${JSON.stringify(e)}`,
'background: green; color: black; padding: 0.5rem; font-size: 0.75rem;',
'background: black; color: green; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;',
);
}
});
},
[runEffect],
);
const trackPage = useCallback(
(path: string) => {
runEffect(ga => {
if (process.env.NODE_ENV === 'production') {
ga.pageview(path);
} else {
console.log(
`%cPage View %c${path}`,
'background: blue; color: white; padding: 0.5rem; font-size: 0.75rem;',
'background: white; color: blue; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;',
);
}
});
},
[runEffect],
);
const trackModal = useCallback(
(path: string) => {
runEffect(ga => {
if (process.env.NODE_ENV === 'production') {
ga.modalview(path);
} else {
console.log(
`%cModal View %c${path}`,
'background: red; color: white; padding: 0.5rem; font-size: 0.75rem;',
'background: white; color: red; padding: 0.5rem; font-size: 0.75rem; font-weight: bold;',
);
}
});
},
[runEffect],
);
const initialize = useCallback(
(trackingId: string, debug: boolean) => {
if (typeof trackingId !== 'string') {
return;
}
enable();
const initializeOpts = { titleCase: false } as ReactGA.InitializeOptions;
if (debug) {
initializeOpts.debug = true;
}
runEffect(ga => {
ga.initialize(trackingId, initializeOpts);
});
},
[runEffect, enable],
);
return { trackEvent, trackModal, trackPage, initialize, ga: ReactGA };
}

View file

@ -1,7 +1,6 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { useGoogleAnalytics } from './useGoogleAnalytics';
import { fetchWithTimeout } from '~/util'; import { fetchWithTimeout } from '~/util';
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query'; import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
@ -18,16 +17,6 @@ export function useLGQuery(
const { requestTimeout, cache } = useConfig(); const { requestTimeout, cache } = useConfig();
const controller = useMemo(() => new AbortController(), []); const controller = useMemo(() => new AbortController(), []);
const { trackEvent } = useGoogleAnalytics();
trackEvent({
category: 'Query',
action: 'submit',
dimension1: query.queryLocation,
dimension2: query.queryTarget,
dimension3: query.queryType,
});
const runQuery: QueryFunction<QueryResponse, LGQueryKey> = async ( const runQuery: QueryFunction<QueryResponse, LGQueryKey> = async (
ctx: QueryFunctionContext<LGQueryKey>, ctx: QueryFunctionContext<LGQueryKey>,
): Promise<QueryResponse> => { ): Promise<QueryResponse> => {

View file

@ -1,13 +1,21 @@
import fs from 'fs';
import Document, { Html, Head, Main, NextScript } from 'next/document'; import Document, { Html, Head, Main, NextScript } from 'next/document';
import { Favicon } from '~/components'; import { Favicon, CustomJavascript, CustomHtml } from '~/components';
import favicons from '../favicon-formats'; import favicons from '../favicon-formats';
import type { DocumentContext, DocumentInitialProps } from 'next/document'; import type { DocumentContext, DocumentInitialProps } from 'next/document';
class MyDocument extends Document { interface DocumentExtra extends DocumentInitialProps {
static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> { customJs: string;
customHtml: string;
}
class MyDocument extends Document<DocumentExtra> {
static async getInitialProps(ctx: DocumentContext): Promise<DocumentExtra> {
const initialProps = await Document.getInitialProps(ctx); const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps }; const customJs = fs.readFileSync('custom.js').toString();
const customHtml = fs.readFileSync('custom.html').toString();
return { customJs, customHtml, ...initialProps };
} }
render(): JSX.Element { render(): JSX.Element {
@ -31,9 +39,11 @@ class MyDocument extends Document {
{favicons.map((favicon, idx) => ( {favicons.map((favicon, idx) => (
<Favicon key={idx} {...favicon} /> <Favicon key={idx} {...favicon} />
))} ))}
<CustomJavascript>{this.props.customJs}</CustomJavascript>
</Head> </Head>
<body> <body>
<Main /> <Main />
<CustomHtml>{this.props.customHtml}</CustomHtml>
<NextScript /> <NextScript />
</body> </body>
</Html> </Html>

View file

@ -172,7 +172,6 @@ interface _ConfigShallow {
primary_asn: string; primary_asn: string;
request_timeout: number; request_timeout: number;
org_name: string; org_name: string;
google_analytics: string | null;
site_title: string; site_title: string;
site_keywords: string[]; site_keywords: string[];
site_description: string; site_description: string;

View file

@ -259,6 +259,25 @@ def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None:
file.write_text(data) file.write_text(data)
def write_custom_files(params: "UIParameters") -> None:
"""Write custom files to the `ui` directory so they can be imported and rendered."""
js = Path(__file__).parent.parent / "ui" / "custom.js"
html = Path(__file__).parent.parent / "ui" / "custom.html"
# Handle Custom JS.
if params.web.custom_javascript is not None:
copyfiles((params.web.custom_javascript,), (js,))
else:
with js.open("w") as f:
f.write("")
# Handle Custom HTML.
if params.web.custom_html is not None:
copyfiles((params.web.custom_html,), (html,))
else:
with html.open("w") as f:
f.write("")
async def build_frontend( # noqa: C901 async def build_frontend( # noqa: C901
dev_mode: bool, dev_mode: bool,
dev_url: str, dev_url: str,
@ -370,6 +389,8 @@ async def build_frontend( # noqa: C901
migrate_images(app_path, params) migrate_images(app_path, params)
write_custom_files(params)
generate_opengraph( generate_opengraph(
params.web.opengraph.image, params.web.opengraph.image,
1200, 1200,