UNPKG

15.3 kBPlain TextView Raw
1import * as _ from "lodash";
2import * as cookie from "cookie";
3import * as fs from "fs";
4import * as moment from "moment";
5import * as OS from "os";
6import * as request from "request";
7import * as Debug from "debug";
8import * as url_parser from "url";
9
10import {SeoMiddleware, SeoOptions} from "./seo";
11import {ExtendedRequest, ExtendedResponse, getLocaleByURL, getLink, SiteMeta, KeywordMapper} from "./common";
12
13const debug = Debug("bablic:seo");
14
15const BABLIC_ROOT = "https://www.bablic.com";
16
17function escapeRegex(str: string): string {
18 return str.replace(/([.?+^$[\]\\(){}|-])/g, "\\$1");
19}
20
21export interface BablicOptions {
22 siteId: string;
23 rootUrl?: string;
24 locale?: string;
25 subDir?: boolean;
26 subDirBase?: string;
27 subDirOptional?: boolean;
28 onReady?: () => void;
29 seo?: SeoOptions;
30 folders?: {
31 [locale: string]: string,
32 };
33 meta?: SiteMeta;
34 snippet?: string;
35 keywords?: {
36 [urlKeyword: string]: {
37 [locale: string]: string,
38 },
39 };
40}
41
42export interface SiteData {
43 id?: string;
44 error?: string;
45 snippet: string;
46 meta: SiteMeta;
47 keywords?: {
48 [urlKeyword: string]: {
49 [locale: string]: string,
50 },
51 };
52}
53
54const Defaults: BablicOptions = {
55 siteId: null,
56 rootUrl: null,
57 locale: null,
58 subDir: false,
59 subDirBase: "",
60 subDirOptional: false,
61 onReady: null,
62 seo: {
63 useCache: true,
64 defaultCache: [],
65 test: false,
66 },
67 folders: null,
68};
69
70const BackwardCompOptions = {
71 siteId: ["site_id"],
72 rootUrl: ["root_url"],
73 subDir: ["subdir", "sub_dir"],
74 subDirBase: ["subdir_base"],
75 subDirOptional: ["subdir_optional"],
76};
77export const BackwardCompSEOOptions = {
78 useCache: ["use_cache"],
79 defaultCache: ["default_cache"],
80};
81
82export class BablicMiddleware {
83 public meta: SiteMeta = null;
84 public snippet = "";
85 private options: BablicOptions;
86 private LOCALE_REGEX: RegExp;
87 private seoMiddleware: (meta: SiteMeta, keywordsByLocale: KeywordMapper, reverseKeywordByLocale: KeywordMapper, req: ExtendedRequest, res: ExtendedResponse, next: () => void) => void;
88
89 private keywordsByLocale: KeywordMapper = null;
90 private reverseKeywordByLocale: KeywordMapper = null;
91 constructor(options: BablicOptions) {
92 let generalOptions = options as any;
93 for (let key in BackwardCompOptions) {
94 if (!options[key]) {
95 BackwardCompOptions[key].forEach((alt) => {
96 if (generalOptions[alt]) {
97 generalOptions[key] = generalOptions[alt];
98 }
99 });
100 }
101 }
102 if (options.seo) {
103 for (let key in BackwardCompSEOOptions) {
104 if (!options.seo[key]) {
105 BackwardCompSEOOptions[key].forEach((alt) => {
106 if (generalOptions.seo[alt]) {
107 generalOptions.seo[key] = generalOptions.seo[alt];
108 }
109 });
110 }
111 }
112 }
113
114 if (!options.siteId) {
115 throw new Error("Middleware requires and site_id");
116 }
117
118 this.options = _.defaultsDeep(options, Defaults);
119 let seoHandler = new SeoMiddleware(this.options.siteId, this.options.seo, {subDir: this.options.subDir, subDirBase: this.options.subDirBase, subDirOptional: this.options.subDirOptional});
120 this.seoMiddleware = seoHandler.middleware();
121
122 if (this.options.meta) {
123 this.meta = this.options.meta;
124 this.processKeywords(this.options.keywords);
125 }
126 if (this.options.snippet) {
127 this.snippet = this.options.snippet;
128 }
129
130 if (this.meta && this.snippet) {
131 if (this.options.onReady) {
132 this.options.onReady();
133 }
134 return;
135 }
136 this.loadSiteMeta(() => {
137 if (this.options.onReady) {
138 this.options.onReady();
139 }
140 });
141 }
142 public getSiteMeta(cbk: (e?: Error) => void) {
143 debug("getting from bablic");
144 request({
145 method: "GET",
146 url: `${BABLIC_ROOT}/api/v1/site/${this.options.siteId}?channel_id=node`,
147 }, (error, response, body) => {
148 if (error) {
149 return cbk(error);
150 }
151 if (!body) {
152 return cbk(new Error("empty response"));
153 }
154
155 try {
156 let data: SiteData = JSON.parse(body);
157 debug("data:", data);
158 this.saveSiteMeta(data);
159 cbk();
160 } catch (e) {
161 debug(e);
162 }
163 });
164 }
165 public saveSiteMeta(data: SiteData) {
166 let {snippet, meta} = data;
167 this.snippet = snippet;
168 this.meta = meta;
169 this.processKeywords(data.keywords);
170 this.LOCALE_REGEX = null;
171 data.id = this.options.siteId;
172 fs.writeFile(this.snippetUrl(), JSON.stringify(data), (error) => {
173 if (error) {
174 console.error("Error saving snippet to cache", error);
175 }
176 });
177 }
178 public snippetUrl() {
179 return `${OS.tmpdir()}/snippet.${this.options.siteId}`;
180 }
181 public getLocale(req: ExtendedRequest): string {
182 if (req.headers["bablic-locale"]) {
183 return req.headers["bablic-locale"] as string;
184 }
185
186 let auto = this.meta.autoDetect;
187 let defaultLocale = this.meta.default;
188 let customUrls = this.meta.customUrls;
189 let localeKeys = this.meta.localeKeys.slice();
190 localeKeys.push(this.meta.original);
191 let localeDetection = this.meta.localeDetection;
192 if (this.options.subDir) {
193 localeDetection = "subdir";
194 }
195 return getLocaleByURL(
196 url_parser.parse(getCurrentUrl(req)),
197 localeDetection,
198 customUrls,
199 detectLocaleFromCookie(req, this.meta),
200 defaultLocale,
201 auto ? detectLocaleFromHeader(req) : "",
202 false,
203 this.options.locale,
204 this.options.subDirBase,
205 this.options.folders,
206 localeKeys,
207 );
208
209 }
210 public loadSiteMeta(cbk: (e?: Error) => void) {
211 debug("loading meta from file");
212 fs.readFile(this.snippetUrl(), (error, data) => {
213 if (error) {
214 debug("no local file, getting from server");
215 return this.getSiteMeta(cbk);
216 }
217
218 debug("reading from temp file");
219 try {
220 let object: SiteData = JSON.parse(data.toString("utf8"));
221 if (object.id != this.options.siteId || object.error) {
222 debug("not of this site id");
223 return this.getSiteMeta(cbk);
224 }
225 this.meta = object.meta;
226 this.snippet = object.snippet;
227 this.processKeywords(object.keywords);
228 cbk();
229 } catch (e) {
230 debug(e);
231 return this.getSiteMeta(cbk);
232 }
233
234 debug("checking snippet time");
235 fs.stat(this.snippetUrl(), (e, stats) => {
236 if (e) {
237 return cbk();
238 }
239 let last_modified = moment(stats.mtime.getTime());
240 if (last_modified > moment().subtract(4, "hours")) {
241 return debug("snippet cache is good");
242 }
243 debug("refresh snippet");
244 this.getSiteMeta(() => debug("refreshed snippet"));
245 });
246 });
247 }
248 public handleBablicCallback(req: ExtendedRequest, res: ExtendedResponse) {
249 this.getSiteMeta(() => debug("site snippet refreshed"));
250 res.end("OK");
251 }
252 public getLink(locale: string, url: string): string {
253 let parsed = url_parser.parse(url);
254 return getLink(locale, parsed, this.meta, this.options);
255 }
256 public altTags(url: string, locale: string) {
257 let locales = this.meta.localeKeys || [];
258 let tags = _(locales)
259 .concat([this.meta.original])
260 .without(locale)
261 .map((l: string) => `<link rel="alternate" href="${this.getLink(l, url)}" hreflang="${l == this.meta.original ? "x-default" : l}">`)
262 .valueOf() as string[];
263 return tags.join("");
264 }
265 private generateOriginalPath(url: string, locale: string): string {
266 let urlParts = url.split("?");
267 let pathname = urlParts[0];
268 let reversed = this.reverseKeywordByLocale[locale];
269 let original = pathname.split("/").map((p) => reversed[p] || p).join("/");
270 if (original != pathname) {
271 urlParts[0] = original;
272 return urlParts.join("?");
273 } else {
274 return null;
275 }
276 }
277 private generateTranslatedPath(url: string, locale: string): string {
278 let urlParts = url.split("?");
279 let pathname = urlParts[0];
280 let proper = this.keywordsByLocale[locale];
281 let translated = pathname.split("/").map((p) => proper[p] || p).join("/");
282 if (translated != pathname) {
283 urlParts[0] = translated;
284 return urlParts.join("?");
285 } else {
286 return null;
287 }
288 }
289
290 public handle(req: ExtendedRequest, res: ExtendedResponse, next: () => void) {
291 if (!req.originalUrl) {
292 req.originalUrl = req.url;
293 }
294 if ((req.originalUrl == "/_bablicCallback" && req.method == "POST") || req.headers["x-bablic-refresh"]) {
295 debug("Redirecting to Bablic callback");
296 return this.handleBablicCallback(req, res);
297 }
298 res.setHeader("x-bablic-id", this.options.siteId);
299 if (!this.LOCALE_REGEX && this.options.subDir && this.meta && this.meta.localeKeys) {
300 this.LOCALE_REGEX = RegExp("^(?:" + escapeRegex(this.options.subDirBase) + ")?\\/(" + this.meta.localeKeys.join("|") + ")\\b");
301 }
302 if (!this.meta) {
303 debug("not loaded yet", req.originalUrl);
304 req.bablic = {
305 locale: "",
306 };
307 extendResponseLocals(res, {
308 bablic: {
309 locale: "",
310 snippet: "",
311 snippetBottom: "<!-- Bablic Footer OFF -->",
312 snippetTop: "<!-- Bablic Head OFF -->",
313 },
314 });
315
316 return next();
317 }
318
319 let locale = this.getLocale(req);
320
321 req.bablic = {
322 locale,
323 proxied: false,
324 };
325
326 let _snippet = this.snippet;
327
328 if (this.options.subDir && this.LOCALE_REGEX) {
329 req.url = req.url.replace(this.LOCALE_REGEX, "");
330 req.originalUrl = req.originalUrl.replace(this.LOCALE_REGEX, "");
331 _snippet = `<script type="text/javascript">var bablic=bablic||{};bablic.localeURL="subdir";bablic.subDirBase="${this.options.subDirBase}";bablic.subDirOptional=${!!this.options.subDirOptional};</script>` + _snippet;
332 }
333
334 if (this.reverseKeywordByLocale && this.reverseKeywordByLocale[locale]) {
335 let original = this.generateOriginalPath(req.url, locale);
336 // build original URL, so server will return proper content
337 if (original) {
338 req.url = original;
339 req.originalUrl = this.generateOriginalPath(req.originalUrl, locale) || req.originalUrl;
340 } else {
341 // check to see if there is a translated URL, if so, it should be redirected to it
342 let translated = this.generateTranslatedPath(req.originalUrl, locale);
343 if (translated) {
344 res.writeHead(301, {location: translated});
345 return res.end();
346 }
347
348 }
349 }
350
351 if (this.meta.original == locale) {
352 _snippet = _snippet.replace("<script", "<script async");
353 }
354
355 extendResponseLocals(res, {
356 bablic: {
357 locale,
358 snippet: _snippet,
359 snippetBottom: "",
360 snippetTop: "<!-- start Bablic Head -->" + this.altTags(req.originalUrl, locale) + _snippet + "<!-- start Bablic Head -->",
361 },
362 });
363
364 if (!this.seoMiddleware) {
365 return next();
366 }
367
368 if (locale == this.meta.original) {
369 debug("ignored same language", req.url);
370 return next();
371 }
372 return this.seoMiddleware(this.meta, this.keywordsByLocale, this.reverseKeywordByLocale, req, res, next);
373 }
374
375 private processKeywords(keywords: {[keyword: string]: {[locale: string]: string}}) {
376 if (!keywords) {
377 return;
378 }
379
380 this.keywordsByLocale = {};
381 this.reverseKeywordByLocale = {};
382 this.meta.localeKeys.forEach((locale) => {
383 let proper = {};
384 let reverse = {};
385 for (let keyword in keywords) {
386 if (!keywords[keyword][locale]) {
387 continue;
388 }
389 proper[keyword] = keywords[keyword][locale];
390 reverse[keywords[keyword][locale]] = keyword;
391 }
392 this.keywordsByLocale[locale] = proper;
393 this.reverseKeywordByLocale[locale] = reverse;
394 });
395 }
396}
397
398function extendResponseLocals(res: ExtendedResponse, context: {}) {
399 if (typeof(res.locals) == "function") {
400 res.locals(context);
401 } else if (res.locals) {
402 _.extend(res.locals, context);
403 } else {
404 res.locals = context;
405 }
406}
407
408function detectLocaleFromHeader(req: ExtendedRequest): string {
409 let header = req.headers["accept-language"];
410 if (!header) {
411 return "";
412 }
413 let langs = (header as string).split(",");
414 if (langs.length > 0) {
415 return langs[0].replace("-", "_");
416 }
417 return "";
418}
419
420function detectLocaleFromCookie(req: ExtendedRequest, meta: SiteMeta) {
421 let cookieHeader = req.headers.cookie;
422 if (!cookieHeader) {
423 return "";
424 }
425 if (!meta.localeKeys) {
426 return "";
427 }
428 let cookies = (req as any).cookies || cookie.parse(cookieHeader as string);
429 if (!cookies) {
430 return "";
431 }
432 let bablicCookie = cookies.bab_locale;
433 if (!bablicCookie) {
434 return "";
435 }
436 let index = meta.localeKeys.indexOf(bablicCookie);
437 if (index > -1) {
438 return bablicCookie;
439 }
440
441 let partialFound = _.find(meta.localeKeys, (l) => l[0] == bablicCookie[0] && l[1] == bablicCookie[1]);
442 return partialFound || "";
443}
444
445function getCurrentUrl(req){
446 return `http://${req.headers.host}${req.originalUrl}`;
447}
448
449
450