mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2025-03-22 19:05:56 -04:00
Add script for configuring Fastly for S3
* Builds the Pass condition based on the static directory and view routes. * Updates Fastly with new header and conditions based on the view routes. * Uses a nice module for interacting with fastly :) Needs some major cleanup but it works. Hopefully Travis will work too.
This commit is contained in:
parent
c8c681f8c0
commit
de5f36b649
5 changed files with 280 additions and 13 deletions
21
.travis.yml
21
.travis.yml
|
@ -10,24 +10,34 @@ notifications:
|
|||
secure: ezESiG7JnuSLZc2/PPhOvWUv5BHBCr+g86MsuLLw+S+zz3DUfzWHMQ1g5tUvkeSDTPmfEIX41EnPkaoWtsD3OGO0PGXgseAfA8+6Z4N1rICNZZrhXZB2s6UdwRK1e+0Jol4W3kHmt96BHyN2scLNgJYeWMgSJllVsuPhMTlKBZIXI9u540NH8Nxjl3f2WvoIg64Q1mZvMxkpPbw4xssx6U4HSFE8kTTE6+EFsSxzombFX0cLGjPiJ9QZgGVUk4UkIjyiFLQQDfQlLllCaUpqJ9+qbuCNoMSKA2yty/qyZ8Y+r4OlMberjmBzR9GRLLyXWWcaAfMIgwlRhjtLYIDAUSsGM1iwUWCgyB9maG2IiXuYLSueuMx8DcDwbpUepoDgnqBYnM2AJmT8gcsxqlKYzJpYpHDgZgBlLZQgMXqjrVJHs/Tf9XVcLS6HAn1Ww0OOT01jThfy4gClpAuqLayYexsXOoL+RaFg25E2NzuTtaFWgRfWZgcAeqYNDiUzwun2D4vZ5I+NtdRP0gzpbG2fxhFz05vAqyf1Kp6ZYb17Li3A38dIm6Lsvv3qawAIAgNaZpIZX3f89+uq6jHU8kJy1Iv823JK2Xac3vEz3SHUKJnuXFF0LO07om9AcNEXhP/JrJ617S8nfvDtZRJODMFhz8qQwie+65Ql1I871goBpVs=
|
||||
env:
|
||||
global:
|
||||
# EB_AWS_ACCESS_KEY_ID
|
||||
- secure: A138rYuXDsOmpEwYxZ31WyXEeq5fgr9qyqsQh1nTFsjBKpFtNM+CN9e0QJQFT3PLs4wH/lWTRSyHxakxKQS1sxq828f9gHed+f15REKk/fRUplcCYIexT9xKVtU3D8CRNn/KBFWk75fZyZt20eyOVIv4h3pInKQz7y84J6PWzB1BCrAFvADrzS1X68Z3NJJLyxnz0YEurzz8mC2v4D0s/XifKTWvRtefD4QM6pE0C2iYyk+ThrLwg7i9FDHVfo0MrkgcdX7mz37SnTr7p7mHWnGXrGngi/NiDRQ+Uwwq/sr2UIww0rCwS1xsOcS//dC4NNqrrt1kUTsoC1Yt87Ny+gI0nUplsfEpdKajAkOYdANC5bJUGqPdSlOds1v9aJs9Hx48uGamWkm/3cFmoJ5uA2ZzUwbSGjTkWbnhwzT0YRvcLGhP1WE/EswaIyK5qMp522E79mP1yH6M750iUvi4N39+QW1BNX3ADkOwyAI67ArX5on5gWP83RXcJ15im7XsBpsmVn/KXi6AouWPb8jmSmKCj0QZCzfLY7ivM42IugYpK2NV7kFB38DpXQamJ5eskgwYa3elRmednIFUuwb1QDnONvJogVjk4CLmoSxssC2mJnnrUItM7l8G6As81GMI+6lTtl86hAuXBjUk60FMbgTAQDX9ll26LgpBy8jHSx8=
|
||||
# EB_AWS_SECRET_ACCESS_KEY
|
||||
- secure: EX1fyov+f6ytWN2ZSL4dLslwrVkp6Ho/uoSLO38/qNG3XdGmBN4VprxddcQiWfo+Mrg3GdWcfcM/VazhhStBi1uLfZiw3RHZaSGuWbiuD2EtzqtlC+OVvoajgy91QFajh9Zzuwa0rYbEPd/sw01R53NoWJYl0GSteWk7C8Wv6anl4FUJCqgvvTV2ZEcyTtGcVJgUhKi1MfNpTSM6JWBy0DWszcyxj7C8LSs1+l9ZjAtnlUBWY13HsrNu8G5d+FwqGHZLUAjdu2O602wxV897/xLARLduZ+01ALpVefNEEGMB1Wd+xMw4dm2B0Uk86a4TBRCeOgJZ1yoJoPpGPOHTo+dgNXcU8ReszGVoy7uOjFWwu82FQq8gzfcf75yzaRJgG8/BJ6BkJfa0EmFg3iO5CwixQyHR5+CqsedtoLAWVT8zlOfQ/Z6yx4Pm7jXQSOkyvo09YJ2QIn4IFGPvwOVS7Firzi+fLl8GYApeSV9G10e1IzA4pPrKdJMRA4qRMPt9zJGq7ZO1J/d9aW/5KIsJUDnodnl7yXJyDMOyNeljT9I82ciHZcURxRRY080vrW6dgNJE1V9jxBhWEvr2iCeWMMedWaGuC41I7K9L79eW8lmaE+cQ+OZrzpOJP4GbfmIiXrh+0M4ChL/xBpjtiFwpNdkCXXhzWMnjJ4wCrii4yuc=
|
||||
- CXX=g++-4.8
|
||||
- S3_BUCKET_NAME_STAGING=scratch-www-staging
|
||||
- S3_BUCKET_NAME_PRODUCTION=scratch-www-production
|
||||
- S3_ACL=public_read
|
||||
- S3_LOCAL_DIR=build
|
||||
- S3_REGION=us-east-1
|
||||
- SKIP_CLEANUP=true
|
||||
# FASTLY_API_KEY
|
||||
- secure: XNWcCnqSAd4MpKg6FVe3WeFmdqfdH753+PBCOEkJrHS+AHmLMuWsjIQFJ3LUR9ylEQRVPR2OyXJW/R8NI9toStREgwE4fwIVo0l4fwYqLStxYpEKlcWfkJ3uNpRZhvcVmUBycelrnjJqXVdrtlxPCKX0tNkpcKH2b98We7A2/r7HxKv13upDxWTQ/qRUv0+SJCRTB4n/QInABi87Ef8Q2rNGrL0WQzQvVBeiEXOP0JSkyYK4+q65gswMKPehgiFagnYVgJN9J9Q1VrBDc06gidbznBcEpPaBAYvsTTY9dWTJxaaKNSrmOIe/OiuJUEHjb+8NL+j6Lp7wX8lzEjbr0FkVlFnxS9VbftS2KFkN7+c3RF57+tsq0xwJ6vgomIVS5FupHgl/oCJicnH/FLfynditOLZhmhF+Ed5GCAoIEamRRzcVHdjvglsEtYsDX1/z2t+HKYtPQuXYOywDRVTSPf88eEbu8ehfgNcYaIAuD6eedyDnKTOIv7owWs3Y7GsxQ2jBLGXq1YoUEkPtB0vfaHi72CeEhDQ53mEn2Ure47UMGMgUjKtiIhDBNTbECwP/ZDJv1accGRljKjDy93aCJeRi1T7Op7tDbHSl4ScieeOwOeKJMcD1U5JGdA/sRnjjgSKb24P2ys4NYr95dgqWNNGPGMxca+lGufzdEaTQT44=
|
||||
# FASTLY_SERVICE_ID_STAGING
|
||||
- secure: n2A/v+rfDqhYNvp2ANvWXFnDRnstDqTuAiiMHx5YZmk/2OXIj8FZ74cu5klRNuq8xeKLdWAq24nOjMkaM7LSu5l3xPTIX5nhk094srFZI+KZnpB8LIftJFR9+8f2Jzz+HboQVwYdAXnmkGWkE8U1v+qOrFkAY3GmZurWcBFJwZ3ytTcyXqPe3QsdXeijEgMRvPmj1RIrtD+ovJdir19la7HMIBfwbARRhCSCmBsd9WZwNc4aCz5ob1lTnZaNBFtYHbB9hpvp/+A68Nql7+zLRxytUQ6raehe6WC1gWpfhMIphmUpumJqCU4fK0uPD0ztzHoxYbpeSds8KO7Q0yJVNfRxQLgD9kVQliTWFM/jCWQYcCLnrH0Rq2L3VlXw0qLlT+cYXc9+/Nx8Rl+q8R7w+1sGUZJDbzHPXW1gssVizU1Mxo78MJJrZ6+DpsEhOHLGGrs3RrYIzo7/c4prEdr3lN40lkYplQdzPEA9XQoqAOOfzazetEMzlBm+/q1fVmNZrBY2m1Xnu78l1SMvoUzEYZeezP70UG3TQfPSS7M5fPhAbXroD67VHE2qrcLo9PMDNl8/QmIhjGaogGDLSv0eiZU+e9KbCtKuawtgP5DahEKiOx17xOE9Vx73KAqDMT6G/reQmUhZihCwysbRudL/vWd617l721uloGV5xVrF0s4=
|
||||
# FASTLY_SERVICE_ID_PRODUCTION
|
||||
- secure: W8aANFTNABATDi2WFUAb934xN55eKVifopbOQhBPuLMGlch0BUn3Otbwl05Zt7mQZtestHGy6lgpUzV9tajvuZ3GjzlVN2Dm7xfQe8VbVL2pjWXhVgMiX+eY08/Ba5KG2X4BcSdcpdXP4GEjNW9GoNUjJDSpYUFACZew/hZS/+EwNF0j8EC87Gs0xL37JOQ61X/uG2QCMVzmP4R7NjTCFoLwh6CiqRRAMowNrU11aOjQ8rM52AO7iL4ekXYWx16PURn+jQ32ywo009J11x7zeqithnvLcyG4dvAcqyKYI9PaH+tWyr3JxSZqEJkvlP7CSFKsnuOyIF+1k0kS3IIyMEK4KHptM+/lll6MiFd9xIoNYyqkq0Y9pYjaq2mGbf6lOuIC028g+KcHM1/q8a9MhwTUmGiLg6k5VU1zsr217Z0f6//fNzvOG6/x42d78Zyc66T9OnZ8ap92AzDjrrJBP1D9JYIU2nVojRMesQ2iLrdiU2HaHY/vZz4MPhkdE9t6lvptHEXysmn5tokdNWrgXJpHgLlteMLnFOWG6PaadysLn39QAz9o4wcsuAnIJWYoaWOBplwfY011G0QKq1VNpZrmHJZaHMLruP8rbXLHWnQkkQp/RlT+7+z+Tf+3kB8LBRykRt8WaH717eblUUm5MVPFrZA0A5faCD8o0maN3SA=
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- ubuntu-toolchain-r-test
|
||||
- ubuntu-toolchain-r-test
|
||||
packages:
|
||||
- g++-4.8
|
||||
- g++-4.8
|
||||
deploy:
|
||||
- provider: s3
|
||||
access_key_id: $EB_AWS_ACCESS_KEY_ID
|
||||
secret_access_key: $EB_AWS_SECRET_ACCESS_KEY
|
||||
bucket: scratch-www-staging
|
||||
bucket: $S3_BUCKET_NAME_STAGING
|
||||
local_dir: $S3_LOCAL_DIR
|
||||
acl: $S3_ACL
|
||||
region: $S3_REGION
|
||||
|
@ -42,7 +52,7 @@ deploy:
|
|||
- provider: s3
|
||||
access_key_id: $EB_AWS_ACCESS_KEY_ID
|
||||
secret_access_key: $EB_AWS_SECRET_ACCESS_KEY
|
||||
bucket: scratch-www-production
|
||||
bucket: $S3_BUCKET_NAME_PRODUCTION
|
||||
local_dir: $S3_LOCAL_DIR
|
||||
acl: $S3_ACL
|
||||
region: $S3_REGION
|
||||
|
@ -50,3 +60,6 @@ deploy:
|
|||
on:
|
||||
repo: LLK/scratch-www
|
||||
branch: master
|
||||
after_deploy:
|
||||
- if [ $TRAVIS_BRANCH == 'master' ]; then export FASTLY_SERVICE_ID=$FASTLY_SERVICE_ID_PRODUCTION S3_BUCKET_NAME=$S3_BUCKET_NAME_PRODUCTION; else export FASTLY_SERVICE_ID=$FASTLY_SERVICE_ID_STAGING S3_BUCKET_NAME=$S3_BUCKET_NAME_STAGING; fi;
|
||||
- make configure-fastly
|
||||
|
|
3
Makefile
3
Makefile
|
@ -44,6 +44,9 @@ translations:
|
|||
webpack:
|
||||
$(WEBPACK) --bail
|
||||
|
||||
configure-fastly:
|
||||
$(NODE) ./bin/configure-fastly.js
|
||||
|
||||
# ------------------------------------
|
||||
|
||||
start:
|
||||
|
|
250
bin/configure-fastly.js
Normal file
250
bin/configure-fastly.js
Normal file
|
@ -0,0 +1,250 @@
|
|||
var defaults = require('lodash.defaults');
|
||||
var fastly = require('fastly')(process.env.FASTLY_API_KEY);
|
||||
var glob = require('glob');
|
||||
var path = require('path');
|
||||
|
||||
var routes = require('../server/routes.json');
|
||||
var serviceId = process.env.FASTLY_SERVICE_ID;
|
||||
var s3Bucket = process.env.AWS_S3_BUCKET_NAME;
|
||||
|
||||
var extraAppRoutes = [
|
||||
// Homepage with querystring.
|
||||
// TODO: Should this be added for every route?
|
||||
'^/\\?',
|
||||
// Version output by build
|
||||
'/version\.txt$',
|
||||
// View html
|
||||
'^/[^\/]*\.html'
|
||||
]
|
||||
|
||||
var getFastlyAPIPrefix = function (serviceId, version) {
|
||||
return '/service/' + encodeURIComponent(serviceId) + '/version/' + version;
|
||||
}
|
||||
|
||||
var getStaticPaths = function (pathToStatic) {
|
||||
// Given the relative path to the static directory, return an array of
|
||||
// patterns matching the files and directories there.
|
||||
var staticPaths = glob.sync(path.resolve(__dirname, pathToStatic));
|
||||
return staticPaths.map( function (pathName) {
|
||||
// Reduce absolute path to relative paths like '/js'
|
||||
var base = path.dirname(path.resolve(__dirname, pathToStatic));
|
||||
return '^' + pathName.replace(base, '');
|
||||
});
|
||||
}
|
||||
|
||||
var getViewPaths = function (routes) {
|
||||
// Given a list of express routes, return a list of patterns to match
|
||||
// the express route and a static view file associated with the route
|
||||
return routes.map(function (route) {
|
||||
return route.pattern;
|
||||
});
|
||||
}
|
||||
|
||||
var pathsToCondition = function (paths) {
|
||||
// Given a list of patterns for paths, OR all of them together into one
|
||||
// string suitable for a Fastly condition
|
||||
return paths.reduce(function(conditionString, pattern) {
|
||||
var patternCondition = 'req.url ~ "' + pattern + '"';
|
||||
return conditionString + (conditionString ? ' || ' : '') + patternCondition;
|
||||
}, '');
|
||||
}
|
||||
|
||||
var getAppRouteCondition = function (pathToStatic, routes, additionalPaths) {
|
||||
var staticPaths = getStaticPaths(pathToStatic);
|
||||
var viewPaths = getViewPaths(routes);
|
||||
var allPaths = [].concat(staticPaths, viewPaths, additionalPaths);
|
||||
return pathsToCondition(allPaths);
|
||||
}
|
||||
|
||||
var negateCondition = function (condition) {
|
||||
return '!(' + condition + ')';
|
||||
}
|
||||
|
||||
var getConditionNameForView = function (view) {
|
||||
return 'routes/' + view;
|
||||
};
|
||||
|
||||
var getHeaderNameForView = function (view) {
|
||||
return 'rewrites/' + view;
|
||||
}
|
||||
|
||||
var getPassRequestConditionName = function () {
|
||||
return 'Pass';
|
||||
};
|
||||
|
||||
var getNotPassRequestConditionName = function () {
|
||||
return '!(Pass)';
|
||||
};
|
||||
|
||||
var getPassCacheConditionName = function () {
|
||||
return 'Cache ' + getPassRequestConditionName();
|
||||
};
|
||||
|
||||
var getRouteHeaderConditionPairs = function (routes) {
|
||||
return routes.map(function (route, id) {
|
||||
return {
|
||||
condition: {
|
||||
name: getConditionNameForView(route.view),
|
||||
statement: 'req.url ~ "' + route.pattern + '"',
|
||||
type: 'REQUEST',
|
||||
priority: 10
|
||||
},
|
||||
header: {
|
||||
name: getHeaderNameForView(route.view),
|
||||
action: 'set',
|
||||
ignore_if_set: 0,
|
||||
type: 'request',
|
||||
dst: 'url',
|
||||
src: '"/' + route.view + '.html"',
|
||||
request_condition: getConditionNameForView(route.view),
|
||||
priority: id
|
||||
},
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var getLatestVersion = function (serviceId, cb) {
|
||||
var url = '/service/'+ encodeURIComponent(serviceId) +'/version';
|
||||
fastly.request('GET', url, function (err, versions) {
|
||||
if (err) return cb(err);
|
||||
var latestVersion = versions.reduce(function (latestVersion, version) {
|
||||
if (!latestVersion) return version;
|
||||
if (version.number > latestVersion.number) return version;
|
||||
return latestVersion;
|
||||
});
|
||||
return cb(null, latestVersion);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
var getHeaders = function (serviceId, version, cb) {
|
||||
var url = getFastlyAPIPrefix(serviceId, version) + '/header';
|
||||
fastly.request('GET', url, function (err, headers) {
|
||||
if (err) return cb(err);
|
||||
return cb(null, headers);
|
||||
});
|
||||
};
|
||||
|
||||
var setCondition = function (serviceId, version, name, condition, callback) {
|
||||
var putUrl = getFastlyAPIPrefix(serviceId, version) + '/condition/' + encodeURIComponent(name);
|
||||
var postUrl = getFastlyAPIPrefix(serviceId, version) + '/condition';
|
||||
var cb = callback;
|
||||
return fastly.request('PUT', putUrl, condition, function (err, response) {
|
||||
if (err && err.statusCode === 404) return fastly.request('POST', postUrl, condition, cb);
|
||||
return cb(err, response);
|
||||
});
|
||||
};
|
||||
|
||||
var setHeader = function (serviceId, version, name, header, callback) {
|
||||
var putUrl = getFastlyAPIPrefix(serviceId, version) + '/header/' + encodeURIComponent(name);
|
||||
var postUrl = getFastlyAPIPrefix(serviceId, version) + '/header';
|
||||
var cb = callback
|
||||
return fastly.request('PUT', putUrl, header, function (err, response) {
|
||||
if (err && err.statusCode === 404) return fastly.request('POST', postUrl, header, cb);
|
||||
return cb(err, response);
|
||||
});
|
||||
};
|
||||
|
||||
var notPassCondition = {
|
||||
name: getNotPassRequestConditionName(),
|
||||
statement: getAppRouteCondition('../static/*', routes, extraAppRoutes),
|
||||
type: 'REQUEST',
|
||||
priority: 10
|
||||
};
|
||||
|
||||
var passCondition = {
|
||||
name: getPassRequestConditionName(),
|
||||
statement: negateCondition(notPassCondition.statement),
|
||||
type: 'REQUEST',
|
||||
priority: 10
|
||||
};
|
||||
|
||||
var routeHeaderConditionPairs = getRouteHeaderConditionPairs(routes);
|
||||
|
||||
getLatestVersion(serviceId, function (err, version) {
|
||||
if (err) return console.error(err);
|
||||
if (version.active) return console.error('Latest version is active. Will not modify.');
|
||||
if (version.locked) return console.error('Latest version is locked. Cannot modify.');
|
||||
setCondition(
|
||||
serviceId, version.number, notPassCondition.name, notPassCondition,
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Failed to set !(Pass) request condition:');
|
||||
console.dir(err);
|
||||
console.error('Could not set bucket header without setting !(Pass) condition');
|
||||
return;
|
||||
}
|
||||
|
||||
var bucketNameHeaderName = 'Bucket name';
|
||||
setHeader(
|
||||
serviceId, version.number, bucketNameHeaderName,
|
||||
{
|
||||
name: bucketNameHeaderName,
|
||||
action: 'set',
|
||||
ignore_if_set: 0,
|
||||
type: 'REQUEST',
|
||||
dst: 'http.host',
|
||||
src: '"' + s3Bucket + '"',
|
||||
request_condition: notPassCondition.name,
|
||||
priority: 1
|
||||
},
|
||||
function (err) {
|
||||
if (err) return console.error('Failed to set Bucket name header:', err);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
setCondition(
|
||||
serviceId, version.number, passCondition.name, passCondition,
|
||||
function (err) {
|
||||
if (err) return console.error('Failed to set Pass condition:', err);
|
||||
fastly.request(
|
||||
'PUT',
|
||||
getFastlyAPIPrefix(serviceId, version.number) + '/backend/femto',
|
||||
{request_condition: passCondition.name},
|
||||
function (err) {
|
||||
if (err) return console.error('Failed to set femto backend to use Pass condition.', err)
|
||||
}
|
||||
);
|
||||
fastly.request(
|
||||
'PUT',
|
||||
getFastlyAPIPrefix(serviceId, version.number) + '/request_settings/Pass',
|
||||
{request_condition: passCondition.name},
|
||||
function (err) {
|
||||
if (err) return console.error('Failed to set Pass request setting to use Pass condition.', err);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
var passCacheCondition = defaults({name: getPassCacheConditionName(), type: 'CACHE'}, passCondition);
|
||||
setCondition(
|
||||
serviceId, version.number, getPassCacheConditionName(), passCacheCondition,
|
||||
function (err) {
|
||||
if (err) return console.error('Failed to set Cache Pass condition:', err, passCacheCondition);
|
||||
fastly.request(
|
||||
'PUT',
|
||||
getFastlyAPIPrefix(serviceId, version.number),
|
||||
{cache_condition: getPassCacheConditionName()},
|
||||
function (err) {
|
||||
if (err) return console.error('Failed to set Pass cache setting to use Cache Pass condition', err, cachePassCondition);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
routeHeaderConditionPairs.forEach(function (pair) {
|
||||
var condition = pair.condition;
|
||||
var header = pair.header;
|
||||
setCondition(
|
||||
serviceId, version.number, condition.name, condition,
|
||||
function (err, response) {
|
||||
if (err) return console.error('Failed to set route condition', condition.name, err);
|
||||
setHeader(
|
||||
serviceId, version.number, header.name, header,
|
||||
function (err) {
|
||||
if (err) return console.error('Failed to set route rewrite header', header.name, err);
|
||||
}
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -39,6 +39,7 @@
|
|||
"eslint": "1.3.1",
|
||||
"eslint-plugin-react": "3.3.1",
|
||||
"exenv": "1.2.0",
|
||||
"fastly": "1.2.1",
|
||||
"file-loader": "0.8.4",
|
||||
"glob": "5.0.15",
|
||||
"json-loader": "0.5.2",
|
||||
|
|
|
@ -1,46 +1,46 @@
|
|||
[
|
||||
{
|
||||
"pattern": "/",
|
||||
"pattern": "^/?$",
|
||||
"view": "splash",
|
||||
"title": "Imagine, Program, Share"
|
||||
},
|
||||
{
|
||||
"pattern": "/about",
|
||||
"pattern": "^/about$",
|
||||
"view": "about",
|
||||
"title": "About"
|
||||
},
|
||||
{
|
||||
"pattern": "/components",
|
||||
"pattern": "^/components$",
|
||||
"view": "components",
|
||||
"title": "Components"
|
||||
},
|
||||
{
|
||||
"pattern": "/hoc",
|
||||
"pattern": "^/hoc$",
|
||||
"view": "hoc",
|
||||
"title": "Hour of Code"
|
||||
},
|
||||
{
|
||||
"pattern": "/info/credits",
|
||||
"pattern": "^/info/credits$",
|
||||
"view": "credits",
|
||||
"title": "Credits"
|
||||
},
|
||||
{
|
||||
"pattern": "/info/cards",
|
||||
"pattern": "^/info/cards$",
|
||||
"view": "cards",
|
||||
"title": "Cards"
|
||||
},
|
||||
{
|
||||
"pattern": "/info/communityblocks-interviews",
|
||||
"pattern": "^/info/communityblocks-interviews$",
|
||||
"view": "communityblocks-interviews",
|
||||
"title": "Community Blocks Beta Tester Interviews"
|
||||
},
|
||||
{
|
||||
"pattern": "/jobs",
|
||||
"pattern": "^/jobs$",
|
||||
"view": "jobs",
|
||||
"title": "Jobs"
|
||||
},
|
||||
{
|
||||
"pattern": "/wedo",
|
||||
"pattern": "^/wedo$",
|
||||
"view": "wedo2",
|
||||
"title": "LEGO WeDo 2.0"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue