diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index 874b712f50..f171c58c04 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -40,7 +40,7 @@ export function adaptServerUrl(url: URL, req: RequestWithOrg): void { const reqBaseDomain = parseSubdomain(req.hostname).base; if (process.env.GRIST_SERVE_SAME_ORIGIN === 'true' || req.isCustomHost) { - url.hostname = req.hostname; + url.host = req.headers.host || req.hostname; } else if (reqBaseDomain) { const subdomain: string|undefined = parseSubdomain(url.hostname).org || DEFAULT_HOME_SUBDOMAIN; url.hostname = `${subdomain}${reqBaseDomain}`; diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 2aba9d31ee..77dd5faf0a 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -26,7 +26,7 @@ import jsesc from 'jsesc'; import * as path from 'path'; import difference = require('lodash/difference'); -const translate = (req: express.Request, key: string, args?: any) => req.t(`sendAppPage.${key}`, args); +const translate = (req: express.Request, key: string, args?: any) => String(req.t(`sendAppPage.${key}`, args)); export interface ISendAppPageOptions { path: string; // Ignored if .content is present (set to "" for clarity). @@ -158,9 +158,9 @@ export function makeSendAppPage({ server, staticDir, tag, testLogin, baseDomain const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : ''; const staticTag = options.tag || tag; // If boot tag is used, serve assets locally, otherwise respect - // APP_STATIC_URL. - const staticOrigin = staticTag === 'boot' ? '' : (process.env.APP_STATIC_URL || ''); - const staticBaseUrl = `${staticOrigin}/v/${staticTag}/`; + // APP_STATIC_URL or APP_HOME_URL. + const staticOrigin = staticTag === 'boot' ? '' : (process.env.APP_STATIC_URL || config.homeUrl || ''); + const staticBaseUrl = `${staticOrigin.replace(/\/*$/, '')}/v/${staticTag}/`; const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? ""; const warning = testLogin ? "
Authentication is not enforced
" : ""; // Preload all languages that will be used or are requested by client. @@ -174,8 +174,8 @@ export function makeSendAppPage({ server, staticDir, tag, testLogin, baseDomain ).join('\n'); const content = fileContent .replace("", warning) - .replace("", getPageTitle(req, config)) - .replace("", getPageMetadataHtmlSnippet(config)) + .replace("", getDocName(config) ?? translate(req, 'Loading...')) + .replace("", getPageMetadataHtmlSnippet(req, config, staticBaseUrl)) .replace("", getPageTitleSuffix(server.getGristConfig())) .replace("", `` + tagManagerSnippet) .replace("", preloads) @@ -264,18 +264,15 @@ function configuredPageTitleSuffix() { } /** - * Returns a page title suitable for inserting into an HTML title element. - * - * Currently returns the document name if the page being requested is for a document, or - * a placeholder, "Loading...", that's updated in the client once the page has loaded. + * Returns the doc name. * * Note: The string returned is escaped and safe to insert into HTML. + * */ -function getPageTitle(req: express.Request, config: GristLoadConfig): string { +function getDocName(config: GristLoadConfig): string|null { const maybeDoc = getDocFromConfig(config); - if (!maybeDoc) { return translate(req, 'Loading') + "..."; } - return handlebars.Utils.escapeExpression(maybeDoc.name); + return maybeDoc && handlebars.Utils.escapeExpression(maybeDoc.name); } /** @@ -286,25 +283,29 @@ function getPageTitle(req: express.Request, config: GristLoadConfig): string { * * Note: The string returned is escaped and safe to insert into HTML. */ -function getPageMetadataHtmlSnippet(config: GristLoadConfig): string { +function getPageMetadataHtmlSnippet(req: express.Request, config: GristLoadConfig, staticBaseUrl: string): string { const metadataElements: string[] = []; const maybeDoc = getDocFromConfig(config); - const description = maybeDoc?.options?.description; - if (description) { - const content = handlebars.Utils.escapeExpression(description); - metadataElements.push(``); - metadataElements.push(``); - metadataElements.push(``); - } + metadataElements.push(''); + metadataElements.push(''); + const description = maybeDoc?.options?.description ?? translate(req, 'og-description'); + const escapedDescription = handlebars.Utils.escapeExpression(description); + metadataElements.push(``); + metadataElements.push(``); + metadataElements.push(``); - const icon = maybeDoc?.options?.icon; - if (icon) { - const content = handlebars.Utils.escapeExpression(icon); - metadataElements.push(``); - metadataElements.push(``); - metadataElements.push(``); - } + const icon = maybeDoc?.options?.icon ?? new URL('img/opengraph-preview-image.png', staticBaseUrl).href; + const escapedIcon = handlebars.Utils.escapeExpression(icon); + metadataElements.push(``); + metadataElements.push(``); + metadataElements.push(``); + + const maybeDocTitle = getDocName(config); + const title = (maybeDocTitle ? maybeDocTitle + getPageTitleSuffix(config) : translate(req, 'og-title')); + // NB: We don't generate the content of the tag here. + metadataElements.push(`<meta property="og:title" content="${title}">`); + metadataElements.push(`<meta name="twitter:title" content="${title}">`); return metadataElements.join('\n'); } diff --git a/static/img/opengraph-preview-image.png b/static/img/opengraph-preview-image.png new file mode 100644 index 0000000000..b9a367ad3c Binary files /dev/null and b/static/img/opengraph-preview-image.png differ diff --git a/static/locales/en.server.json b/static/locales/en.server.json index 3023c47ad2..e39a55bc6f 100644 --- a/static/locales/en.server.json +++ b/static/locales/en.server.json @@ -1,6 +1,8 @@ { "sendAppPage": { - "Loading": "Loading" + "Loading...": "Loading...", + "og-description": "A modern, open source spreadsheet that goes beyond the grid", + "og-title": "Grist, the evolution of spreadsheets" }, "oidc": { "emailNotVerifiedError": "Please verify your email with the identity provider, and log in again." diff --git a/static/locales/fr.server.json b/static/locales/fr.server.json index 6b219b7d99..05535d3f31 100644 --- a/static/locales/fr.server.json +++ b/static/locales/fr.server.json @@ -1,5 +1,5 @@ { "sendAppPage": { - "Loading": "Chargement" + "Loading...": "Chargement..." } } diff --git a/test/nbrowser/HomeIntro.ts b/test/nbrowser/HomeIntro.ts index e08f77fa4d..905d351f1b 100644 --- a/test/nbrowser/HomeIntro.ts +++ b/test/nbrowser/HomeIntro.ts @@ -29,6 +29,7 @@ describe('HomeIntro', function() { }); it('should should intro screen for anon', () => testIntroScreen({anon: true, team: false})); + it('should should set meta tags for URL previews', testMetaTags); it('should not show Other Sites section', testOtherSitesSection); it('should allow create/import from intro screen', testCreateImport.bind(null, false)); it('should link to examples page from the intro', testExamplesPage); @@ -129,6 +130,22 @@ describe('HomeIntro', function() { } } + async function testMetaTags() { + const expectedTitle = 'Grist, the evolution of spreadsheets'; + assert.equal(await driver.find('meta[name="twitter:title"]').getAttribute('content'), expectedTitle); + assert.equal(await driver.find('meta[property="og:title"]').getAttribute('content'), expectedTitle); + + const expectedDescription = 'A modern, open source spreadsheet that goes beyond the grid'; + assert.equal(await driver.find('meta[name="description"]').getAttribute('content'), expectedDescription); + assert.equal(await driver.find('meta[name="twitter:description"]').getAttribute('content'), expectedDescription); + assert.equal(await driver.find('meta[property="og:description"]').getAttribute('content'), expectedDescription); + + const gristIconFileName = 'opengraph-preview-image.png'; + assert.include(await driver.find('meta[name="thumbnail"]').getAttribute('content'), gristIconFileName); + assert.include(await driver.find('meta[name="twitter:image"]').getAttribute('content'), gristIconFileName); + assert.include(await driver.find('meta[property="og:image"]').getAttribute('content'), gristIconFileName); + } + async function testCreateImport(isLoggedIn: boolean) { await checkIntroButtons(isLoggedIn); diff --git a/test/nbrowser/NewDocument.ntest.js b/test/nbrowser/NewDocument.ntest.js index c81c3f7262..7af7166f8d 100644 --- a/test/nbrowser/NewDocument.ntest.js +++ b/test/nbrowser/NewDocument.ntest.js @@ -18,7 +18,22 @@ describe('NewDocument.ntest', function() { this.timeout(10000); await gu.actions.createNewDoc('Untitled'); assert.equal(await gu.actions.getDocTitle(), 'Untitled'); - assert.equal(await driver.getTitle(), 'Untitled - Grist'); + + const expectedTitle = 'Untitled - Grist'; + assert.equal(await driver.getTitle(), expectedTitle); + assert.equal(await driver.find('meta[name="twitter:title"]').getAttribute('content'), expectedTitle); + assert.equal(await driver.find('meta[property="og:title"]').getAttribute('content'), expectedTitle); + + const expectedDescription = 'A modern, open source spreadsheet that goes beyond the grid'; + assert.equal(await driver.find('meta[name="description"]').getAttribute('content'), expectedDescription); + assert.equal(await driver.find('meta[name="twitter:description"]').getAttribute('content'), expectedDescription); + assert.equal(await driver.find('meta[property="og:description"]').getAttribute('content'), expectedDescription); + + const gristIconFileName = 'opengraph-preview-image.png'; + assert.include(await driver.find('meta[name="thumbnail"]').getAttribute('content'), gristIconFileName); + assert.include(await driver.find('meta[name="twitter:image"]').getAttribute('content'), gristIconFileName); + assert.include(await driver.find('meta[property="og:image"]').getAttribute('content'), gristIconFileName); + assert.equal(await $('.active_section .test-viewsection-title').wait().text(), 'TABLE1'); await gu.waitForServer(); });