Wicket
Wicket

Reputation: 38416

How to map client-side code to Source Code

Recently I learned that it's possible to show JavaScript code added to the DOM / Dev Tools Elements tab by using document.write, eval, etc. to the Source panel of Chrome Dev Tools and other browsers. This is done by adding a comment before the closing <script>:

<script>
...

//# sourceURL=filename.js
</script>

I tried to apply this but the comment is not added by the HtmlService to browser. How can the Google Apps Script client-side code be shown in the Dev Tools Sources panel?

Below is my attempt of adding sourceURL as shown above Code.gs

function doGet(e) {
  return HtmlService.createHtmlOutput()
    .append(`
  <!DOCTYPE html>
  <html>
  <head>
  <base target="_top">
  </head>
  <body>
  <form>
  <input type="text" name="something" value="default value"><br>
  <button type="submit">Submit</button>
  </form>
  <script>
  function formSubmitHandler(){
    google.script.run.doSomething(event.currentTarget)
  }
  //# sourceURL=javascript.js
  </script>
  </body>
  </html>
`)
    .setTitle('Demo')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function doSomething(formData){
  console.log(JSON.stringify(formData.something));
}

Related

References

Upvotes: 1

Views: 657

Answers (2)

Stefan C
Stefan C

Reputation: 94

In my case, I was trying to get this to work in the context of AppsScript running from a Google Sheet extension, producing a custom dialog. Unfortunately, while your hack to getting the //# sourceURL=filename.js pragma comment into the HtmlOutput worked, there was still something else downstream stripping all the comments.

So I had to work around it by returning output that was a simple script to add a script element with the generated code, plus the pragma comment, into the DOM at runtime. Even more hacky, but without this, it's impossible to debug...

Edited/Added:

Here's the essence of what I had to do in order to go from a JavaScript module in my repo (.js containing utility functions, for example) to the same file in DevTools loaded as a result of opening a custom dialog given an HTML template in GAS.

I start with a module util.js:

// a comment to preserve
const sayHello = () => console.log('hello');

window.myApp = { sayHello };
  1. Run this through a Rollup IIFE build. The output is util.js.html (.html because that's what clasp will sync and what you can load as a template).

Using Rollup is key because it doesn't mangle its output like Webpack does into a single-line eval() statement that absolutely requires a source map to read. So the code it produces is code largely as you wrote it, but perhaps transpiled to hit an ES target you set, or even convert JSX into JS (or TypeScript into JS).

The result is a self-executing script:

(function () {
'use strict';

// a comment to preserve
const sayHello = () => console.log('hello');

window.myApp = { sayHello };

})();
  1. Create a GAS function called includeScript.

  2. In your dialog's HTML template file, include your generated script using the function:

<?!= includeScript('path/to/generated/util.js.html') />

The includeScript() function is where all the magic happens.

  1. Load the script template file's content while preserving comments:
  // we basically use this just to be able to load the raw file from the cloud-based file system
  // NOTE: crucially, this retains comments
  // NOTE: we have to wrap in a <script> block to make sure that the HtmlOutput's content, which
  //  we get later on, will NOT XML-encode the string, which would mess-up things like less-than
  //  operators ('<' would become '&lt;'); by putting the raw content in a <script> block, its
  //  treated as CDATA instead of HTML code that needs to be escaped
  const rawContent = `<script>${HtmlService.createTemplateFromFile(
    'path/to/generated/util.js.html'
  ).getRawContent()}</script>`;
  const template = HtmlService.createTemplate(rawContent);

Note that this is the point where you could apply a scope to the template prior to evaluating it, if you wanted/needed to.

  1. Get the actual code out the script (still with comments so far):
  const output = template.evaluate();

  // unwrap the now-evaluated raw content from the temporary <script> block because we're now
  //  going to put the code _into_ an actual <script> element in the DOM (when the generated
  //  output runs in the browser)
  let code = output
    .getContent()
    .replace(/^<script>/gm, '')
    .replace(/<\/script>$/gm, '');

Note that the technique suggested in this post (above) must only work for Google Web Services, not GAS, because comments are still stripped. So now we have to do something really hacky in order to get the raw code we now have in code while preserving comments.

First, add the pragma comment to the source that identifies its original module structure/path/directory so that you can find this file in DevTools once it loads:

code = `${code}\n//# sourceURL=path/to/original/util.js`;

Finally, return the actual string that the HtmlService will use in the HTML file it's loading. Remember, the includeScript() function is executing in the template you're giving to SpreadsheetApp.getUi().showModalDialog(...):

<?!= includeScript('path/to/generated/util.js.html') />

What we need to do is URI-encode (to preserve extended characters in your strings), and Base64-encode to preserve quotes and such that would otherwise get converted into HTML characters like &quot;, the code so that we end-up with a string that the HtmlService essentially won't touch (i.e. strip comments, including that sourceURL pragma critical to debugging in DevTools).

And because the browser won't load this on its own, we need to wrap that blob into a little loader function that will undo these encodings in the browser:

    return `<script>
(function () {
  const code = decodeURIComponent(atob('${Utilities.base64Encode(
    encodeURIComponent(code)
  )}'));
  const scriptEl = document.createElement('script');
  scriptEl.textContent = code;
  document.body.appendChild(scriptEl);
})();
</script>`;

That's for the "dev" version of your code. The "prod" version doesn't need to do all this because presumably, you don't need comments in your prod version, and you probably also minify it anyway.

So for "prod", this is all you need instead of the self-executing function that unwraps the encoding (because you don't need to encode the code in the first place since you don't need to preserve anything):

return `<script>${code}</script>`;

And I do have Dev and Prod builds of each of my JS and JSX modules, through Rollup. My Dev builds preserve comments and don't minify, while my Prod builds strip comments and minify.

Hopefully this helps... It's too much code in too many files to just post here. I should really OSS my GAS build solution, but that's for another time.

Upvotes: 2

Wicket
Wicket

Reputation: 38416

Instead of adding the whole client-side code at once (using a single HtmlOutput.append ), split the code in at least two parts. The first one should include the code from the top to the first / of //# sourceURL=javascript.js, the second part should add the rest of the code. The key here is to avoid having // added at the same time (instead of using a single HtmlOutput.append use two).

function doGet(e) {
  return HtmlService.createHtmlOutput()
    .append(`
  <!DOCTYPE html>
  <html>
  <head>
  <base target="_top">
  </head>
  <body>
  <form>
  <input type="text" name="something" value="default value"><br>
  <button type="submit">Submit</button>
  </form>
  <script>
  function formSubmitHandler(){
    google.script.run.doSomething(event.currentTarget)
  }
  /`)
  .append(`/# sourceURL=javascript.js
  </script>
  </body>
  </html>
`)
    .setTitle('Demo')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function doSomething(formData){
  console.log(JSON.stringify(formData.something));
}

I also tried this in a prototype SPA using templated HTML with multiple files pulled from multiple libraries (each library has one or two sets of three .html files, index, css and js, each seat corresponds to module having at least one form and one list view. The final HtmlOutput has > 20 <stript>, all are mapped correctly to Source Code.

The JavaScript code mapped to Source Code will appear as show in the following image:

Right clicking the file name and selecting Copy link address will return something like this:

https://n-hyluq5mztdwi5brxcufwcb4wfggugjbof23qiby-0lu-script.googleusercontent.com/javascript.js

Upvotes: 3

Related Questions